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.
Contents
- 1 The Fluent API — Assert.That()
- 2 Custom Constraints
- 3 Collection Assertions — ThatList and ThatDict
- 4 Parameterized Tests with [TestCase]
- 5 Setup, TearDown, and the Test Lifecycle
- 6 Categories
- 7 The [Ignore] Attribute
- 8 The [ExpectedException] Attribute
- 9 TDataSet Assertions
- 10 Memory Leak Detection
- 11 JUnit XML for CI/CD
- 12 Summary
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 assertionsAssert.That('hello world') .IsNotEmpty .Contains('world') .StartsWith('hello') .EndsWith('world') .HasLength(11);// Integer assertionsAssert.That(42) .IsEqualTo(42) .IsGreaterThan(0) .IsInRange(1, 100) .IsPositive;// Float assertions with toleranceAssert.That(3.14159) .IsCloseTo(Pi, 0.001) .IsPositive .IsInRange(3.0, 4.0);// BooleanAssert.That(True).IsTrue;Assert.That(False).IsFalse;// ObjectAssert.That(MyComponent) .IsNotNull .InheritsFrom(TComponent);// GUIDAssert.That(MyGuid) .IsNotEmpty .IsEqualTo(ExpectedGuid);// PointerAssert.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
| Method | Description |
|---|---|
IsEqualTo(S, IgnoreCase) | Exact or case-insensitive comparison |
IsNotEqualTo(S) | Not equal |
IsEmpty / IsNotEmpty | Empty 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)
| Method | Description |
|---|---|
IsEqualTo(N) | Equality (with optional tolerance for Double) |
IsGreaterThan(N) / IsLessThan(N) | Comparison |
IsInRange(Lo, Hi) | Inclusive range |
IsPositive / IsNegative / IsZero | Sign 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 = classpublic [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:
| Attribute | Scope | When it runs |
|---|---|---|
[SetupFixture] | Once per fixture class | Before the first test in the fixture |
[TearDownFixture] | Once per fixture class | After the last test in the fixture |
[Setup] | Per test | Before each individual test method |
[TearDown] | Per test | After each individual test method |
[TestFixture]TTestDatabase = classprivate 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' categoryRunTestsForCI('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 = classprivate 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:
| Mode | Behavior |
|---|---|
lrmIgnore | Leaks are silently ignored |
lrmWarning | Leaks are reported but the test still passes |
lrmFailure | Leaks 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 OKprocedure TestOwnershipTransfer;[Test][MemoryLeakAllowed(1024)] // up to 1024 bytes is OKprocedure 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 API —
Assert.That(...)chains for readable, expressive assertions. - Constraints — reusable validation rules via
IkbmConstraint, predicates, and regex. - Collections —
ThatList<T>andThatDict<K,V>for generic collection testing. - Parameterized tests —
[TestCase]for data-driven testing. - Lifecycle —
[Setup],[TearDown],[SetupFixture],[TearDownFixture]. - Categories —
[Category]andCategoryFilterfor selective execution. - TDataSet assertions —
ThatDataSet(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.
![]()






