📜 ⬆️ ⬇️

How to organize database testing in dUnit

As is known, 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 expensive, running several tests, before each establish a connection with 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. 



Call scheme will be as follows:

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

In addition, with a database it may be that, before connecting to a database, it must be created with the required structure.
')
To solve this problem in dUnit is a class TTestSetup (described in the module TTestExtensions).

It essentially implements the same ITest interface as TTestCase, that is, the same scheme: SetUp, Test ..., TearDown, only instead of calling the tests, the entire test-case specified during its creation 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. 

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



In essence, this is the suite + test-cases scheme. 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 understandable when we have only one test-case with tests that requires connecting to the database. But what to do when we want to create a second test-case, which also needs a connection to the database (let's call it TTestDB2 with the methods TestDB2_1, TestDB2_2, etc.)?

The constructor of TTestSetup.Create described as follows:

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

That is, only one test-case can be "included" in the suite. If we write this:

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

Then we get calls by 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 want to connect to the database only 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 is 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 the SuitePath is divided into parts, and the separator of these parts is a period, i.e. this is a certain “suite path” to which the registered test-case is added.

We try to register TestDB2 like this (to add TTestDB2 as a “child node” to TTestDBSetup):
 RegisterTest('Setup decorator ((d) TTestDB1)', TTestDB2.Suite); 

Did not work out:



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


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

Here we look, for example, into the IndySoap library (it has dUnit tests organized in groups) and we see there something like this (we will write down immediately with reference to 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 already add this suite to TTestSetup.



And, it seems, everything works, and everything is fine. On this one could finish.

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 placed in a separate module, and this module should be added to the module with the DBSuite function. I personally don’t really like this DBSuite change (besides, the “extra” DB tests ”node is added to the test hierarchy visually, although TTestDB1 / TTestDB2 could“ belong ”to TTestDBSetup right away). I just want to add the test module to the project and they would “automatically” be added to TTestDBSetup.

Well, let's do as we want. First of all, I don’t like the Setup name in the form “Setup decorator ((d) ...”. Besides, then when we register other tests in this Setup, we will use this name. We’ll 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 in
 constructor TTestSetup.Create(ATest: ITest; AName: string = ''); 

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

So if we override
 ... 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 so:
 unit uTestDB3; ... initialization RegisterTest('DB', TTestDB3.Suite)); 

For this you need (remember RegisterTestInSuite ) for TTestDBSetup to implement the ITestSuite interface.

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

There are only 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 TTestDB3 tests are not performed:



To understand why, look at the implementation:

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

Those. tests run only ( FTest ), which were set 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. Launch them by redefining 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; 

Run:



Now, it seems, everything is OK. However, if you look closely, we will see that in statistics the number of tests is 4, and it was launched - 6. Obviously, our added tests are not counted. Disorder.

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 auxiliary functions.

Nota bene. In the GUI variant, CountEnabledTestCases is considered, and in the console version - CountTestCases.





Now order.

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

PS: Source code of the example - github.com/ashumkin/habr-dunit-ttestsetup-demo

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

Source: https://habr.com/ru/post/266487/


All Articles