Как организовать тестирование БД в dUnit

от автора

Как известно, в xUnit-фреймворках, простейший test-case состоит из последовательности вызовов SetUp, TestSomething, TearDown. И довольно часто в unit-тестировании требуется подготовить какие-то ресурсы перед основными тестами. Типичный пример этого — соединение с базой данных. И логика подсказывает нам, что было бы весьма затратно, запуская несколько тестов, перед каждым устанавливать соединение с БД в SetUp, и отключаться в TearDown.

Пример модуля

... type   TTestDB1 = class(TTestCase)   protected   public     procedure SetUp; override;     procedure TearDown; override;   published     procedure TestDB1_1;     procedure TestDB1_2;   end; ... implementation ... procedure TTestDB1.SetUp; begin   inherited;   // connect to DB end;  procedure TTestDB1.TearDown; begin   // disconnect from DB   inherited; end; ... initialization   RegisterTest(TTestDB1.Suite); end. 

Схема вызовов будет такая:

-- TTestDB1.SetUp ---- TTestDB1.TestDB1_1 -- TTestDB1.TearDown -- TTestDB1.SetUp ---- TTestDB1.TestDB1_2 -- TTestDB1.TearDown 

К тому же с БД может статься, что перед тем, как к БД подключиться, её нужно создать с требуемой структурой.

Для решения такой задачи в dUnit есть класс TTestSetup (описан в модуле TTestExtensions).

Он, по сути, реализует тот же интерфейс ITest, что и TTestCase, то есть ту же схему: SetUp, Test…, TearDown, только вместо вызова тестов происходит вызов всего test-case’а, указанного при его создании. Т.е. видоизменив модуль:

uses   ...   TestExtensions;  type   TTestDBSetup = class(TTestSetup)   public     procedure SetUp; override;     procedure TearDown; override;   // published-методы в TTestSetup не запускаются   end;    TTestDB1 = ... ... implementation ... initialization   RegisterTest(TTestDBSetup.Create(TTestDB1.Suite)); end. 

получим схему вызовов:

-- TTestDBSetup.SetUp  ---- TTestDB1.SetUp ------ TTestDB1.TestDB1_1 ---- TTestDB1.TearDown  ---- TTestDB1.SetUp ------ TTestDB1.TestDB1_2 ---- TTestDB1.TearDown  -- TTestDBSetup.TearDown 

По сути, это схема suite + test-cases. Таким образом, устанавливая соединение к БД в TTestDBSetup.SetUp, мы сделаем это лишь однажды перед запуском TestDB1_1 и TestDB1_2.

Это доcтаточно понятно, когда у нас только один test-case с тестами, требующий соединения с БД. Но что делать, когда мы хотим создать второй test-case, которому также нужно соединение с БД (назовём его TTestDB2 с методами TestDB2_1, TestDB2_2, и т.д)?

Конструктор TTestSetup.Create описан так:

constructor TTestSetup.Create(ATest: ITest; AName: string = ''); 

То есть «включать» в suite можно только лишь один test-case. Если мы напишем так:

  RegisterTest(TTestDBSetup.Create(TTestDB1.Suite));   RegisterTest(TTestDBSetup.Create(TTestDB2.Suite)); 

То получим вызовы по схеме:

-- TTestDBSetup.SetUp  ---- TTestDB1.SetUp ------ TTestDB1.TestDB1_1 ---- TTestDB1.TearDown  ---- TTestDB1.SetUp ------ TTestDB1.TestDB1_2 ---- TTestDB1.TearDown  -- TTestDBSetup.TearDown  -- TTestDBSetup.SetUp  ---- TTestDB2.SetUp ------ TTestDB2.TestDB2_1 ---- TTestDB2.TearDown  ---- TTestDB2.SetUp ------ TTestDB2.TestDB2_2 ---- TTestDB2.TearDown  -- TTestDBSetup.TearDown 

Это не то, что мы хотим. Мы ведь хотим подключиться к БД лишь единожды.

Тут и начинается, собственно, то, что побудило меня написать эту статью. Обратим внимание на второй вариант метода RegisterTest:

procedure RegisterTest(SuitePath: string; test: ITest); begin   assert(assigned(test));   if __TestRegistry = nil then CreateRegistry;   RegisterTestInSuite(__TestRegistry, SuitePath, test); end; 

Что за SuitePath? Смотрим RegisterTestInSuite:

Скрытый текст

procedure RegisterTestInSuite(rootSuite: ITestSuite; path: string; test: ITest); ... begin   if (path = '') then   begin     // End any recursion     rootSuite.addTest(test);   end   else   begin     // Split the path on the dot (.)     dotPos := Pos('.', Path);     if (dotPos <= 0) then dotPos := Pos('\', Path);     if (dotPos <= 0) then dotPos := Pos('/', Path);     if (dotPos > 0) then     begin       suiteName := Copy(path, 1, dotPos - 1);       pathRemainder := Copy(path, dotPos + 1, length(path) - dotPos);     end     else     begin       suiteName := path;       pathRemainder := '';     end; ... 

И видим, что SuitePath разбивается на части, а разделитель этих частей — точка, т.е. это некий «путь suite», в который добавляется регистрируемый test-case.

Пробуем TestDB2 зарегистрировать так (чтобы добавить TTestDB2 «дочерним узлом» в TTestDBSetup):

RegisterTest('Setup decorator ((d) TTestDB1)', TTestDB2.Suite); 

Не получилось:

Смотрим опять код RegisterTestInSuite:

Скрытый текст

procedure RegisterTestInSuite(rootSuite: ITestSuite; path: string; test: ITest); ... begin ...       currentTest.queryInterface(ITestSuite, suite);       if Assigned(suite) then       begin ... 

Видим, что test-case добавляется в ITestSuite, а TTestSetup не реализует этот интерфейс. Как же быть?

Тут подглядываем, например, в библиотеку IndySoap (в ней есть тесты dUnit, организованные по группам) и видим там примерно следующее (запишем сразу применительно к нашим тестам):

... function DBSuite: ITestSuite; begin   Result := TTestSuite.Create('DB tests');   Result.AddTest(TTestDB1.Suite);   Result.AddTest(TTestDB2.Suite); end; ... initialization   RegisterTest(TTestDBSetup.Create(DBSuite)); 

То есть мы создаём suite из наших test-case’ов, а уже этот suite добавляем в TTestSetup.

И вроде бы, всё работает, и всё хорошо. На этом можно бы и закончить.

Но если (точнее, «когда») мы будем добавлять ещё тесты БД (назовём их, TTestDB3), то нам придётся добавлять их и в DBSuite:

... function DBSuite: ITestSuite; begin   ...   Result.AddTest(TTestDB3.Suite); end; ... 

Кроме того, по-хорошему, их надо выносить в отдельный модуль, а уже этот модуль добавлять в модуль с функцией DBSuite. Это изменение DBSuite лично мне не очень нравится (к тому же, визуально в иерархии тестов добавляется «лишний» узел «DB tests», хотя TTestDB1/TTestDB2 могли бы «принадлежать» сразу TTestDBSetup). Я хочу лишь добавить модуль тестов в проект и они «автоматически» добавились бы в TTestDBSetup.

Что ж, сделаем как хотим. Во-первых, мне не нравится имя Setup’а вида «Setup decorator ((d)…». К тому же, потом, когда мы будем регистрировать другие тесты в этот Setup, мы будем использовать это имя. Поменяем. Для этого обратим внимание на следующее:

function TTestSetup.GetName: string; begin   Result := Format(sSetupDecorator, [inherited GetName]); end; 

И на параметр AName в

constructor TTestSetup.Create(ATest: ITest; AName: string = ''); 

Который в итоге присваивается

constructor TAbstractTest.Create(AName: string); ...   FTestName := AName; ... 

Так что, если мы переопределим

... TTestDBSetup = ...   public     function GetName: string; override; ... implementation  ... function TTestDBSetup.GetName: string; begin   Result := FTestName; end; ... initialization   RegisterTest(TTestDBSetup.Create(DBSuite, 'DB')); 

То получим:

Теперь хочется регистрировать test-case’ы сразу при подключении модуля в проект. То есть так:

unit uTestDB3; ... initialization   RegisterTest('DB', TTestDB3.Suite)); 

Для этого надо (вспомним RegisterTestInSuite), чтобы TTestDBSetup реализовывал интерфейс ITestSuite.

...  ITestSuite = interface(ITest)     ['{C20E38EF-7369-44D9-9D84-08E84EC1DCF0}']      procedure AddTest(test: ITest);     procedure AddSuite(suite : ITestSuite);   end; 

Там всего-то два метода:

...   TTestDBSetup = class(TTestSetup, ITestSuite)   public     procedure AddTest(test: ITest);     procedure AddSuite(suite : ITestSuite);   end; ... implementation ... procedure TTestDBSetup.AddTest(test: ITest); begin   Assert(Assigned(test));    FTests.Add(test); end;  procedure TTestDBSetup.AddSuite(suite: ITestSuite); begin   AddTest(suite); end; ... 

Получилось!

Однако, при запуске (F9, кстати) оказывается, что тесты TTestDB3 не выполняются:

Чтобы понять почему, посмотрим на реализацию:

procedure TTestDecorator.RunTest(ATestResult: TTestResult); begin   FTest.RunWithFixture(ATestResult); end; 

Т.е. тесты запускаются только те (FTest), которые были заданы при создании TTestDBSetup:

Скрытый текст

constructor TTestDecorator.Create(ATest: ITest; AName: string); begin   ...   FTest := ATest;   FTests:= TInterfaceList.Create;   FTests.Add(FTest); end; 

А которые мы добавили позже (FTests) — нет. Запустим и их, переопределив RunTest:

...   TTestDBSetup = ...   protected     procedure RunTest(ATestResult: TTestResult); override; ...   end. ... procedure TTestDBSetup.RunTest(ATestResult: TTestResult); var   i: Integer; begin   inherited;   // пропустим первый элемент, т.к. это FTest   for i := 1 to FTests.Count - 1 do     (FTests[i] as ITest).RunWithFixture(ATestResult); end; 

Запускаем:

Вот теперь, вроде, всё ок. Однако, если приглядеться, то увидим, что в статистике количество тестов — 4, а было запущено — 6. Очевидно, наши добавленные тесты не учитываются. Непорядок.

Наведём красоту:

Скрытый текст

 ...   TTestDBSetup = ...   protected ...     function CountTestInterfaces: Integer;     function CountEnabledTestInterfaces: Integer;   public ...     function CountTestCases: Integer; override;     function CountEnabledTestCases: Integer; override;   end; ...  function TTestDBSetup.CountTestCases: Integer; begin   Result := inherited;   if Enabled then     Inc(Result, CountTestsInterfaces); end;  function TTestDBSetup.CountTestInterfaces: Integer; var   i: Integer; begin   Result := 0;   // skip FIRST test case (it is FTest)   for i := 1 to FTests.Count - 1 do     Inc(Result, (FTests[i] as ITest).CountTestCases); end;  function TTestDBSetup.CountEnabledTestCases: Integer; begin   Result := inherited;   if Enabled then     Inc(Result, CountEnabledTestInterfaces); end;  function TTestDBSetup.CountEnabledTestInterfaces: Integer; var   i: Integer; begin   Result := 0;   // skip FIRST test case (it is FTest)   for i := 1 to FTests.Count - 1 do     if (FTests[i] as ITest).Enabled then       Inc(Result, (FTests[i] as ITest).CountTestCases); end; ...  

Здесь CountEnabledTestCases и CountEnabledTestInterfaces — вспомогательные функции.

Nota bene. В GUI варианте учитывается CountEnabledTestCases, а в консольном — CountTestCases.

Вот теперь порядок.

Дочитавший до конца читатель может спросить, а стОит ли так заморачиваться вместо использования функции по типу вышеописанной DBSuite? Я и сам об этом сейчас подумал. Но для меня один из плюсов данного решения состоит в том, что переделка одного моего проекта, в котором я, ещё до того, как разобрался с dUnit настолько, делал немного по-другому. И для приведения к такой красивости там понадобится подправить лишь один пару методов (ну и добавить вышеописанное в базовый класс).

P.S.: Исходные коды примера — github.com/ashumkin/habr-dunit-ttestsetup-demo

ссылка на оригинал статьи http://habrahabr.ru/post/266487/