Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ See sample: [`asserterror-needs-expectederror-and-code.good.al`](asserterror-nee

`asserterror DoInvalid();` with nothing after it. The test asserts only that the call failed somehow; swap the validation for a different bug and the test still passes, certifying a guard that may no longer fire. A negative test that cannot tell one error from another verifies almost nothing.

Not an instance of this anti-pattern: a trailing `asserterror Error(SomeLabel)` used purely as an end-of-test rollback sentinel to undo a lazily-initialized shared fixture's scratch changes (see `commit-shared-test-fixture-inside-lazy-initialize.md`). That `Error` call exists to force a rollback, not to verify that a specific failure occurred — the sentinel's own text is not meant to be asserted against, and adding an `ExpectedError` there would just duplicate the label without checking anything the test doesn't already control.

See sample: [`asserterror-needs-expectederror-and-code.bad.al`](asserterror-needs-expectederror-and-code.bad.al).
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
codeunit 50142 "Sample Test Library"
{
Subtype = Test;

var
LibraryInventory: Codeunit "Library - Inventory";
Initialized: Boolean;
SharedItemNo: Code[20];
RollBackMsg: Label 'Revert back the tables to their original state.';

local procedure Initialize()
begin
if Initialized then
exit;

CreateSharedFixtureData();
// BUG: no Commit() here. The fixture below is still inside this
// test method's own transaction.
Initialized := true;
end;

local procedure CreateSharedFixtureData()
var
Item: Record Item;
begin
LibraryInventory.CreateItem(Item);
SharedItemNo := Item."No.";
end;

[Test]
procedure FirstTestUsesSharedFixture()
var
Item: Record Item;
begin
Initialize();

Item.Get(SharedItemNo);
Item.Description := 'Scratch change this test makes and does not need to keep.';
Item.Modify();

asserterror Error(RollBackMsg);
// The deliberate rollback above also erases the never-committed
// fixture from CreateSharedFixtureData(). Initialized still reads
// true on the next test, but the row it points at is gone.
end;

[Test]
procedure SecondTestStillFindsSharedFixture()
var
Item: Record Item;
begin
Initialize();

// Fails here: Initialize() saw Initialized = true and returned
// immediately, so it never recreated the fixture - and the first
// test's rollback took the original row with it.
Item.Get(SharedItemNo);
end;
}

codeunit 50143 "Sample Test Runner"
{
// Codeunit isolation alone does not save this fixture: TestIsolation
// only controls whether committed changes survive between methods, and
// this fixture was never committed in the first place.
Subtype = TestRunner;
TestIsolation = Codeunit;

trigger OnRun()
begin
Codeunit.Run(Codeunit::"Sample Test Library");
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
codeunit 50142 "Sample Test Library"
{
Subtype = Test;

var
LibraryInventory: Codeunit "Library - Inventory";
Initialized: Boolean;
SharedItemNo: Code[20];
RollBackMsg: Label 'Revert back the tables to their original state.';

local procedure Initialize()
begin
if Initialized then
exit;

CreateSharedFixtureData();
Commit();
Initialized := true;
end;

local procedure CreateSharedFixtureData()
var
Item: Record Item;
begin
LibraryInventory.CreateItem(Item);
SharedItemNo := Item."No.";
end;

[Test]
procedure FirstTestUsesSharedFixture()
var
Item: Record Item;
begin
Initialize();

Item.Get(SharedItemNo);
Item.Description := 'Scratch change this test makes and does not need to keep.';
Item.Modify();

asserterror Error(RollBackMsg);
// Rolls back the Modify() above, but not the fixture: that was
// already committed inside Initialize().
end;

[Test]
procedure SecondTestStillFindsSharedFixture()
var
Item: Record Item;
begin
// Runs after FirstTestUsesSharedFixture's deliberate rollback.
// Initialize() sees Initialized = true and does nothing, but the
// committed fixture it created earlier is still there to Get().
Initialize();

Item.Get(SharedItemNo);
end;
}

codeunit 50143 "Sample Test Runner"
{
// Codeunit isolation: everything this codeunit's tests commit,
// including the shared fixture, survives from one test method to the
// next, and rolls back only once every method in the codeunit has run.
Subtype = TestRunner;
TestIsolation = Codeunit;

trigger OnRun()
begin
Codeunit.Run(Codeunit::"Sample Test Library");
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
bc-version: [all]
domain: testing
keywords: [initialize, isinitialized, shared-fixture, commit, autocommit, asserterror, testisolation, 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 method with no `[TransactionModel(...)]` attribute defaults to `AutoCommit` (see `transactionmodel-attribute-governs-test-transactions.md`): a method that completes without error commits automatically at its own boundary, with no explicit `Commit()` needed. So a lazy/shared `Initialize()` — guarded by an `IsInitialized` flag, creating master/setup data once to avoid repeating expensive setup across many `[Test]` methods — does not need `Commit()` just to survive into the next test method; under the default model it already will. (Declaring `[TransactionModel(AutoRollback)]` instead is not compatible with this pattern at all: `AutoRollback` assumes the code under test never commits, and a `Commit()` call under it raises a runtime error.)

What an early `Commit()` inside `Initialize()` actually guards against is the test method's *own later, deliberate* rollback — the BCApps cleanup idiom of ending a test with `asserterror Error(SomeLabel)` to undo demo-data mutations that method made, so the run doesn't permanently dirty the database. Per the documented `Codeunit.Run` transaction semantics, changes are committed at the end of an execution "unless an error occurs" — an unhandled error rolls back whatever wasn't already committed. `Commit()` closes out the fixture's own transaction immediately, so it is unaffected by whatever the rest of that method does afterward, including that end-of-test error. Without the early `Commit()`, the same deliberate rollback wipes out the fixture too, even though `IsInitialized` still reads `true` on the next test, since it's a plain variable, not persisted data. BCApps' `codeunit 134915 "ERM Online Mapping Setup"` shows exactly this shape: no `TransactionModel` attribute, `Commit()` inside a lazy `Initialize()`, and the test itself ends with `asserterror Error(RollBackMessage)`.

Protecting the fixture from that same-method rollback is necessary but not sufficient for the fixture to reach a *later* test method — that also depends on the executing test runner's `TestIsolation`. Under `Disabled` (the property's own documented default) or `Codeunit` (used by BCApps' own `TestRunner`, `CLITestRunner`, and `SnapTestRunner` codeunits), nothing rolls back until the whole test codeunit finishes, so the already-committed fixture survives across every method run before then. These two are not interchangeable, though: `Codeunit` rolls back everything once the codeunit's last method completes, so the environment is clean afterward; `Disabled` never rolls back anything at all — "tests are not isolated from each other" is the property's own description — so a fixture this pattern commits stays in the database permanently unless something else explicitly deletes it. Under `Function`, the runner rolls back all database changes — explicitly including ones already committed via `Commit()` — after every single test method; no amount of committing inside `Initialize()` makes a fixture shared across methods survive that regime, because the whole premise of a lazy, once-per-codeunit fixture doesn't hold when every method is isolated from every other.

## Best Practice

When a test method's own cleanup relies on ending in a deliberate error to roll back its scratch changes, call `Commit()` once, inside the lazy `Initialize()` guard, right after the shared fixture is created — before that cleanup-triggering error can run. This pattern only delivers a fixture shared across test methods when the executing runner's `TestIsolation` is `Disabled` or `Codeunit`; do not recommend it, or pair it with, a `Function`-isolated runner — that configuration undoes the committed fixture after every method regardless. Recommend `TestIsolation = Codeunit`: it gives every method in the codeunit the same shared, committed fixture and still leaves the database clean once the codeunit finishes. Recommend `Disabled` only alongside an explicit, verified teardown step that removes the fixture data at the end of the run — without one, the committed fixture is permanent contamination, not a controlled trade-off.

See sample: [`commit-shared-test-fixture-inside-lazy-initialize.good.al`](commit-shared-test-fixture-inside-lazy-initialize.good.al).

## Anti Pattern

A shared `Initialize()` guarded by `IsInitialized` that creates fixture records without committing, in a test method that ends with a deliberate `asserterror Error(...)` to undo its own scratch changes, run under a `Disabled`- or `Codeunit`-isolated test runner. That rollback also erases the never-committed fixture; the next test still finds `IsInitialized = true` but the rows it depends on are gone. (Under a `Function`-isolated runner the fixture is lost regardless of `Commit()`, for the unrelated reason above — that is a runner-configuration problem, not this anti-pattern.)

See sample: [`commit-shared-test-fixture-inside-lazy-initialize.bad.al`](commit-shared-test-fixture-inside-lazy-initialize.bad.al).

## Source

The shared/lazy `Initialize()` pattern and its `Commit()` call are drawn from Luc van Vugt's "Let's talk about Shared Fixture and how to profit from this with the Dynamics NAV Test Toolkit": https://www.fluxxus.nl/index.php/bc/let39s-talk-about-shared-fixture-and-how-to-profit-from-this-with-the-dynamics-nav-test-toolkit/. That post shows the `Commit()` call in its `Initialize()` example but does not explain the transaction mechanics behind it; the `AutoCommit`-default, `Codeunit.Run`-error, and `TestIsolation`-level analysis above is this article's own, verified independently against Microsoft's TransactionModel/TestIsolation documentation and BCApps' `codeunit 134915 "ERM Online Mapping Setup"` source, not taken from the post.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
table 50144 "Sample Setup"
{
fields
{
field(1; "Primary Key"; Code[10]) { }
field(2; "Default Category Code"; Code[20]) { }
}
keys
{
key(PK; "Primary Key") { Clustered = true; }
}
}

table 50145 "Sample Header"
{
fields
{
field(1; "No."; Code[20]) { }
field(10; "Category Code"; Code[10])
{
TableRelation = "Sample Setup"."Primary Key";
}
field(11; "Parent No."; Code[20])
{
TableRelation = "Sample Header"."No.";
}
}
keys
{
key(PK; "No.") { Clustered = true; }
}
}

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 (field/related table/
// related field all 0), not just the one known exception - this
// also strips "Parent No." -> "Sample Header"."No.", which had no
// exception and should have stayed covered by the standard test.
TableRelationTest.RemoveTableRelation(TableRelationsMetadata, Database::"Sample Header", 0, 0, 0);
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
table 50144 "Sample Setup"
{
fields
{
field(1; "Primary Key"; Code[10]) { }
field(2; "Default Category Code"; Code[20]) { }
}
keys
{
key(PK; "Primary Key") { Clustered = true; }
}
}

table 50145 "Sample Header"
{
fields
{
field(1; "No."; Code[20]) { }
// A known exception: "Category Code" predates "Sample Setup" and
// can carry a value that no longer resolves to a real row there,
// so the standard Table Relation Test would otherwise reject it -
// excluded via OnAfterRemoveTableRelation below.
field(10; "Category Code"; Code[10])
{
TableRelation = "Sample Setup"."Primary Key";
}
// An ordinary relation with no exception - ExcludeSampleFieldFrom
// TableRelationTest below must leave this one checked.
field(11; "Parent No."; Code[20])
{
TableRelation = "Sample Header"."No.";
}
}
keys
{
key(PK; "No.") { Clustered = true; }
}
}

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 only the one known exception. "Parent No." -> "Sample
// Header"."No." is untouched and stays covered by the standard test.
TableRelationTest.RemoveTableRelation(TableRelationsMetadata, Database::"Sample Header", 10, Database::"Sample Setup", 1);
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
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" (shipped in BCApps' test app — only consumers that depend on the BC test libraries can subscribe to it) reads Table Relations Metadata tenant-wide across every installed app, not just the current one, and validates each field's type and length against what its relations require — but the exact rule depends on whether that field has an *unconditional* relation (a `Table Relations Metadata` row with `Condition Field No. = 0`) among its relations, or only *conditional* ones:

- If any relation is unconditional, the field's length must equal *exactly* the largest related field's length, and its type must exactly match the required type — resolved to `Text` when the related fields themselves mix `Code` and `Text`.
- If every relation for that field is conditional, the requirement relaxes: the field only needs to be *at least* as long as the largest related field (longer is accepted; only shorter fails), and when the required type is specifically `Code`, both a `Code` and a `Text` source field pass. That `Code`/`Text` tolerance is conditional-only — it does not apply on the unconditional side, and it does not extend to a required type of `Text` (a `Code` source field does not satisfy a required `Text`).

A field with a legitimate, intentional relation shape outside both of these tolerances has no per-field override in its own object definition; the check runs with no built-in escape hatch beyond them. The validation test method itself is `[Scope('OnPrem')]`: it only runs from an on-premises test surface, not from a cloud-targeted test app, so this whole exception mechanism — and the check it works around — is only reachable where that test can actually execute.

## 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. Because the test itself is `[Scope('OnPrem')]`, do not recommend subscribing to it as a way to guard a cloud-targeted app's test suite — the subscription has no effect where the test never runs.

See sample: [`table-relation-test-exclude-known-invalid-relations-via-event.good.al`](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`](table-relation-test-exclude-known-invalid-relations-via-event.bad.al).

## Source

The `OnAfterRemoveTableRelation` exclusion technique is drawn from Luc van Vugt's "How-to: Test your Table Relations (2)": https://www.fluxxus.nl/index.php/bc/how-to-test-your-table-relations-2/. The codeunit/event signature, the `[Scope('OnPrem')]` boundary, and the tenant-wide `Table Relations Metadata` scope described above were verified directly against BCApps' `codeunit 134926 "Table Relation Test"` source, not taken from the post.
Loading
Loading