The Complete Guide to kbmUnitTest

This is part 3 of a 6-part series. Part 1 covered first steps, Part 2 covered TestInsight.


In Part 1 we used Assert.AreEqual and Assert.WillRaise — the bread and butter of any test suite. kbmUnitTest goes much further. This part covers the fluent assertion API, custom constraints, parameterized tests, categories, the test lifecycle, TDataSet assertions, and memory leak detection.

The Fluent API — Assert.That()

Instead of calling discrete static methods like Assert.AreEqual, you can start with Assert.That(value) and chain multiple checks in a single, readable expression. Every method returns Self, so chains can be as long as you need:

// String assertions
Assert.That('hello world')
.IsNotEmpty
.Contains('world')
.StartsWith('hello')
.EndsWith('world')
.HasLength(11);
// Integer assertions
Assert.That(42)
.IsEqualTo(42)
.IsGreaterThan(0)
.IsInRange(1, 100)
.IsPositive;
// Float assertions with tolerance
Assert.That(3.14159)
.IsCloseTo(Pi, 0.001)
.IsPositive
.IsInRange(3.0, 4.0);
// Boolean
Assert.That(True).IsTrue;
Assert.That(False).IsFalse;
// Object
Assert.That(MyComponent)
.IsNotNull
.InheritsFrom(TComponent);
// GUID
Assert.That(MyGuid)
.IsNotEmpty
.IsEqualTo(ExpectedGuid);
// Pointer
Assert.That(MyPtr).IsNotNull;

The fluent wrappers are stack-allocated records, so there is no memory management concern. Each type — string, Integer, Int64, Double, Boolean, TObject, TGUID, Pointer — has its own TkbmAssertThat* record with type-appropriate methods.

Strings

MethodDescription
IsEqualTo(S, IgnoreCase)Exact or case-insensitive comparison
IsNotEqualTo(S)Not equal
IsEmpty / IsNotEmptyEmpty string check
Contains(Sub, IgnoreCase)Substring search
StartsWith(Prefix)Prefix check
EndsWith(Suffix)Suffix check
HasLength(N)Exact length
MatchesRegex(Pattern)Regular expression match
DoesNotMatchRegex(Pattern)Negative regex match

Numbers (Integer, Int64, Double)

MethodDescription
IsEqualTo(N)Equality (with optional tolerance for Double)
IsGreaterThan(N) / IsLessThan(N)Comparison
IsInRange(Lo, Hi)Inclusive range
IsPositive / IsNegative / IsZeroSign checks
IsCloseTo(N, Tolerance)Float proximity (Double only)

Custom Constraints

Every fluent type supports .Matches(AConstraint), .DoesNotMatch(AConstraint), .MatchesAllOf([...]), .MatchesAnyOf([...]), and .MatchesNoneOf([...]). A constraint is any object implementing IkbmConstraint:

IkbmConstraint = interface
function Check(const AValue: TValue): Boolean;
function Describe: string;
function DescribeFailure(const AValue: TValue): string;
end;

Ad-hoc Constraints with Predicates

For one-off checks, use TkbmPredicateConstraint or the Constraint.Predicate factory:

Assert.That(OrderTotal).Matches(
Constraint.Predicate(
function(const V: TValue): Boolean
begin
Result := V.AsExtended > 0;
end,
'be a positive amount'));

Regex Constraints

Assert.That(Email).Matches(
Constraint.Regex('^[^@]+@[^@]+\.[^@]+$', 'be a valid email'));

Reusable Constraint Classes

For validation rules you use across many tests, implement IkbmConstraint in a dedicated class:

type
TPositiveConstraint = class(TInterfacedObject, IkbmConstraint)
public
function Check(const AValue: TValue): Boolean;
function Describe: string;
function DescribeFailure(const AValue: TValue): string;
end;
function TPositiveConstraint.Check(const AValue: TValue): Boolean;
begin
Result := AValue.AsExtended > 0;
end;
function TPositiveConstraint.Describe: string;
begin
Result := 'be positive';
end;
function TPositiveConstraint.DescribeFailure(const AValue: TValue): string;
begin
Result := Format('value %g is not positive', [AValue.AsExtended]);
end;

Then use it in any test:

Assert.That(Balance).Matches(TPositiveConstraint.Create);

Combining Constraints

MatchesAllOf requires all to pass (AND logic), MatchesAnyOf requires at least one (OR logic), and MatchesNoneOf requires none to pass:

Assert.That(UserName).MatchesAllOf([
Constraint.Regex('^[a-zA-Z]', 'start with a letter'),
Constraint.Predicate(
function(const V: TValue): Boolean
begin Result := Length(V.AsString) >= 3 end,
'be at least 3 characters')
]);

Collection Assertions — ThatList and ThatDict

