How to organize database testing in dUnit

As you know, in xUnit frameworks, the simplest test-case consists of a sequence of calls to SetUp, TestSomething, TearDown. And quite often in unit testing it is required to prepare some resources before the main tests. A typical example of this is a database connection. And the logic tells us that it would be very costly, by running several tests, before each, establish a connection to the database in SetUp, and disconnect in TearDown.

Module example
...
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.



The call scheme will be as follows:

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

In addition, it may happen with the database that before connecting to the database, it must be created with the required structure.

To solve this problem, dUnit has a TTestSetup class (described in the TTestExtensions module).

In fact, it implements the same interface ITestas TTestCase, that is, the same scheme: SetUp, Test ..., TearDown, but instead of calling the tests, the whole test case specified when it was created is called. Those. modifying the module:

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.

we get the call scheme:
-- TTestDBSetup.SetUp
---- TTestDB1.SetUp
------ TTestDB1.TestDB1_1
---- TTestDB1.TearDown
---- TTestDB1.SetUp
------ TTestDB1.TestDB1_2
---- TTestDB1.TearDown
-- TTestDBSetup.TearDown



This is essentially a suite + test-cases schema. Thus, establishing a connection to the database in TTestDBSetup.SetUp, we will do this only once before running TestDB1_1 and TestDB1_2.

This is quite clear when we have only one test case with tests, which requires a connection to the database. But what to do when we want to create a second test-case that also needs a connection to the database (let's call it TTestDB2 with the methods TestDB2_1, TestDB2_2, etc.)?

The constructor is TTestSetup.Createdescribed as follows:

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

That is, only one test-case can be “included” in a suite. If we write like this:

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

Then we will receive calls according to the scheme:
-- 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



This is not what we want. We only want to connect to the database once.

Here begins, in fact, what prompted me to write this article. Let's pay attention to the second variant of the RegisterTest method:
procedure RegisterTest(SuitePath: string; test: ITest);
begin
  assert(assigned(test));
  if __TestRegistry = nil then CreateRegistry;
  RegisterTestInSuite(__TestRegistry, SuitePath, test);
end;

What the hell SuitePath? We look RegisterTestInSuite:
Hidden text
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;
...


And we see that SuitePath is divided into parts, and the separator of these parts is a period, i.e. this is a kind of "suite path" into which the registered test-case is added.

We try to register TestDB2 as follows (to add TTestDB2 as a "child node" in TTestDBSetup):
RegisterTest('Setup decorator ((d) TTestDB1)', TTestDB2.Suite);

Failed:



We look again at the code RegisterTestInSuite:
Hidden text
procedure RegisterTestInSuite(rootSuite: ITestSuite; path: string; test: ITest);
...
begin
...
      currentTest.queryInterface(ITestSuite, suite);
      if Assigned(suite) then
      begin
...


We see that test-case is added to ITestSuite, and TTestSetup does not implement this interface. How to be?

Here we peek, for example, into the IndySoap library (it has dUnit tests organized in groups) and we see something like the following (we write it down immediately for our tests):

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

That is, we create a suite from our test cases, and add this suite to TTestSetup.



And everything seems to work, and everything is fine. This could be done.

But if (more precisely, “when”) we will add more database tests (let's call them TTestDB3), then we will have to add them to DBSuite:

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

In addition, in a good way, they must be taken out in a separate module, and this module should already be added to the module with the DBSuite function. I personally don’t really like this DBSuite change (besides, visually in the test hierarchy a “redundant” DB tests node is added, although TTestDB1 / TTestDB2 could “belong” to TTestDBSetup right away). I just want to add the test module to the project and they would be “automatically” added to TTestDBSetup.

Well, we will do as we want. First of all, I don’t like the name of Setup of the form “Setup decorator ((d) ...". Moreover, later, when we register other tests in this Setup, we will use this name. Change it. For this pay attention to the following:

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

And on the parameter ANamein
constructor TTestSetup.Create(ATest: ITest; AName: string = '');

Which is eventually assigned
constructor TAbstractTest.Create(AName: string);
...
  FTestName := AName;
...

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

Then we get:



Now I want to register test cases immediately when the module is connected to the project. That is, like this:
unit uTestDB3;
...
initialization
  RegisterTest('DB', TTestDB3.Suite));

To do this, we need (recall RegisterTestInSuite) that TTestDBSetup implements the ITestSuite interface.

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

There are just two methods:

...
  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;
...



Happened!

However, at startup (F9, by the way), it turns out that the TTestDB3 tests are not executed:



To understand why, let's look at the implementation:

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

Those. tests run only those ( FTest) that were specified when creating TTestDBSetup:
Hidden text
constructor TTestDecorator.Create(ATest: ITest; AName: string);
begin
  ...
  FTest := ATest;
  FTests:= TInterfaceList.Create;
  FTests.Add(FTest);
end;


And which we added later ( FTests) - no. Run them too, overriding 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;

Launch:



Now, it seems, everything is ok. However, if you look closely, we will see that in the statistics the number of tests is 4, and it was launched - 6. Obviously, our added tests are not taken into account. The mess.

Let's bring beauty:

Hidden text

...
  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;
...

Here CountEnabledTestCases and CountEnabledTestInterfaces are helper functions.

Nota bene. The GUI version counts CountEnabledTestCases, and the console countTestCases.





Now the order.

A reader who has read to the end may ask, is it worth it to bother instead of using a function like the DBSuite described above? I myself thought about it now. But for me, one of the advantages of this solution is that the alteration of one of my projects, in which I, even before I understood dUnit so much, did a little differently. And to lead to such a prettiness there you will need to correct only one pair of methods (well, add the above to the base class).

PS: The source code for the example is github.com/ashumkin/habr-dunit-ttestsetup-demo

Upd. The source code of the resulting class TTestDBSetup(renamed to TTestSetupEx) has been moved to a separate dUnitEx project (see TestSetupEx.pas )

Also popular now: