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.
The call scheme will be as follows:
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
we get the call scheme:
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
That is, only one test-case can be “included” in a suite. If we write like this:
Then we will receive calls according to the scheme:
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:
What the hell
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):
Failed:
We look again at the code
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):
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:
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:
And on the parameter
Which is eventually assigned
So if we redefine
Then we get:
Now I want to register test cases immediately when the module is connected to the project. That is, like this:
To do this, we need (recall
There are just two methods:
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:
Those. tests run only those (
And which we added later (
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:
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
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
ITest
as 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.Create
described 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
AName
inconstructor 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
Here CountEnabledTestCases and CountEnabledTestInterfaces are helper functions.
Nota bene. The GUI version counts CountEnabledTestCases, and the console countTestCases.
...
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 )