Merge main into community-contribution/vanvugt-blog-patterns

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jesper Schulz-Wedde 2026-09-29 12:54:39 +02:00
commit fb1ff3db7b
70 changed files with 1406 additions and 9 deletions

View file

@ -0,0 +1,14 @@
[Test]
procedure PostsSalesOrderForCashCustomer()
var
Customer: Record Customer;
SalesHeader: Record "Sales Header";
begin
// Assumes a 'CASH' customer already exists in the environment —
// fails on any database where it doesn't.
Customer.Get('CASH');
LibrarySales.CreateSalesHeader(
SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
// ... add lines, post, assert ...
end;

View file

@ -0,0 +1,13 @@
[Test]
procedure PostsSalesOrderForRandomCustomer()
var
Customer: Record Customer;
SalesHeader: Record "Sales Header";
begin
// Freshly created customer, owned by this test — no assumption about what exists.
LibrarySales.CreateCustomer(Customer);
LibrarySales.CreateSalesHeader(
SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
// ... add lines, post, assert ...
end;

View file

@ -0,0 +1,30 @@
---
bc-version: [all]
domain: testing
keywords: [testing, test-data, random, library, any]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Generate Test Data Programmatically, Never Assume Existing Records
> Contributions welcome — open a PR to refine or extend this article.
## Description
A BC test company normally contains initialized system/setup data — an AL test suite should not assume an empty database, but it must be independent of unrelated business records: create the records and setup it owns rather than looking up a specific code, number, or name assumed to already exist, since that makes the test fail for reasons unrelated to the code under test. Every mandatory field on a created record also needs an actual value — leaving one blank because setup-time validation happens to allow it produces a record that doesn't reflect a real one and can fail later, elsewhere in the flow (posting, a report, a later assertion), for a reason unrelated to what the test claims to check. A short-but-valid value is not itself a defect: AL field lengths are maxima, not minimums, so a two-character value in a `Text[100]` field is fine unless the scenario specifically depends on the field's length or shape — for example, a test that verifies truncation or a format check needs a value chosen to exercise that boundary, not an arbitrary short one.
Not every value should be generated, though. Incidental fixture data — identifiers, names, descriptions — should generally come from the standard library codeunits rather than be tied to specific existing data. But values that materially define the scenario under test — amounts, quantities, percentages, dates, thresholds, rounding precision — should stay explicit and deliberately chosen, not randomized: a rounding test needs values placed deliberately around the rounding boundary, not a random one that might miss it entirely.
## Best Practice
Use the standard library codeunits (`Library - ERM`, `Library - Inventory`, `Library - Sales`, `Library - Utility`) to generate incidental fixture values — they produce valid, unique-enough data via number series and controlled randomness, not a mathematical collision-free guarantee — and fill every mandatory field with correctly-sized data. Keep values that define the scenario's expected outcome explicit and fixed. Reserve hardcoded values for tests that validate an external contract itself — a fixed JSON schema, an EDIFACT message, a counterparty code — where the hardcoded value documents the specification rather than arbitrary test logic.
See sample: [`test-data-must-be-random-and-complete.good.al`](test-data-must-be-random-and-complete.good.al).
## Anti Pattern
Looking up a record assumed to already exist (a hardcoded payment method or customer number) instead of creating it, or leaving a mandatory field empty because setup-time validation happens to allow it. Also an anti-pattern, narrower: using a value that doesn't satisfy a scenario's explicit length or format requirement — for example a truncation test that never actually exceeds the field it's meant to overflow.
See sample: [`test-data-must-be-random-and-complete.bad.al`](test-data-must-be-random-and-complete.bad.al).

View file

@ -0,0 +1,10 @@
codeunit 50132 "Sample Customer Type Library"
{
procedure CreateCustomerType(var CustomerType: Record "Customer Type")
begin
CustomerType.Init();
CustomerType.Code := 'TEST001';
CustomerType.Description := 'Test Customer Type';
CustomerType.Insert(true);
end;
}

View file

@ -0,0 +1,21 @@
codeunit 50132 "Sample Customer Type Library"
{
var
LibraryUtility: Codeunit "Library - Utility";
procedure CreateCustomerType(var CustomerType: Record "Customer Type")
begin
CustomerType.Init();
// Code is shorter than GenerateGUID()'s 10 characters, and this field's
// uniqueness matters, so use GenerateRandomCodeWithLength: it opens the
// real (non-temporary) table and loops until the value doesn't collide.
// GenerateRandomCode would not do this — it opens the table as temporary,
// so its own emptiness check never inspects real rows.
CustomerType.Code :=
LibraryUtility.GenerateRandomCodeWithLength(CustomerType.FieldNo(Code), Database::"Customer Type", MaxStrLen(CustomerType.Code));
// Description is long enough to hold the full GenerateGUID() value
// untruncated, and only needs to be incidental, not verified-unique.
CustomerType.Description := CopyStr(LibraryUtility.GenerateGUID(), 1, MaxStrLen(CustomerType.Description));
CustomerType.Insert(true);
end;
}

View file

@ -0,0 +1,33 @@
---
bc-version: [all]
domain: testing
keywords: [generateguid, library-utility, test-fixtures, uniqueness, generaterandomcode, maxstrlen]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Generate unique test fixture values with LibraryUtility helpers, not hardcoded literals
## Description
A fixture helper that assigns a hardcoded literal to a primary-key field, or to any field the test relies on as a unique lookup identifier, collides the moment two tests, or two runs of the same test, create that fixture without cleanup — and a literal longer than the field allows raises a truncation or insert error. An ordinary descriptive field carries no such constraint: two rows with the same description do not collide on insert, and a deterministic descriptive value is often exactly what an exact-match assertion needs, so none of this applies to it. `LibraryUtility.GenerateGUID()` is not a real GUID — it is a `Code[10]` number-series value (`GU00000000`–`GU99999999`) — and it returns the full 10 characters unshortened. Truncating it yourself with `CopyStr(..., 1, MaxStrLen(ShorterField))` for a field under 10 characters is unsafe: the changing digits sit at the right end and are exactly what gets cut off, so consecutive calls into a short field can produce the same truncated value. `GenerateGUID()` is only safe as-is for a field that holds the full 10 characters.
## Best Practice
For a field that holds the full 10 characters, assign `LibraryUtility.GenerateGUID()` directly. For a shorter field, do not truncate a GUID yourself — but also do not assume every `LibraryUtility` helper verifies uniqueness against the real table, because they don't all behave the same way:
- `GenerateRandomCode(FieldNo, TableNo)` opens the target table as a **temporary** `RecordRef`: the buffer starts and stays empty, so its `repeat...until RecRef.IsEmpty()` loop always exits after one iteration — despite taking `TableNo`, it never checks the real table, and it never retries even within its own call. Its value is the rightmost `FieldRef.Length` characters of `GenerateGUID()`'s sequential `GU00000000`–`GU99999999` series, so for a short field that window of digits cycles: a 1-character field repeats every 10 calls, a 2-character field every 100, and so on. It is a finite short-field namespace with a low collision *chance* within one test run — not a guarantee at any scope, unlike the table-checking helpers below.
- `GenerateRandomCodeWithLength(FieldNo, TableNo, CodeLength)` opens the real (non-temporary) table and loops until the generated value doesn't collide — a genuine verified-unique guarantee — but it returns `Code[10]` regardless of the requested `CodeLength`, so it's only useful for a field of 10 characters or fewer.
- `GenerateRandomCode20(FieldNo, TableNo)` is the same real, verified-against-the-table pattern as `GenerateRandomCodeWithLength`, sized for a `Code[20]` field.
- `GenerateRandomXMLText(Length)` performs no table lookup at all — it's a plain random-text generator, appropriate for a descriptive/incidental field where uniqueness doesn't matter, not for a value that needs to be collision-checked.
Pick `GenerateRandomCodeWithLength`/`GenerateRandomCode20` when the test genuinely needs a code verified unique against the table; use `GenerateRandomCode`/`GenerateGUID`/`GenerateRandomXMLText` for incidental values where a low collision *chance* is enough.
See sample: [`use-generateguid-for-unique-test-fixture-values.good.al`](use-generateguid-for-unique-test-fixture-values.good.al).
## Anti Pattern
Hardcoding a primary-key or unique-lookup fixture value such as `'TEST001'`, which collides across parallel or repeated test runs — a fixed descriptive value is not this anti-pattern, since the field carries no uniqueness constraint. Equally an anti-pattern: truncating `GenerateGUID()`'s result with `CopyStr(..., 1, MaxStrLen(Field))` for a field shorter than 10 characters — the truncation removes the part of the value that actually varies.
See sample: [`use-generateguid-for-unique-test-fixture-values.bad.al`](use-generateguid-for-unique-test-fixture-values.bad.al).

View file

@ -0,0 +1,27 @@
codeunit 50134 "Sample Customer Type Edit Test"
{
Subtype = Test;
[Test]
procedure CustomerTypeFieldNotEditable_WhenLocked()
var
Assert: Codeunit Assert;
CustomerType: Record "Customer Type";
CustomerTypeCard: TestPage "Customer Type Card";
begin
// [GIVEN] a customer type record whose Locked flag is set
CustomerType.Init();
CustomerType.Locked := true;
CustomerType.Insert(true);
// [WHEN] the page is opened in VIEW mode — editability logic that only
// applies in edit mode is not exercised the same way
CustomerTypeCard.OpenView();
CustomerTypeCard.GoToRecord(CustomerType);
// [THEN] wrong function: Enabled() does not verify editability
Assert.IsFalse(CustomerTypeCard.Description.Enabled(), 'Description should not be editable while Locked is set.');
CustomerTypeCard.Close();
end;
}

View file

@ -0,0 +1,26 @@
codeunit 50133 "Sample Customer Type Edit Test"
{
Subtype = Test;
[Test]
procedure CustomerTypeFieldNotEditable_WhenLocked()
var
Assert: Codeunit Assert;
CustomerType: Record "Customer Type";
CustomerTypeCard: TestPage "Customer Type Card";
begin
// [GIVEN] a customer type record whose Locked flag is set
CustomerType.Init();
CustomerType.Locked := true;
CustomerType.Insert(true);
// [WHEN] the page is opened in edit mode on that record
CustomerTypeCard.OpenEdit();
CustomerTypeCard.GoToRecord(CustomerType);
// [THEN] the field's actual editable state reflects the lock
Assert.IsFalse(CustomerTypeCard.Description.Editable(), 'Description should not be editable while Locked is set.');
CustomerTypeCard.Close();
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [testpage, editable, openedit, ui-state, field-verification]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Verify field editability with TestPage.Editable(), opened in edit mode
## Description
Whether a field can actually be changed is a distinct state from whether it is shown or enabled — `Editable()` and `Enabled()` are separate `TestField` functions. Verifying editability also requires opening the `TestPage` with `OpenEdit()`, not `OpenView()`: `OpenView()` opens the page in view mode, so it does not exercise the field's own conditional editability logic the way an actual edit-mode session does.
## Best Practice
Open the `TestPage` with `OpenEdit()`, navigate to the relevant record, then assert against `TestPageField.Editable()` to verify whether the field can be changed under the given precondition.
See sample: [`use-testpage-editable-to-verify-field-editability.good.al`](use-testpage-editable-to-verify-field-editability.good.al).
## Anti Pattern
Asserting `Enabled()` (or checking nothing at all) when the actual claim is about editability, or opening the page with `OpenView()` when the field's editability depends on business logic that only applies in edit mode.
See sample: [`use-testpage-editable-to-verify-field-editability.bad.al`](use-testpage-editable-to-verify-field-editability.bad.al).

View file

@ -0,0 +1,14 @@
codeunit 50131 "Sample Customer Type UI Test"
{
Subtype = Test;
[Test]
procedure CustomerTypeFieldIsEnabledOnCustomerCard()
var
CustomerCard: TestPage "Customer Card";
begin
// Confirms only that the page opens - never checks the field's actual UI state
CustomerCard.OpenView();
CustomerCard.Close();
end;
}

View file

@ -0,0 +1,15 @@
codeunit 50131 "Sample Customer Type UI Test"
{
Subtype = Test;
[Test]
procedure CustomerTypeFieldIsEnabledOnCustomerCard()
var
Assert: Codeunit Assert;
CustomerCard: TestPage "Customer Card";
begin
CustomerCard.OpenView();
Assert.IsTrue(CustomerCard."Customer Type".Enabled(), 'Customer Type should be enabled on the Customer Card.');
Assert.IsTrue(CustomerCard."Customer Type".Visible(), 'Customer Type should be visible on the Customer Card.');
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [testpage, visible, enabled, ui-state, headless-test, field-verification]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Verify field visibility and enabled state with TestPage.Visible()/.Enabled()
## Description
A UI test codeunit does not need to inspect table or page properties indirectly to confirm a field is shown or enabled under given conditions. The `TestPage` object exposes a `Visible()` and an `Enabled()` function on each field, reflecting the page's actual rendered state, callable directly from a `[Test]` procedure. `Enabled()` and `Editable()` are distinct states — this article covers visibility/enabled state specifically; see `use-testpage-editable-to-verify-field-editability.md` for verifying whether a field can actually be changed.
## Best Practice
Open the `TestPage`, navigate to the relevant record if needed, then assert against `TestPageField.Visible()` and `TestPageField.Enabled()` to verify the field's shown/enabled state, rather than checking an unrelated table/page property or skipping the check.
See sample: [`use-testpage-visible-enabled-to-verify-field-ui-state.good.al`](use-testpage-visible-enabled-to-verify-field-ui-state.good.al).
## Anti Pattern
A test that opens the `TestPage` but never asserts against `Visible()`/`Enabled()` on the field in question — confirming only that the page opens, not that the field behaves as expected.
See sample: [`use-testpage-visible-enabled-to-verify-field-ui-state.bad.al`](use-testpage-visible-enabled-to-verify-field-ui-state.bad.al).