Тестирование БД мобильного Delphi-приложения

в 21:08, , рубрики: Delphi, DUnitX, Firebird/Interbase, FireMonkey, IBLite, Тестирование мобильных приложений, метки: ,

В предыдущем материале «Выбор СУБД для мобильного Delphi-приложения», как следует из его названия, был показан первый этап в разработке той подсистемы приложения, что отвечает за хранение и бо́льшую часть обработки его данных; уточнение про «бо́льшую часть» сделано неспроста, т. к. в итоге обозначенный выбор пал на СУБД Interbase именно из-за возможности применять хранимые процедуры (ХП), которые и стали сосредоточением основной логики по работе с данными, оставляя за Делфи-кодом несложную задачу по их вызову.

Для лучшего понимания необходимости тестирования в данном конкретном случае, нужно отметить, что в описанном проекте изначально была задана довольно высокая планка качества, поддержание которой в части функционала, реализованного в процедурах, достиглось, в том числе, за счёт автотестов, проверяющих ключевые ХП (они ответственны за критический для приложения функционал – систему рекомендаций). Именно один из способов организации такого тестирования – на основе DUnitX и XML – и является предметом статьи.

А был ли прок?

Если Вы сами уже применяли, либо знакомы с литературой, объясняющей выгоду от автоматических тестов, то этот полувводный раздел вполне можно пропустить, тем более что он не содержит какой-либо теории, а приводит примеры пользы, принесённой данному конкретному проекту.

Если отбросить примитивные, глупые и легкообнаруживаемые ошибки навроде всегда пустого результата из-за забытого SUSPEND – т. е. все те, что сразу проявляют себя при обязательном ручном тестировании только что написанного кода на минимальных тестовых данных, то можно выделить 2 категории успешно устранённых проблем:

  1. Наиболее очевидная, ради которой, собственно, и пишутся в основном подобные тесты, – это пласт логических ошибок, когда требуемый алгоритм в чём-то реализован неверно. Автотестам удалось выявить до десятка таких изъянов, причём ручные проверки эти случаи с большой долей вероятности бы не обнаружили, ибо требовались особые, специально подобранные тестовые данные.
  2. Не такая богатая на урожай, но тоже приводящая к недопустимым ошибкам, категория – несовершенство самого Interbase. Благодаря тестам состоялось неприятное знакомство с такими нюансами, как нестабильный курсор и соединение с ХП, параметры которой берутся из таблиц запроса.

Узнав про прелести автоматического тестирования, неискушённый разработчик может захотеть применять его везде и всюду, однако… Мир во всём мире – это конечно замечательно, но какова цена таких стремлений? Автор потратил на собственно сами тесты – составление тест-плана, подбор тестовых данных, примерно 3-4 недели (и это без учёта времени на создание инфраструктуры для их запуска, о чём раздел ниже). Поэтому рекомендуется соотносить выгоду и затраты индивидуально в каждом конкретном случае.

От слов – к делу

Перейдём к практической части, предварительно сформулировав технические требования, которым должны удовлетворять автотесты:

  1. Кроссплатформенность: запуск на Windows и мобильных устройствах; настольная ОС нужна по вполне очевидной причине – прогон тестов выполняется во много раз быстрее, следовательно их разработка ведётся на ней, после чего идёт финальный прогон на мобильных ОС.
  2. Использование библиотеки DUnitX – она, в отличие от DUnit, стабильно развивается и имеет готовый графический интерфейс на FireMonkey (пусть и среднего пошиба), одинаковый для всех платформ.
  3. Отделение инфраструктуры по запуску (DUnitX-части) от собственно смыслового наполнения тестов – тестовых данных, перечня ХП с параметрами выполнения, ожидаемых результатов – всё это должно храниться в XML-файле. Данное требование несколько усложняет задачу, но даёт и преимущества: суть тестов остаётся незамутнённой, исключительно сервисный Делфи-код не усложняет их, и второе – в случае необходимости можно безболезненно перейти на другую тестовую библиотеку. Небольшой фрагмент такого XML поможет лучше понять сказанное (отсылки к нему будут встречаться далее по тексту):
    <?xml version="1.0" encoding="utf-8"?>
    <Тестирование>
      <Действия_до_теста>
        <Очистка_БД>
          <Таблица Имя="SHOPPING_LIST"/>
        </Очистка_БД>
      </Действия_до_теста>
      <Действия_после_теста/>
      <Тесты>
        <!--Простейшие случаи с одним товаром.-->
        <Тест>...</Тест>
        ...
        <!--2 товара, без изменения уровня.-->
        <Тест>
          <Тестовые_данные>
            <Таблица Имя="SHOPPING_LIST">
              <Запись>
                <ID Тип="Целое">1</ID>
                <NAME Тип="Строка">Тестовый список</NAME>
                <ADD_DATE Тип="Дата_и_время">1.2.2015</ADD_DATE>
              </Запись>
            </Таблица>
            <Таблица Имя="LIST_ITEM">
              <Запись>
                <ID Тип="Целое">1</ID>
                <LIST_ID Тип="Целое">1</LIST_ID>
                <GOODS_ID Тип="Целое">107</GOODS_ID>
                <AMOUNT Тип="Дробное">1</AMOUNT>
                <ADD_DATE Тип="Дата_и_время">25.2.2015 15:12</ADD_DATE>
              </Запись>
              ...
            </Таблица>
          </Тестовые_данные>
          <Процедура Имя="RECOMMEND_GOODS_TO_EMPTY_LIST" Вид_результата="Запись">
            <Выполнение>
              <Входные_параметры>
                <TARGET_DATE Тип="Дата_и_время">16.9.2014</TARGET_DATE>
              </Входные_параметры>
            </Выполнение>
            ...
            <Выполнение>
              <Входные_параметры>
                <TARGET_DATE Тип="Дата_и_время">1.3.2015</TARGET_DATE>
              </Входные_параметры>
              <Результат>
                <Запись>
                  <GOODS_ID Тип="Целое">107</GOODS_ID>
                  <RECOMMENDATION_ID Тип="Целое">0</RECOMMENDATION_ID>
                  <ACCURACY Тип="Дробное">0.75</ACCURACY>
                </Запись>
              </Результат>
            </Выполнение>
            ...
          </Процедура>
        </Тест>
        <Тест>...</Тест>
        ...
      </Тесты>
    </Тестирование>

Расширение DUnitX

Выбранная библиотека позволяет разместить тесты в любом классе, пометив его и соответствующие методы специальными атрибутами, например так:

[TestFixture]
TTestSet = class
public
  [SetupFixture]
  procedure Setup;
  [TearDownFixture]
  procedure Teardown;

  [Setup]
  procedure TestSetup;
  [TearDown]
  procedure TestTeardown;

  [Test]
  procedure Test1;
  [Test]
  [TestCase('Случай 1', '1,Строка1')]
  [TestCase('Случай 2', '2,Строка2')]
  procedure Test2(const IntegerParameter: Integer; const StringParameter: string);
end;

Здесь TTestSet – это тестовый набор (fixture, в терминах библиотеки) из 2-х тестов, второй из которых выполнится пару раз – с обоими указанными вариантами параметров. Однако полностью такой стандартный способ нам не подходит, потому что количество тестов и значения параметров к ним задаются статически, на этапе компиляции, а требуется формировать динамически как перечень тестовых наборов (по одному на каждый XML-файл), так и список тестов в каждом из них (беря из соответствующего файла по тегу «Тест»).

Появившееся препятствие может быть легко преодолено за счёт механизма плагинов – DUnitX позволяет создавать наборы гибко и наполнять их по своим потребностям; более того, «коробочный» механизм, основанный на атрибутах, также реализован в виде плагина (см. DUnitX.FixtureProviderPlugin.pas), а значит неплохо проверен и ожидать сюрпризов при работе с ним не приходится.

Интерфейсная часть модуля

Рассмотрение модуля с новым плагином начнём с получившихся классов, оставив реализацию методов на чуть позже:

unit Tests.XMLFixtureProviderPlugin;

interface

uses
  DUnitX.Extensibility;

type
  TXMLFixtureProviderPlugin = class(TInterfacedObject, IPlugin)
  protected
    procedure GetPluginFeatures(const context: IPluginLoadContext);
  end;

implementation

uses
  DUnitX.TestFramework, DUnitX.Utils,
  ...
  Xml.XMLDoc, {$IFDEF MSWINDOWS} Xml.Win.msxmldom {$ELSE} Xml.omnixmldom {$ENDIF};