The fluent API extends to generic collections. Assert.ThatList<T> works with TArray<T> or any TEnumerable<T>, and Assert.ThatDict<K,V> works with TDictionary<K,V>.

Lists

var
Scores: TArray<Integer>;
begin
Scores := [85, 92, 78, 95, 88];
Assert.ThatList<Integer>(Scores)
.HasCount(5)
.Contains(92)
.DoesNotContain(0)
.ContainsAll([85, 95])
.First(85)
.Last(88)
.IsOrdered // ascending order
.AllMatch(
function(V: Integer): Boolean begin Result := V >= 0 end,
'be non-negative');
end;

Available methods include IsEmpty, IsNotEmpty, HasCount, HasCountGreaterThan, HasCountLessThan, HasCountInRange, Contains, DoesNotContain, ContainsAll, ContainsAny, ContainsNone, First, Last, AllMatch, AnyMatch, NoneMatch, IsOrdered, IsOrderedDescending, and IsEqualTo.

Dictionaries

var
Config: TDictionary<string, Integer>;
begin
Config := TDictionary<string, Integer>.Create;
try
Config.Add('timeout', 30);
Config.Add('retries', 3);
Assert.ThatDict<string, Integer>(Config)
.HasCount(2)
.ContainsKey('timeout')
.DoesNotContainKey('debug')
.HasValueAt('retries', 3)
.AllValuesMatch(
function(V: Integer): Boolean begin Result := V > 0 end,
'be positive');
finally
Config.Free;
end;
end;

Parameterized Tests with [TestCase]

When you want to run the same test logic with different inputs, use the [TestCase] attribute instead of writing separate test methods:

[TestFixture]
TTestMath = class
public
[Test('Addition')]
[TestCase('1 + 1 = 2', '1,1,2')]
[TestCase('0 + 0 = 0', '0,0,0')]
[TestCase('-1 + 1 = 0', '-1,1,0')]
[TestCase('100 + 200', '100,200,300')]
procedure TestAdd(const A, B, Expected: Integer);
[Test('String contains')]
[TestCase('Hello', 'Hello World,Hello')]
[TestCase('World', 'Hello World,World')]
procedure TestContains(const AInput, ASubStr: string);
[Test('Boolean identity')]
[TestCase('True', 'True,True')]
[TestCase('False', 'False,False')]
procedure TestBool(const AInput, AExpected: Boolean);
end;
procedure TTestMath.TestAdd(const A, B, Expected: Integer);
begin
Assert.AreEqual(Expected, A + B);
end;

The first argument to [TestCase] is the display name. The second is a comma-separated list of values, which the framework parses and passes to the method parameters. Supported types are Integer, Int64, Double, string, Boolean, Char, and enumerated types.

Each [TestCase] appears as a separate test in the output:

  [Fixture] TTestMath
    ✓ Addition: 1 + 1 = 2          (0.00 ms)
    ✓ Addition: 0 + 0 = 0          (0.00 ms)
    ✓ Addition: -1 + 1 = 0         (0.00 ms)
    ✓ Addition: 100 + 200          (0.00 ms)

Setup, TearDown, and the Test Lifecycle

kbmUnitTest supports four lifecycle methods:

AttributeScopeWhen it runs
[SetupFixture]Once per fixture classBefore the first test in the fixture
[TearDownFixture]Once per fixture classAfter the last test in the fixture
[Setup]Per testBefore each individual test method
[TearDown]Per testAfter each individual test method
[TestFixture]
TTestDatabase = class
private
FConn: TFDConnection;
FQuery: TFDQuery;
public
[SetupFixture]
procedure SetupFixture; // open database connection once
[TearDownFixture]
procedure TearDownFixture; // close connection once
[Setup]
procedure Setup; // start a transaction before each test
[TearDown]
procedure TearDown; // rollback after each test
[Test]
procedure TestInsertCustomer;
[Test]
procedure TestUpdateCustomer;
end;

The runner creates a fresh instance of the fixture class before each test, so instance fields are always in their default state. [SetupFixture] and [TearDownFixture] are useful for expensive operations like opening a database connection — they use class variables or class state that persists across the fixture.

Categories

The [Category] attribute lets you tag fixtures or individual tests. You can then filter by category at runtime:

[TestFixture]
[Category('Integration')]
TTestDatabaseIntegration = class
...
end;
[TestFixture]
[Category('Unit')]
TTestCalculator = class
...
end;

Run only one category:

RunTests('Unit'); // only run tests in the 'Unit' category
RunTestsForCI('results.xml', 'Integration'); // only integration tests

This is especially useful when integration tests are slow — you run unit tests during development and integration tests on the build server.

The [Ignore] Attribute

Mark a test as temporarily disabled without deleting it:

[Test]
[Ignore('Waiting for API v2 endpoint')]
procedure TestNewEndpoint;

