Thursday, April 23, 2009

How to Unit Test a Data Module

Data modules are unit tested in exactly the same way that you would unit test any other piece of code. That is, by refactoring the code to be tested in such a way as to separate it from code not relevant to the test. But since people occasionally seem to find this confusing, perhaps a practical example is in order.


Imagine that you have been given the task of fixing a bug in the following code. Before you fix the bug, you would like to write a unit test which shows the bug, in order to prevent regressions after you fix it.



procedure TMyDM.qryFooCalcFields(DataSet: TDataSet);
var
Minimum, PieceRate: Double;
begin
Minimum := qryFooHOURS.Value / qryFooMINIMUM_WAGE.Value; //
oops; should be *
PieceRate := qryFooPIECE_RATE.Value * qryFooPIECES.Value;
qryFooEarnings.Value := Max(Minimum, PieceRate);
end;


Now, tests which access a database are, generally speaking, integration tests rather than unit tests. But the code you’ve been asked to fix seems to be pretty wound up in database access. It doesn’t have to be, though. Let’s refactor:



function TMyDM.CalcEarnings(AHours, AMinimumWage, APieceRate, APieces: Double): double;
var
Minimum, PieceRate: Double;
begin
Minimum := AHours / AMinimumWage; // oops; should be *
PieceRate := APieceRate * APieces;
Result := Max(Minimum, PieceRate);
end;

procedure TMyDM.qryFooCalcFields(DataSet: TDataSet);
begin
qryFooEarnings.Value := CalcEarnings(qryFooHOURS.Value,
qryFooMINIMUM_WAGE.Value, qryFooPIECE_RATE.Value, qryFooPIECES.Value);
end;


Resist any temptation to actually fix the bug at the moment; we went to write a unit test which fails before making it pass by fixing the bug. Note that you can make CalcEarnings static, meaning you won’t even need an instance of the data module in order to unit test the function:



procedure TestMyDM.CalcEarnings;
var
Actual, Delta, Expected, Hours, Minimum, PieceRate, Pieces: Double;
begin
Hours := 1;
Minimum := 7;
PieceRate := 1;
Pieces := 1;
Expected := 7;

Actual := TMyDM.CalcEarnings(Hours, MinimumWage, PieceRate, Pieces);

Delta := 0.0001;
CheckEquals(Expected, Actual, Delta);
end;


There are aspects of this code which will be hard to unit test. I could, for example, pass the wrong field value into the refactored function. I can fix that by abstracting the data access layer, but it’s not a complete fix, since I might have the wrong data in the database. The fact is that unit testing can only catch so much, and integration testing will always be necessary, as well. The important point is that I have isolated the bug which I was asked to fix, and future regressions in that code will now be caught.


I wonder if some Delphi users are confused by the term "unit test." The word "unit" means something very different in Delphi than it does in the term "unit test." In "unit testing," the word "unit" refers to a single piece of functionality. In Delphi, a unit (reserved word) is a source code file. When you write unit tests, you do not have to test an entire unit at a time. Unit tests are for specific cases of using a specific function.

No comments: