Add 18 more community AL/BC patterns across appsource, data-modeling, error-handling, security, style, testing, ui, upgrade, and web-services

Second contribution from CURABIS ApS, generalized from patterns observed across real AppSource/PTE development. Cross-checked against the current microsoft/knowledge corpus before opening; several originally-drafted candidates were dropped as duplicates of existing files.
This commit is contained in:
Michael Dieringer 2026-09-04 20:59:54 +02:00
parent 07e324ddbc
commit a4d85c3e9e
50 changed files with 1262 additions and 0 deletions

View file

@ -0,0 +1,29 @@
// Only wraps Microsoft's own generic scenario — measures BC, not this extension
codeunit 50101 "BCPT Create Sales Order" implements "BCPT Test Param. Provider"
{
SingleInstance = true;
trigger OnRun()
begin
CreateStandardSalesOrder(GlobalBCPTTestContext);
end;
var
GlobalBCPTTestContext: Codeunit "BCPT Test Context";
local procedure CreateStandardSalesOrder(var BCPTTestContext: Codeunit "BCPT Test Context")
begin
BCPTTestContext.StartScenario('Create Sales Order With N Lines');
// ... standard sales order creation, no reference to the extension's own logic
BCPTTestContext.EndScenario('Create Sales Order With N Lines');
end;
procedure GetDefaultParameters(): Text[1000]
begin
exit('');
end;
procedure ValidateParameters(Parameters: Text[1000])
begin
end;
}

View file

