Add 4 more AL/BC testing patterns from Luc van Vugt's fluxxus.nl blog

Fourth batch from CURABIS ApS, mined from an external BC/NAV testing expert's blog archive (fluxxus.nl). Confirm+StrSubstNo interaction with ConfirmHandler, Table Relation Test's OnAfterRemoveTableRelation exclusion hook (verified against BCApps source, codeunit 134926), committing shared lazy-Initialize fixture data, and Assert.IsFalse vs asserterror for boolean checks.
This commit is contained in:
Michael Dieringer 2026-09-05 08:49:44 +02:00
parent 07e324ddbc
commit 12b7f73d24
12 changed files with 218 additions and 0 deletions

View file

@ -0,0 +1,19 @@
codeunit 50142 "Sample Test Library"
{
var
Initialized: Boolean;
procedure Initialize()
begin
if Initialized then
exit;
CreateSharedFixtureData();
Initialized := true;
end;
local procedure CreateSharedFixtureData()
begin
// insert master/setup data shared across every test in this codeunit
end;
}

View file

@ -0,0 +1,20 @@
codeunit 50142 "Sample Test Library"
{
var
Initialized: Boolean;
procedure Initialize()
begin
if Initialized then
exit;
CreateSharedFixtureData();
Commit();
Initialized := true;
end;
local procedure CreateSharedFixtureData()
begin
// insert master/setup data shared across every test in this codeunit
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [initialize, isinitialized, shared-fixture, commit, autorollback, lazy-initialization]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Commit shared fixture data created inside a lazy Initialize(), or later tests lose it
## Description
A test codeunit that creates master/setup data once, guarded by an `IsInitialized` flag, to avoid repeating expensive setup across many `[Test]` methods depends on that data surviving into every later test. Each `[Test]` method runs under `AutoRollback` by default, so data inserted during the first test's call to `Initialize()` rolls back at the end of that test. `IsInitialized` is a variable, not persisted data, so it still reads `true` on the next test — but the fixture rows it points to are already gone.
## Best Practice
Call `Commit()` at the end of a lazy/shared `Initialize()` procedure, once the shared fixture data is created, so it survives past the first test's rollback boundary. Pair this with a `TestIsolation`-enabled test runner so the committed fixture is still cleaned up at the end of the full run.
See sample: `commit-shared-test-fixture-inside-lazy-initialize.good.al`.
## Anti Pattern
A shared `Initialize()` guarded by `IsInitialized` that creates fixture records but never commits. The first test that runs it passes; every later test in the same codeunit either fails to find the fixture data or silently re-triggers setup logic that `IsInitialized` was meant to skip.
See sample: `commit-shared-test-fixture-inside-lazy-initialize.bad.al`.

View file

@ -0,0 +1,9 @@
codeunit 50140 "Sample Confirm Usage"
{
procedure ConfirmDeletion(RecordCount: Integer): Boolean
var
ConfirmMsg: Label 'Do you want to delete %1 records?';
begin
exit(Confirm(ConfirmMsg, false, RecordCount));
end;
}

View file

@ -0,0 +1,9 @@
codeunit 50140 "Sample Confirm Usage"
{
procedure ConfirmDeletion(RecordCount: Integer): Boolean
var
ConfirmMsg: Label 'Do you want to delete %1 records?';
begin
exit(Confirm(StrSubstNo(ConfirmMsg, RecordCount), false));
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [confirm, confirmhandler, strsubstno, placeholder, question, substitution]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Build the Confirm message with StrSubstNo, or a ConfirmHandler sees the raw template
## Description
`Confirm`'s placeholder-substitution overload — `Confirm('text %1', false, Value)` — substitutes the placeholder only for the dialog a real user sees. Inside a `[ConfirmHandler]`, the `Question` parameter received is the literal, unsubstituted template string (`'text %1'`), not the value-filled text. A test that asserts `Question` against the expected substituted message either fails outright or silently checks the wrong thing.
## Best Practice
When a `ConfirmHandler` needs to assert on the actual message text, build the string with `StrSubstNo(Text, Value)` in the production code first, and pass the already-substituted string to `Confirm()` with no further placeholder arguments.
See sample: `confirm-needs-strsubstno-before-confirmhandler-sees-substituted-text.good.al`.
## Anti Pattern
Calling `Confirm('text %1', false, Value)` and then asserting the substituted text against `Question` inside a `[ConfirmHandler]`. `Question` holds the raw `'text %1'` template, so the assertion never matches the intended message.
See sample: `confirm-needs-strsubstno-before-confirmhandler-sees-substituted-text.bad.al`.

View file

@ -0,0 +1,11 @@
codeunit 50141 "Sample Table Relation Test Ext"
{
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Table Relation Test", 'OnAfterRemoveTableRelation', '', false, false)]
local procedure ExcludeSampleFieldFromTableRelationTest(var TableRelationsMetadata: Record "Table Relations Metadata" temporary)
var
TableRelationTest: Codeunit "Table Relation Test";
begin
// Removes every relation on the whole table, not just the one known exception
TableRelationTest.RemoveTableRelation(TableRelationsMetadata, Database::"Sample Header", 0, 0, 0);
end;
}

View file

@ -0,0 +1,10 @@
codeunit 50141 "Sample Table Relation Test Ext"
{
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Table Relation Test", 'OnAfterRemoveTableRelation', '', false, false)]
local procedure ExcludeSampleFieldFromTableRelationTest(var TableRelationsMetadata: Record "Table Relations Metadata" temporary)
var
TableRelationTest: Codeunit "Table Relation Test";
begin
TableRelationTest.RemoveTableRelation(TableRelationsMetadata, Database::"Sample Header", 10, Database::"Sample Setup", 1);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [table-relation-test, tablerelationsmetadata, onafterremovetablerelation, field-length, field-type]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Exclude a known-valid TableRelation exception via OnAfterRemoveTableRelation
## Description
Codeunit 134926 "Table Relation Test" walks every `TableRelation` field property in the app and fails the moment a related field's type or length doesn't match what the relation requires — the related field must match the largest related field's length, and its type must match (except a field may relate to both `Code` and `Text`, which resolves to `Text`). A field with a legitimate, intentional relation shape has no per-field override in its own object definition; the check runs across the whole app with no built-in escape hatch.
## Best Practice
Subscribe to `OnAfterRemoveTableRelation` and call the codeunit's own `RemoveTableRelation(TableRelationsMetadata, TableID, FieldID, RelatedTableID, RelatedFieldID)` to strike the one known-valid relation before the test evaluates it, scoped as narrowly as the exception actually is.
See sample: `table-relation-test-exclude-known-invalid-relations-via-event.good.al`.
## Anti Pattern
Excluding an entire table's relations (or disabling the whole test codeunit) to work around one known exception. This discards the check's coverage for every other relation on that table, or in the app, not just the one that needed an exception.
See sample: `table-relation-test-exclude-known-invalid-relations-via-event.bad.al`.

View file

@ -0,0 +1,18 @@
codeunit 50143 "Sample Doc Amount Test"
{
Subtype = Test;
[Test]
procedure DocAmountIsNotVerifiedWhenLinesAreMissing()
var
Assert: Codeunit Assert;
PurchHeader: Record "Purchase Header";
begin
asserterror Assert.IsTrue(VerifyDocAmount(PurchHeader), 'Doc. amount should not verify with no lines.');
end;
local procedure VerifyDocAmount(var PurchHeader: Record "Purchase Header"): Boolean
begin
exit(false);
end;
}

View file

@ -0,0 +1,18 @@
codeunit 50143 "Sample Doc Amount Test"
{
Subtype = Test;
[Test]
procedure DocAmountIsNotVerifiedWhenLinesAreMissing()
var
Assert: Codeunit Assert;
PurchHeader: Record "Purchase Header";
begin
Assert.IsFalse(VerifyDocAmount(PurchHeader), 'Doc. amount should not verify with no lines.');
end;
local procedure VerifyDocAmount(var PurchHeader: Record "Purchase Header"): Boolean
begin
exit(false);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [assert, isfalse, istrue, asserterror, boolean-check, negative-test]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use Assert.IsFalse to check a boolean result, not asserterror around Assert.IsTrue
## Description
`asserterror` exists to assert that a statement raises a runtime error; it is not a general-purpose way to invert a boolean check. Wrapping `asserterror Assert.IsTrue(SomeFunc(), Msg)` to verify that `SomeFunc()` returns `false` tests whether `Assert.IsTrue`'s own error-raising behavior fired, not the value `SomeFunc()` actually returned.
## Best Practice
When the code under test returns a `Boolean` rather than raising an error, assert the value directly with `Assert.IsFalse(SomeFunc(), Msg)` (or `Assert.IsTrue` for the positive case). Reserve `asserterror` for statements expected to actually raise an error.
See sample: `use-assert-isfalse-not-asserterror-for-boolean-checks.good.al`.
## Anti Pattern
`asserterror Assert.IsTrue(SomeFunc(), Msg);` to verify `SomeFunc()` is `false`. It passes today because `Assert.IsTrue` happens to raise an error on failure, but it verifies the assertion helper's error-raising behavior, not the value under test.
See sample: `use-assert-isfalse-not-asserterror-for-boolean-checks.bad.al`.