Ignored tests appear as “skipped” in the output with the reason displayed.

The [ExpectedException] Attribute

An alternative to Assert.WillRaise for when the entire test method is expected to throw:

[Test]
[ExpectedException(EArgumentException)]
procedure TestBadArgument;

If the method exits without raising EArgumentException, the test fails. This is more compact than wrapping the test body in Assert.WillRaise, but gives you less control (you cannot check the message or test code after the exception).

TDataSet Assertions

Add the optional kbmTestDataSet unit for fluent assertions on any TDataSet descendant — queries, in-memory tables, TClientDataSet, TFDMemTable, TkbmMemTable, kbmMW dataset instances, or anything else that descends from TDataSet:

uses
kbmTestFramework, kbmTestDataSet;
procedure TTestQueries.TestCustomerQuery;
begin
Query.Open;
ThatDataSet(Query)
.IsActive
.IsNotEmpty
.HasRowCount(3)
.HasField('Name')
.HasFields(['Name', 'Email', 'Age']);
// Navigate and check field values (0-based row index)
ThatDataSet(Query)
.AtRow(0)
.FieldEquals('Name', 'Alice')
.FieldEquals('Age', 30)
.FieldIsNotNull('Email')
.AtRow(2)
.FieldEquals('Name', 'Charlie')
.FieldIsNull('Email');
// Multi-row predicates
ThatDataSet(Query)
.AllRowsMatch(
function(DS: TDataSet): Boolean
begin
Result := DS.FieldByName('Age').AsInteger >= 18;
end,
'all customers are adults');
end;

Working with TkbmMemTable

TkbmMemTable is a natural fit for dataset assertions because it needs no database connection — you can build test data entirely in code and verify the results in the same test:

uses
kbmMemTable,
kbmTestFramework, kbmTestDataSet;
[TestFixture]
TTestOrderCalculation = class
private
FOrders: TkbmMemTable;
public
[Setup]
procedure Setup;
[TearDown]
procedure TearDown;
[Test]
procedure TestTotalIsRecalculated;
end;
procedure TTestOrderCalculation.Setup;
begin
FOrders := TkbmMemTable.Create(nil);
FOrders.FieldDefs.Add('ProductName', ftString, 50);
FOrders.FieldDefs.Add('Qty', ftInteger);
FOrders.FieldDefs.Add('UnitPrice', ftCurrency);
FOrders.FieldDefs.Add('LineTotal', ftCurrency);
FOrders.CreateTable;
FOrders.Open;
end;
procedure TTestOrderCalculation.TearDown;
begin
FOrders.Free;
end;
procedure TTestOrderCalculation.TestTotalIsRecalculated;
begin
// Arrange — populate through the business logic under test
AddOrderLine(FOrders, 'Widget', 5, 12.50);
AddOrderLine(FOrders, 'Gadget', 2, 49.99);
AddOrderLine(FOrders, 'Gizmo', 10, 3.00);
// Assert — verify the resulting dataset
ThatDataSet(FOrders)
.IsActive
.HasRowCount(3)
.AtRow(0)
.FieldEquals('ProductName', 'Widget')
.FieldEquals('LineTotal', 62.50)
.AtRow(1)
.FieldEquals('LineTotal', 99.98)
.AtRow(2)
.FieldEquals('LineTotal', 30.00);
// Verify every line total is positive
ThatDataSet(FOrders)
.AllRowsMatch(
function(DS: TDataSet): Boolean
begin
Result := DS.FieldByName('LineTotal').AsCurrency > 0;
end,
'all line totals are positive');
end;

Working with kbmMW Query Results

When testing service or ORM layers built on kbmMW, you can pass any kbmMW dataset result — such as a TkbmMWClientQuery result set or a TkbmMemTable populated by the middleware — straight into ThatDataSet:

procedure TTestCustomerService.TestFindByCountry;
var
LResult: TkbmMemTable;
begin
// Act — call the service layer which returns a dataset
LResult := FCustomerService.FindByCountry('DK');
try
ThatDataSet(LResult)
.IsActive
.IsNotEmpty
.HasField('CustomerName')
.HasField('Country');
// Every returned row should be from Denmark
ThatDataSet(LResult)
.AllRowsMatch(
function(DS: TDataSet): Boolean
begin
Result := DS.FieldByName('Country').AsString = 'DK';
end,
'all customers are from Denmark');
// Spot-check the first row
ThatDataSet(LResult)
.AtRow(0)
.FieldIsNotNull('CustomerName')
.FieldEquals('Country', 'DK');
finally
LResult.Free;
end;
end;

The key point is that ThatDataSet accepts any TDataSet — it does not matter whether the data came from a SQL query, was built in memory with TkbmMemTable, was returned by a kbmMW service, or was materialized from a mock scenario (covered in Part 5). The fluent API is the same in every case.