type
  TXMLFixtureProvider = class(TInterfacedObject, IFixtureProvider)
  protected
    procedure GenerateTests(const Fixture: ITestFixture; const FileName: string);
    procedure Execute(const context: IFixtureProviderContext);
  end;

  TDBTests = class abstract
  {
  Опущены поля и методы, ответственные за подключение к БД, работу с ХП, а также
  приведение базы к «чистому», исходному состоянию, чтобы исключить влияние предыдущих
  тестовых наборов на результаты.
  }
  ...
  public
    procedure Setup; virtual;
    procedure Teardown; virtual;

    procedure TestSetup; virtual; abstract;
    procedure TestTeardown; virtual; abstract;

    procedure Test(const TestIndex: Integer); virtual; abstract;
  end;

  TXMLBasedDBTests = class(TDBTests)
  private
    const
      TestsTag = 'Тесты';
      ...
  private
    FFileName: string;
    FXML: TXMLDocument;

    // Опущены поля и методы, ответственные за работу с XML.
    ...
  public
    procedure AfterConstruction; override;
    {
    Вместо конструктора приходится вынужденно использовать AfterConstruction – иначе
    метод Setup будет проигнорирован при запуске тестов. Впервые такое поведение
    появилось здесь: https://github.com/VSoftTechnologies/DUnitX/commit/267111f4feec77d51bf2307a194f44106d499680#diff-745fb4ee38a43631f57d1b6ef88e0ffcR212
    }
    destructor Destroy; override;

    [SetupFixture]
    procedure Setup; override;
    [TearDownFixture]
    procedure Teardown; override;

    [Setup]
    procedure TestSetup; override;
    [TearDown]
    procedure TestTeardown; override;

    [Test]
    procedure Test(const TestIndex: Integer); override;

    function DetermineTestIndexes: TArray<Integer>;

    property FileName: string read FFileName write FFileName;
  end;

// Реализация методов далее в статье...

initialization
  TDUnitX.RegisterPlugin(TXMLFixtureProviderPlugin.Create);

end.

Первые два класса – TXMLFixtureProviderPlugin и TXMLFixtureProvider, нужны для встраивания в существующую систему плагинов и интересны только реализацией своих методов. Следующий, TDBTests, тоже малоинтересен, т. к. по большому счёту выделен в иерархии с целью инкапсулировать БД-специфичные вещи, поэтому стоит перейти сразу к его наследнику – TXMLBasedDBTests. Его обязанности привязаны к этапам его жизни:

  • Сразу после старта приложения, ответственного за запуск тестов, DUnitX выполняет формирование перечня тестовых наборов: в этот момент происходит создание объектов указанного класса с задействованием метода DetermineTestIndexes, возвращающего индексы дочерних узлов узла «Тесты» (см. XML-фрагмент выше). При его реализации обойтись малой кровью – просто узнав количество узлов-потомков и вернув, условно, последовательность индексов от 1 до N – не получится, потому что, прежде всего, некоторые узлы являются комментариями, а также возможно временное отключение теста (вместо его удаления из файла).
    Итогом же, на каждый полученный индекс будет добавлен тест в набор.
  • Данный этап, в общем случае, может и отсутствовать, но если от пользователя поступает команда на прогон тестов (путём нажатия кнопки), то следует такая цепочка вызовов:
    1. Setup
    2. Неоднократно выполняется Test, которому передаются полученные на первом этапе индексы. Каждому его вызову предшествует TestSetup, а после завершения следует TestTeardown.
    3. Teardown

Реализация методов

