В предыдущем материале «Выбор СУБД для мобильного Delphi-приложения», как следует из его названия, был показан первый этап в разработке той подсистемы приложения, что отвечает за хранение и бо́льшую часть обработки его данных; уточнение про «бо́льшую часть» сделано неспроста, т. к. в итоге обозначенный выбор пал на СУБД Interbase именно из-за возможности применять хранимые процедуры (ХП), которые и стали сосредоточением основной логики по работе с данными, оставляя за Делфи-кодом несложную задачу по их вызову.
Для лучшего понимания необходимости тестирования в данном конкретном случае, нужно отметить, что в описанном проекте изначально была задана довольно высокая планка качества, поддержание которой в части функционала, реализованного в процедурах, достиглось, в том числе, за счёт автотестов, проверяющих ключевые ХП (они ответственны за критический для приложения функционал – систему рекомендаций). Именно один из способов организации такого тестирования – на основе DUnitX и XML – и является предметом статьи.
А был ли прок?
Если Вы сами уже применяли, либо знакомы с литературой, объясняющей выгоду от автоматических тестов, то этот полувводный раздел вполне можно пропустить, тем более что он не содержит какой-либо теории, а приводит примеры пользы, принесённой данному конкретному проекту.
Если отбросить примитивные, глупые и легкообнаруживаемые ошибки навроде всегда пустого результата из-за забытого SUSPEND – т. е. все те, что сразу проявляют себя при обязательном ручном тестировании только что написанного кода на минимальных тестовых данных, то можно выделить 2 категории успешно устранённых проблем:
- Наиболее очевидная, ради которой, собственно, и пишутся в основном подобные тесты, – это пласт логических ошибок, когда требуемый алгоритм в чём-то реализован неверно. Автотестам удалось выявить до десятка таких изъянов, причём ручные проверки эти случаи с большой долей вероятности бы не обнаружили, ибо требовались особые, специально подобранные тестовые данные.
- Не такая богатая на урожай, но тоже приводящая к недопустимым ошибкам, категория – несовершенство самого Interbase. Благодаря тестам состоялось неприятное знакомство с такими нюансами, как нестабильный курсор и соединение с ХП, параметры которой берутся из таблиц запроса.
Узнав про прелести автоматического тестирования, неискушённый разработчик может захотеть применять его везде и всюду, однако… Мир во всём мире – это конечно замечательно, но какова цена таких стремлений? Автор потратил на собственно сами тесты – составление тест-плана, подбор тестовых данных, примерно 3-4 недели (и это без учёта времени на создание инфраструктуры для их запуска, о чём раздел ниже). Поэтому рекомендуется соотносить выгоду и затраты индивидуально в каждом конкретном случае.
От слов – к делу
Перейдём к практической части, предварительно сформулировав технические требования, которым должны удовлетворять автотесты:
- Кроссплатформенность: запуск на Windows и мобильных устройствах; настольная ОС нужна по вполне очевидной причине – прогон тестов выполняется во много раз быстрее, следовательно их разработка ведётся на ней, после чего идёт финальный прогон на мобильных ОС.
- Использование библиотеки DUnitX – она, в отличие от DUnit, стабильно развивается и имеет готовый графический интерфейс на FireMonkey (пусть и среднего пошиба), одинаковый для всех платформ.
- Отделение инфраструктуры по запуску (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 – не получится, потому что, прежде всего, некоторые узлы являются комментариями, а также возможно временное отключение теста (вместо его удаления из файла).
Итогом же, на каждый полученный индекс будет добавлен тест в набор. - Данный этап, в общем случае, может и отсутствовать, но если от пользователя поступает команда на прогон тестов (путём нажатия кнопки), то следует такая цепочка вызовов:
Setup
- Неоднократно выполняется
Test
, которому передаются полученные на первом этапе индексы. Каждому его вызову предшествуетTestSetup
, а после завершения следуетTestTeardown
. Teardown
Реализация методов
После знакомства с назначением классов, осталось продемонстрировать оставшуюся часть кода:
TXMLFixtureProviderPlugin
procedure TXMLFixtureProviderPlugin.GetPluginFeatures(const context: IPluginLoadContext); begin context.RegisterFixtureProvider(TXMLFixtureProvider.Create); end;
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;
TDBTests
procedure TDBTests.Setup; begin // Подключение к БД и приведение её к эталонному состоянию. ... end; procedure TDBTests.Teardown; begin // Отключение от БД. ... end;
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-х платформах, с одним намеренно проваленным тестом, представлены ниже:
- Windows (время выполнения 7 с)
- Android (время выполнения 35 с)
- iOS (время выполнения 28 с)
Автор: Пья́нков Сергей Михайлович