ThatDataSet returns a fluent record that saves and restores the cursor position via bookmarks, so you can chain multiple assertions without worrying about cursor state. Row indexing is 0-based: AtRow(0) is the first record.

Other available methods include AtFirst, AtLast, HasFieldCount, FieldContains, FieldStartsWith, FieldEndsWith, FieldDateTimeEquals (with tolerance in seconds), HasRowCountGreaterThan, HasRowCountLessThan, HasRowCountInRange, AnyRowMatches, and NoneRowsMatch.

Memory Leak Detection

kbmUnitTest can detect memory leaks on a per-test basis. The runner snapshots the heap before each test and compares after. Any new allocations that were not freed are reported.

Enabling Leak Detection

The simple entry points accept leak parameters:

RunTests('', True, True, lrmFailure);
// ^^^^ ^^^^^^^^^^
// detect leaks leaks = failure

Or configure the runner directly:

LRunner.DetectMemoryLeaks := True;
LRunner.LeakReportMode := lrmFailure; // or lrmWarning, lrmIgnore

The three modes are:

ModeBehavior
lrmIgnoreLeaks are silently ignored
lrmWarningLeaks are reported but the test still passes
lrmFailureLeaks cause the test to fail

Retry Mechanism

Some Delphi subsystems perform lazy, one-time allocations the first time they are used (RTTI caches, locale data, etc.). These show up as “leaks” on the first run but never recur. The runner has a built-in retry mechanism:

LRunner.LeakRetryCount := 1; // default: retry once

If a test leaks on the first attempt, the runner re-runs it. If the leak disappears on retry, it is treated as a one-time initialization and the test passes.

Allowing Known Leaks

Some tests intentionally leak (testing ownership transfer, for example). Mark them with [MemoryLeakAllowed]:

[Test]
[MemoryLeakAllowed] // any amount of leak is OK
procedure TestOwnershipTransfer;
[Test]
[MemoryLeakAllowed(1024)] // up to 1024 bytes is OK
procedure TestCachedSingleton;

Ignoring Specific Block Sizes

Platform-level allocations sometimes create blocks of known sizes that you cannot avoid:

LRunner.IgnoreLeakSizes := [68, 156]; // ignore these block sizes

Enhanced Leak Reports with FastMM5

For richer leak reports (class names, call stacks), add the optional kbmTestFastMM5 unit. FastMM5 must be the first unit in your DPR:

program MyTests;
uses
FastMM5, // must be first
kbmTestFastMM5, // auto-registers the leak analyzer
kbmTestFramework,
kbmTestRunner;
begin
RunTestsAndHalt;
end.

Adding kbmTestFastMM5 to uses is enough — its initialization section automatically registers a TkbmFastMM5LeakAnalyzer as the default analyzer. The runner picks it up and uses it for all subsequent tests. An equivalent kbmTestFastMM4 unit exists for projects still using FastMM4.

With the analyzer active, leak reports include the class name of every leaked object:

Memory leak: 2 object instances, 192 bytes
TStringList: 1 instance, 96 bytes
TButton: 1 instance, 96 bytes

JUnit XML for CI/CD

RunTestsForCI writes a JUnit-compatible XML file:

RunTestsForCI('test-results.xml');

This file is recognized by Jenkins, GitHub Actions, Azure DevOps, GitLab CI, TeamCity, and others. A typical GitHub Actions step:

- name: Run tests
run: MyTests.exe
- name: Publish results
uses: dorny/test-reporter@v1
with:
name: kbmUnitTest Results
path: test-results.xml
reporter: java-junit

The XML logger is a standard IkbmTestLogger and can be added alongside other loggers:

LRunner.AddLogger(TkbmConsoleTestLogger.Create(True));
LRunner.AddLogger(TkbmJUnitXMLLogger.Create('test-results.xml'));

Summary

This part covered the “power tools” of kbmUnitTest:

  • Fluent APIAssert.That(...) chains for readable, expressive assertions.
  • Constraints — reusable validation rules via IkbmConstraint, predicates, and regex.
  • CollectionsThatList<T> and ThatDict<K,V> for generic collection testing.
  • Parameterized tests[TestCase] for data-driven testing.
  • Lifecycle[Setup], [TearDown], [SetupFixture], [TearDownFixture].
  • Categories[Category] and CategoryFilter for selective execution.
  • TDataSet assertionsThatDataSet(DS) for fluent database testing.
  • Memory leak detection — per-test heap diffing with retry, allow, ignore, and FastMM5 integration.
  • JUnit XML — CI/CD integration with one line of code.

In Part 4 we will introduce the kbmUnitTest mock data system — a way to define named test data scenarios that can be materialized into records, objects, and datasets.

Loading

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.