После знакомства с назначением классов, осталось продемонстрировать оставшуюся часть кода:

  1. TXMLFixtureProviderPlugin
    procedure TXMLFixtureProviderPlugin.GetPluginFeatures(const context: IPluginLoadContext);
    begin
      context.RegisterFixtureProvider(TXMLFixtureProvider.Create);
    end;
  2. TXMLFixtureProvider
    procedure TXMLFixtureProvider.Execute(const context: IFixtureProviderContext);
    var
      XMLDirectory, XMLFile: string;
    begin
      {$IFDEF MSWINDOWS}
      XMLDirectory := {Путь к папке с тестами.};
      {$ELSE}
      XMLDirectory := TPath.GetDocumentsPath;
      {$ENDIF}
    
      for XMLFile in TDirectory.GetFiles(XMLDirectory, '*.xml') do
        GenerateTests
          ( 
          context.CreateFixture(TXMLBasedDBTests, TPath.GetFileNameWithoutExtension(XMLFile), ''), 
          XMLFile 
          );
    end;
    
    procedure TXMLFixtureProvider.GenerateTests(const Fixture: ITestFixture; const FileName: string);
    
      procedure FillSetupAndTeardownMethods(const RTTIMethod: TRttiMethod);
      var
        Method: TMethod;
        TestMethod: TTestMethod;
      begin
        Method.Data := Fixture.FixtureInstance;
        Method.Code := RTTIMethod.CodeAddress;
        TestMethod := TTestMethod(Method);
    
        if RTTIMethod.HasAttributeOfType<SetupFixtureAttribute> then
          Fixture.SetSetupFixtureMethod(RTTIMethod.Name, TestMethod);
        if RTTIMethod.HasAttributeOfType<TearDownFixtureAttribute> then
          Fixture.SetTearDownFixtureMethod(RTTIMethod.Name, TestMethod, RTTIMethod.IsDestructor);
    
        if RTTIMethod.HasAttributeOfType<SetupAttribute> then
          Fixture.SetSetupTestMethod(RTTIMethod.Name, TestMethod);
        if RTTIMethod.HasAttributeOfType<TearDownAttribute> then
          Fixture.SetTearDownTestMethod(RTTIMethod.Name, TestMethod);
      end;
    
    var
      XMLTests: TXMLBasedDBTests;
      RTTIContext: TRttiContext;
      RTTIMethod: TRttiMethod;
      TestIndex: Integer;
    begin
      XMLTests := Fixture.FixtureInstance as TXMLBasedDBTests;
      XMLTests.FileName := FileName;
    
      RTTIContext := TRttiContext.Create;
      try
        for RTTIMethod in RTTIContext.GetType(Fixture.TestClass).GetMethods do
        begin
          FillSetupAndTeardownMethods(RTTIMethod);
    
          if RTTIMethod.HasAttributeOfType<TestAttribute> then
            for TestIndex in XMLTests.DetermineTestIndexes do
              Fixture.AddTestCase( RTTIMethod.Name, TestIndex.ToString, '', '', RTTIMethod, True, [TestIndex] );
        end;
      finally
        RTTIContext.Free;
      end;
    end;
  3. TDBTests
    procedure TDBTests.Setup;
    begin
      // Подключение к БД и приведение её к эталонному состоянию.
      ...
    end;
    
    procedure TDBTests.Teardown;
    begin
      // Отключение от БД.
      ...
    end;
  4. TXMLBasedDBTests
    procedure TXMLBasedDBTests.AfterConstruction;
    begin
      inherited;
    
      // Создание FXML.
      ...
      FXML.DOMVendor := GetDOMVendor({$IFDEF MSWINDOWS} SMSXML {$ELSE} sOmniXmlVendor {$ENDIF});
    
      // Прочие инициализации.
      ...
    end;
    
    destructor TXMLBasedDBTests.Destroy;
    begin
      // Освобождение ресурсов.
      ...
    
      inherited;
    end;
    
    function TXMLBasedDBTests.DetermineTestIndexes: TArray<Integer>;
    var
      TestsNode: IXMLNode;
      TestIndex: Integer;
      TestIndexList: TList<Integer>;
    begin
      FXML.LoadFromFile(FFileName);
      try
        TestsNode := FXML.DocumentElement.ChildNodes[TestsTag];
    
        TestIndexList := TList<Integer>.Create;
        try
          for TestIndex := 0 to TestsNode.ChildNodes.Count - 1 do
            if {Узел является тестом и не должен быть пропущен?} then
              TestIndexList.Add(TestIndex);
              
          Result := TestIndexList.ToArray;
        finally
          TestIndexList.Free;
        end;
      finally
        FXML.Active := False;
      end;
    end;
    
    procedure TXMLBasedDBTests.Setup;
    begin
      inherited;
    
      FXML.LoadFromFile(FFileName);
    end;
    
    procedure TXMLBasedDBTests.Teardown;
    begin
      FXML.Active := False;
    
      inherited;
    end;
    
    procedure TXMLBasedDBTests.TestSetup;
    begin
      inherited;
    
      // Выполнение указанного в узле «Действия_до_теста».
      ...
    end;
    
    procedure TXMLBasedDBTests.TestTeardown;
    begin
      inherited;
    
      // Выполнение указанного в узле «Действия_после_теста».
      ...
    end;
    
    procedure TXMLBasedDBTests.Test(const TestIndex: Integer);
    var
      TestNode: IXMLNode;
    begin
      inherited;
    
      TestNode := FXML.DocumentElement.ChildNodes[TestsTag].ChildNodes[TestIndex];
    
      // Выполнение действий теста.
      ...
    end;

Графический интерфейс

В требованиях к автотестам упоминалась готовая форма, позволяющая управлять запуском и просматривать его результаты, – речь шла о DUNitX.Loggers.MobileGUI.pas, которая, после небольших косметических доработок, и была применена. Результаты прогона на 3-х платформах, с одним намеренно проваленным тестом, представлены ниже:

  1. Windows (время выполнения 7 с)
    Перечень тестов (Windows) Результаты запуска тестов (Windows)
  2. Android (время выполнения 35 с)
    Перечень тестов (Android) Результаты запуска тестов (Android)
  3. iOS (время выполнения 28 с)
    Перечень тестов (iOS) Результаты запуска тестов (iOS)

Автор: Пья́нков Сергей Михайлович

Источник

Поделиться

* - обязательные к заполнению поля