@ -0,0 +1,43 @@
codeunit 50100 "BCPT Create Service Request" implements "BCPT Test Param. Provider"
{
SingleInstance = true;
trigger OnRun()
begin
if not IsInitialized then begin
InitTest();
IsInitialized := true;
end;
CreateServiceRequest(GlobalBCPTTestContext);
end;
var
GlobalBCPTTestContext: Codeunit "BCPT Test Context";
IsInitialized: Boolean;
local procedure InitTest()
begin
// Set up any required configuration
end;
local procedure CreateServiceRequest(var BCPTTestContext: Codeunit "BCPT Test Context")
begin
BCPTTestContext.StartScenario('Create Service Request Header');
// ... create the service request
BCPTTestContext.EndScenario('Create Service Request Header');
BCPTTestContext.UserWait();
BCPTTestContext.StartScenario('Add Service Request Line');
// ... add a line
BCPTTestContext.EndScenario('Add Service Request Line');
end;
procedure GetDefaultParameters(): Text[1000]
begin
exit('');
end;
procedure ValidateParameters(Parameters: Text[1000])
begin
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [bcpt, performance-test, scenarios, app-specific, regression]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Include app-specific scenarios in a PerformanceTest app's BCPT suite
## Description
A PerformanceTest app that ships with only the generic Microsoft BCPT samples (creating sales orders, purchase orders, posting item journals) measures Business Central's own baseline performance, not the extension it was built to test. Those samples are starting points, not coverage. Without a scenario that exercises the extension's own business flow — its own codeunits, its own FlowFields, its own page rendering — a performance regression introduced by the extension has no test that would ever detect it.
## Best Practice
For every major business flow the extension adds, create a matching `BCPT*` scenario codeunit: `SingleInstance = true`, implementing `"BCPT Test Param. Provider"`, wrapping the operation under test in `BCPTTestContext.StartScenario()` / `EndScenario()`, and building its own test data in a local `InitTest()` procedure rather than depending on hardcoded records. Give each distinct step its own named scenario so a regression in one step doesn't hide inside a coarser measurement.
See sample: `bcpt-scenarios-must-be-app-specific.good.al`.
## Anti Pattern
A PerformanceTest app whose only scenario codeunits are copies of Microsoft's shipped samples (creating a standard sales order, opening the standard customer list) tests the platform, not the extension. Any regression in the extension's own posting logic, calculations, or pages goes unmeasured and unnoticed.
See sample: `bcpt-scenarios-must-be-app-specific.bad.al`.

View file

@ -0,0 +1,12 @@
[Test]
procedure PostSalesOrder_CreatesInvoice()
var
SalesHeader: Record "Sales Header";
begin
// [GIVEN] a sales order — posting groups left to whatever exists in the test company
LibrarySales.CreateSalesOrder(SalesHeader);
// [WHEN]
LibrarySales.PostSalesOrder(SalesHeader, false, true);
// [THEN]
Assert.RecordIsNotEmpty(SalesInvoiceHeader);
end;

View file

@ -0,0 +1,15 @@
[Test]
procedure PostSalesOrder_CreatesInvoice()
var
Customer: Record Customer;
SalesHeader: Record "Sales Header";
begin
// [GIVEN] a customer with a full posting-group chain and VAT setup
LibrarySales.CreateCustomerWithPostingSetup(Customer);
// [GIVEN] a sales order for that customer, dated explicitly
LibrarySales.CreateSalesOrderForCustomer(SalesHeader, Customer."No.", WorkDate());
// [WHEN]
LibrarySales.PostSalesOrder(SalesHeader, false, true);
// [THEN]
Assert.RecordIsNotEmpty(SalesInvoiceHeader);
end;

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [given, test-setup, posting, report, request-page, precondition, completeness]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Cover the full precondition chain in GIVEN, not just the primary record
## Description
A `[GIVEN]` block is only correct if it sets up every precondition the code under test actually reads, not just the record the scenario is "about." For most master-data tests, creating the primary record is enough. For posting routines and reports it usually is not: an incomplete `[GIVEN]` produces a test that either fails with a setup error unrelated to the scenario, or worse, passes without ever reaching the logic it claims to verify.
## Best Practice
For a posting test, set up the full posting-group chain the document requires (e.g. customer/vendor posting group, gen. business/product posting group, VAT posting setup), the setup records the specific posting path reads, and an explicit date when the path is date-sensitive — a missing link surfaces as an unrelated G/L error, not a meaningful test failure. For a report test that claims to verify filtering or dataset logic, include both a record that should be included and one that should be excluded, plus any request-page parameter or FlowField the report's logic branches on. A report test that only claims to run without error is exempt from the include/exclude pairing, but it must say so in its scenario name or comment — an unlabelled single-record `[GIVEN]` is ambiguous about which claim it is making, and that ambiguity is itself the defect.
See sample: `given-blocks-must-cover-full-precondition-chain.good.al`.
## Anti Pattern
A posting test whose `[GIVEN]` creates only the sales header, relying on whatever posting groups happen to exist in the test company. A report test whose `[GIVEN]` creates only matching records, so the report "passes" whether or not its filter logic does anything at all.
See sample: `given-blocks-must-cover-full-precondition-chain.bad.al`.

View file

@ -0,0 +1,21 @@
codeunit 50102 "Item Price Testing"
{
Subtype = Test;
var
ItemPriceMgt: Codeunit "Item Price Mgt.";
Assert: Codeunit "Library Assert";
[Test]
procedure Test1()
var
Customer: Record Customer;
Item: Record Item;
Price, Disc: Decimal;
begin
// setup mixed with assertions, no clear layers
Customer.Insert(false);
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', Price, Disc);
Assert.AreEqual(100, Price, '');
end;
}

View file

@ -0,0 +1,26 @@
// [FEATURE] Item Price — price cascade (Customer -> Price Group -> All Customers)
codeunit 50103 "Item Price Testing"
{
Subtype = Test;
var
LibrarySales: Codeunit "Library - Sales";
ItemPriceMgt: Codeunit "Item Price Mgt.";
Assert: Codeunit "Library Assert";
// [SCENARIO] Customer with a specific price list line gets that unit price
[Test]
procedure GetPrice_CustomerPrice_ReturnsUnitPrice()
var
Customer: Record Customer;
Item: Record Item;
UnitPrice, LineDiscPct: Decimal;
begin
// [GIVEN] a customer with a price list line at 100 LCY
LibrarySales.CreateCustomerWithPrice(Customer, Item, '', 100);
// [WHEN]
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
// [THEN]
Assert.AreEqual(100, UnitPrice, 'Unit price must match customer price list');
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [feature, scenario, given, when, then, tags, bdd, atdd, comments]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Tag test codeunits with FEATURE, SCENARIO, GIVEN, WHEN, and THEN comments
## Description
Test codeunits are easier to trust and to review when they carry a four-level comment structure taken from Behaviour-/Acceptance-Test-Driven Development: `[FEATURE]` once at the top of the codeunit naming the functional area under test, `[SCENARIO]` above each test procedure stating one falsifiable business claim in plain language, and `[GIVEN]`/`[WHEN]`/`[THEN]` marking the precondition, action, and assertion inside the test body. Without these tags a test procedure is an opaque block of AL that only reveals its intent by being read line by line; a reviewer or product owner cannot scan a codeunit and know what business behaviour it covers.
## Best Practice
Put `[FEATURE]` as a comment before the codeunit's opening brace, naming the domain rather than the object. Put `[SCENARIO]` immediately above each `[Test]` attribute, describing the scenario in business language that complements — not duplicates — the procedure name. Inside the body, mark the precondition setup as `[GIVEN]`, the single action under test as `[WHEN]`, and the assertions as `[THEN]`. The procedure name stays the machine-readable identity shown in test-runner output; the `[SCENARIO]` comment stays the human-readable one. Neither replaces the other.
See sample: `test-feature-scenario-tags.good.al`.
## Anti Pattern
A test procedure with no `[FEATURE]`/`[SCENARIO]`/`[GIVEN]`/`[WHEN]`/`[THEN]` structure, setup mixed freely with assertions, and a procedure name like `Test1` that says nothing about what is being verified. Nothing in the codeunit tells a reader what business rule it exists to protect.
See sample: `test-feature-scenario-tags.bad.al`.

View file

@ -0,0 +1,15 @@
[Test]
procedure GetPrice_ThenGetDiscount_ReturnsCorrectValues()
var
TempBuffer: Record "Item Price Tier Buffer" temporary;
UnitPrice, LineDiscPct: Decimal;
begin
// [GIVEN] ...
// [WHEN] first action
ItemPriceMgt.GetSalesPrice(CustomerNo, ItemNo, '', UnitPrice, LineDiscPct);
// [WHEN] second action — this is a second test in disguise
ItemPriceMgt.GetSalesPriceTiers(CustomerNo, ItemNo, '', TempBuffer);
// [THEN] asserting two unrelated things
Assert.AreEqual(100, UnitPrice, '');
Assert.IsFalse(TempBuffer.IsEmpty(), '');
end;

View file

@ -0,0 +1,29 @@
[Test]
procedure GetPrice_CustomerPrice_ReturnsCorrectUnitPrice()
var
Customer: Record Customer;
Item: Record Item;
UnitPrice, LineDiscPct: Decimal;
begin
// [GIVEN] a customer with a price list line at 100
LibrarySales.CreateCustomerWithPrice(Customer, Item, '', 100);
// [WHEN]
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
// [THEN]
Assert.AreEqual(100, UnitPrice, 'Unit price must match price list');
end;
[Test]
procedure GetPriceTiers_CustomerTier_ReturnsOneTierLine()
var
Customer: Record Customer;
Item: Record Item;
TempBuffer: Record "Item Price Tier Buffer" temporary;
begin
// [GIVEN] a customer with a tier price at min qty 10
LibrarySales.CreateCustomerWithTierPrice(Customer, Item, '', 10, 90);
// [WHEN]
ItemPriceMgt.GetSalesPriceTiers(Customer."No.", Item."No.", '', TempBuffer);
// [THEN]
Assert.AreEqual(1, TempBuffer.Count(), 'Exactly one tier line expected');
end;

View file

@ -0,0 +1,34 @@
---
bc-version: [all]
domain: testing
keywords: [when, single-action, bdd, atdd, given-when-then, flow-test, regression-test]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Keep exactly one WHEN per test, with narrow exceptions for flow and defect-then-fix tests
## Description
Each test procedure should contain exactly one `[WHEN]` block: one action that triggers the behaviour under test. A test with multiple WHENs — "do A, then do B, then check C" — is two or more tests in disguise. Splitting them gives failure isolation (a failing test points at one action, not an ambiguous sequence) and keeps each test readable as a single, falsifiable claim. A precondition action, such as posting a document so a ledger entry exists to assert against, belongs in `[GIVEN]`; only the action actually being asserted belongs in `[WHEN]`.
## Best Practice
Give each test one `[WHEN]` and one focused claim. A procedure name containing "And" or "Then" in the middle (`GetPrice_AndDiscount_ReturnsValues`) is a strong signal the test should be split.
See sample: `test-one-when-per-test.good.al`.
## Anti Pattern
A test that performs a first action, then a second unrelated action, then asserts on both — mixing two falsifiable claims into one procedure so a failure can't tell you which action broke.
See sample: `test-one-when-per-test.bad.al`.
## Flow tests — a deliberate exception
A flow test verifies the accumulated outcome of a genuinely multi-round business process (partial receipt then invoicing, several posting rounds against one document), where the sequence itself is the scenario — splitting it would lose the interaction under test. Multiple `[WHEN]` blocks are allowed only when the procedure name declares the flow, each `[WHEN]` is labelled as one round of a single scenario rather than an unrelated action, and the `[THEN]` asserts the accumulated end-state rather than assertions that decompose cleanly per action (if they do decompose cleanly, it is still two tests in disguise). Outside this shape, unit-level tests keep the strict one-WHEN rule.
## Defect-then-fix tests — a second, narrower exception
A test that reproduces a specific broken state and then verifies a subsequent action corrects it is not the same shape as an unrelated-action test, even though its `[THEN]` assertions decompose cleanly per step — clean decomposition is expected here, not a sign of two unrelated tests. This shape is permitted only when the second `[WHEN]` cannot be meaningfully tested without the first (the fix only affects the exact stale state the first action produced, so splitting would just re-run the first action inside a second test's `[GIVEN]`), and the procedure name communicates the before/after relationship.

View file

@ -0,0 +1,24 @@
codeunit 50104 "Item Price Testing"
{
Subtype = Test;
[Test]
procedure GetPrice_LogicTest()
var
Customer: Record Customer;
Item: Record Item;
UnitPrice, LineDiscPct: Decimal;
begin
// logic test — fine on its own, but not paired with a UI test below
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
end;
[Test]
procedure Page_ShowsPrice_UT()
var
ItemPricePage: TestPage "Item Price";
begin
// UI test mixed into a logic-test codeunit, and the codeunit lacks the _UT suffix
ItemPricePage.OpenNew();
end;
}

View file

@ -0,0 +1,33 @@
codeunit 50105 "Item Price Testing"
{
Subtype = Test;
[Test]
procedure GetPrice_CustomerPrice_ReturnsUnitPrice()
var
Customer: Record Customer;
Item: Record Item;
UnitPrice, LineDiscPct: Decimal;
begin
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
end;
}
codeunit 50106 "Item Price Testing_UT"
{
Subtype = Test;
[Test]
procedure Page_EnterCustomerAndItem_FactBoxShowsPrice()
var
Customer: Record Customer;
Item: Record Item;
ItemPricePage: TestPage "Item Price";
Assert: Codeunit "Library Assert";
begin
ItemPricePage.OpenNew();
ItemPricePage.CustomerNo.SetValue(Customer."No.");
ItemPricePage.ItemNo.SetValue(Item."No.");
Assert.AreEqual('100.00', ItemPricePage.PriceInfo.UnitPrice.Value(), '');
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [ui-test, testpage, naming, suffix, codeunit, page-testing]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Suffix UI-layer test codeunits with _UT and never mix layers in one codeunit
## Description
A test codeunit that drives pages through `TestPage` — opening pages, reading FactBox parts, triggering field `OnValidate` through the page — is testing a different layer than a codeunit that calls business-logic procedures directly. Readers need to know which layer a given test exercises without opening it, and a single codeunit that mixes both kinds of test hides that distinction: a failure could mean the logic broke, the page broke, or both.
## Best Practice
Give any test codeunit that uses `TestPage` a `_UT` (Unit Test — UI layer) suffix, and keep it free of tests that call codeunit/table procedures directly. Keep the corresponding logic-only codeunit unsuffixed. Allocate the two codeunits adjacent object IDs so their relationship is visible in the object list.
See sample: `ui-test-codeunit-naming.good.al`.
## Anti Pattern
A codeunit named without the `_UT` suffix that nonetheless contains `TestPage` calls, or — worse — one codeunit that mixes a direct logic-call test and a `TestPage`-driven test side by side. Either way, the codeunit's name no longer tells a reader which layer a failing test actually broke.
See sample: `ui-test-codeunit-naming.bad.al`.