18 more AL/BC patterns: data-modeling, testing, style, security, error-handling, ui, upgrade, web-services, appsource (#157)

* 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.

* Address Jesper Schulz-Wedde's review on PR #157

- release-must-update-app-version.md: reframe around AppSource's actual
  strict full-version-ordering requirement; scope branching-policy
  claims as team convention, not platform rule.
- pictures-must-use-media-not-blob.md: MediaSet is a collection of
  independent media objects, not automatic image variants/thumbnails.
- log-writes-must-survive-rollback.{md,good.al}: StartSession's only
  data channel into the new session is its Record parameter to a
  TableNo-scoped codeunit; a setter called on a local instance before
  starting the session populates nothing in the new session.
- exposed-objects-must-be-in-a-permission-set.md: correct the three
  exposure mechanisms (Web Services config, PageType/QueryType=API,
  ServiceEnabled as a method-only attribute).
- pages-must-not-contain-business-logic.md: scope to persisted
  mutations and cross-entry-point rules; presentation-only
  calculations and table-owned invariants are not violations.
- given-blocks-must-cover-full-precondition-chain.good.al: replace
  invented LibrarySales calls with the real API
  (CreateCustomer/CreateSalesOrderForCustomerNo/PostSalesDocument).
- test-feature-scenario-tags.{md,good.al}: move [SCENARIO] inside the
  test procedure body to match the current BCApps corpus; keep
  [FEATURE] at codeunit level per Microsoft's own documented option.
- ui-test-codeunit-naming.md: scope the _UT suffix and adjacent-ID
  pairing as an explicit team convention, not a BCApps-wide standard.
- page-design-must-match-bc-page-type-conventions.md /
  table-design-must-match-bc-table-type-conventions.md: Card's
  single-key primary-key claim is a contextual heuristic, not a
  mandatory constraint (Ship-to Address, Customer/Vendor Bank Account
  are real composite-key Card pages); a Subsidiary table with its own
  identity commonly gets List+Card, not Worksheet/Tabular.
- api-page-least-privilege-write-access.{md,good.al}: only page-placed
  fields are ever exposed; set InsertAllowed/DeleteAllowed=false in the
  good sample so a narrow field set can't still create/delete records.
- source-organized-by-feature-not-object-type.md,
  test-one-when-per-test.md: scope as team/testing-design conventions,
  not Microsoft platform requirements.
- upgrade-tag-logic-must-not-nest-deeply.md: add the Microsoft Learn
  citation that already backs the two-level nesting limit.
- Wire the new articles into the testing/data-modeling/error-handling/
  security/ui review skills' candidate-selection signals.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Address second round of Jesper Schulz-Wedde's review on PR #157

- log-writes-must-survive-rollback.good.al: fixed invalid trigger
  OnRun(var Rec: ...) declaration; Rec is implicit when TableNo is set.
- exposed-objects-must-be-in-a-permission-set.md: distinguished the three
  exposure mechanisms (page/query web service or API, codeunit published
  as a web service, [ServiceEnabled] bound action on a page) and their
  actual permission targets (page/query "..." = X vs codeunit "..." = X).
- code-must-not-change-workdate.md: scoped from an absolute "never" to
  "not as a side effect of unrelated logic" - verified real WorkDate(x)
  setter usage in BCApps demo-data generators and test codeunits.
- bcpt-scenarios-must-be-app-specific.md: SingleInstance and
  StartScenario/EndScenario reframed as context-dependent patterns, not
  mandatory requirements - BCPT Create Customer uses neither.
- test-feature-scenario-tags.good.al/.bad.al: replaced the invented
  LibrarySales.CreateCustomerWithPrice/"Item Price Mgt." calls with a real,
  verified price-list-line test using Library - Sales/Library - Inventory/
  Library - Price Calculation.
- page-design-must-match-bc-page-type-conventions.md: scoped the missing
  UsageCategory anti-pattern to pages intended as searchable entry points.
- defensive-vs-offensive-code-must-match-blast-radius.md/.good.al/.bad.al:
  replaced the VAT registration number "low blast radius" example with a
  genuinely cosmetic field (customer home page URL).
- source-organized-by-feature-not-object-type.md: anti-pattern reframed as
  inconsistency with a repo's own convention, not the object-type scheme
  itself.
- pictures-must-use-media-not-blob.md: removed leftover "image variants"
  wording contradicting the already-corrected MediaSet description.

Proactively fixed while sweeping all fixtures for invented APIs:
- given-blocks-must-cover-full-precondition-chain.bad.al: PostSalesOrder
  called with wrong arity and referenced an undeclared variable.
- ui-test-codeunit-naming.good.al/.bad.al: replaced the same fake
  "Item Price Mgt."/TestPage "Item Price" with real Library - Sales calls
  and the real Customer Card TestPage.

Worklist completeness: added review-skill cues for the 12 of 18 new rules
that had none (al-appsource-review.md, al-data-modeling-review.md,
al-error-handling-review.md, al-security-review.md, al-style-review.md x3,
al-testing-review.md x2, al-ui-review.md, al-upgrade-review.md,
al-web-services-review.md), and fixed test-feature-scenario-tags' cue,
which only matched the compliant (tagged) shape instead of the anti-pattern
(untagged/generic-named test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix ten focused correctness items plus sample links from Jesper's 2026-09-15 re-review

Six carried-over threads:
- api-page-least-privilege-write-access fixtures: added the mandatory
  EntityName/EntitySetName properties (AL0485).
- pages-must-not-contain-business-logic fixtures: Sales Line has no
  "Total Amount" field; replaced with the real "Line Amount" (field 103).
- test-feature-scenario-tags.good.al and test-one-when-per-test.good.al:
  CreatePriceHeader leaves a price list in Draft status, which price
  calculation ignores. Added Validate(Status, Active) + Modify before the
  sales line that depends on it. Verified Status field/enum against
  PriceListHeader.Table.al and PriceStatus.Enum.al in the BCApps clone.
- exposed-objects-must-be-in-a-permission-set.md: a published codeunit is
  a SOAP endpoint (SOAP is deprecated), not OData - Page/Query are the
  OData object types. Corrected and pointed new integrations at API
  pages/queries instead.
- al-error-handling-review.md: the log-writes-must-survive-rollback cue
  selected on Session.StartSession, which only appears in the compliant
  fix, never in the anti-pattern - the bad fixture could never be
  worklisted. Recued on the actual risk shape (log insert around a
  failed TryFunction/GetLastError* path, then raise/propagate), with
  StartSession as an explicit compliant discriminator instead.
- page-design-must-match-bc-page-type-conventions.md: the enum value is
  NavigatePage, not Navigate; noted the type list is a selected subset,
  not an exhaustive PageType catalogue (PromptDialog, ConfigurationDialog,
  UserControlHost, XmlPort also exist, out of this article's scope).

Four new correctness gaps:
- release-must-update-app-version.md: "the version is the only identity"
  was backwards - id is the app's stable identity, version identifies a
  release/code-state of it.
- defensive-vs-offensive-code-must-match-blast-radius.good.al: the "low
  blast radius" example had no else branch, so a failed Customer.Get()
  left the field at its prior/default value instead of the explicit
  chosen fallback the article claims to demonstrate. Added the else.
- bcpt-scenarios-must-be-app-specific.good.al: InitTest and both measured
  StartScenario/EndScenario sections were empty/comment-only, so the
  "app-specific" fixture measured no actual work. Filled in a real,
  self-contained header+line creation path.
- upgrade-tag-logic-must-not-nest-deeply.good.al: the flattened version
  dropped both safety conditions the bad fixture had (Discount % = 0,
  nonblank posting group), silently changing behavior instead of just
  removing nesting. Extracted the guarded update into a helper with both
  conditions preserved as early exits.

Also converted this PR's remaining plain-backtick "See sample:" sample
references (16 articles) to the READ-convention markdown-link form,
matching the fix already made on #156/#158.

Rebased onto upstream/main (conflicts in al-ui-review.md, al-style-review.md,
al-upgrade-review.md against merged upstream PRs - all additive, both
sides' worklist cues retained).

* Fix four merge-critical issues from Jesper's 2026-09-22 review

- pages-must-not-contain-business-logic.good.al/.bad.al: the "good"
  codeunit still directly assigned real Sales Line."Line Amount" and
  called Modify(), bypassing the field's normal Validate cascade
  (discount, VAT, related-amount maintenance) - persisting inconsistent
  document lines regardless of which object the code lived in. Replaced
  the real Sales Line example with a self-contained "Sample Order Line"
  table and switched the codeunit to Validate()/Modify(true), so the
  fixture demonstrates the page-vs-codeunit separation without teaching
  unsafe direct field writes to a real BC document table.
- bcpt-scenarios-must-be-app-specific.good.al: Customer.FindFirst()
  assumed a pre-existing customer (fails against an empty environment),
  and a session-local NextNo counter for the header key collides across
  concurrent BCPT sessions and repeated runs. Creates its own customer
  when none exists, and generates keys from CreateGuid() instead of an
  in-memory counter.
- upgrade-tag-logic-must-not-nest-deeply: the rule conflated two
  different things - nesting one tag's existence check inside another
  (the real anti-pattern Microsoft's guidance warns against) with
  having business-data safety conditions inside a single tagged
  migration's own loop body (which Microsoft's own worked example does,
  and its own design guidance explicitly requires: "Implement extra
  safety checks to avoid data corruption, even though you're using
  upgrade tags"). Rewrote the Description/Best Practice/Anti Pattern to
  scope the rule to actual tag nesting and migrations blended under one
  tag, and rewrote both fixtures: good.al now shows two safety
  conditions correctly nested inside one migration's own loop plus a
  second, genuinely separate migration as its own flat tagged
  procedure; bad.al now shows the real anti-pattern, one tag's check
  nested inside another's guarded body.
- table-design-must-match-bc-table-type-conventions: the rule and its
  worklist cue fired on any new table with a keys block, forcing
  buffers, queues, logs, mapping tables, and staging tables into the
  nearest-looking one of nine business-record archetypes. Added an
  explicit scope note that these nine types aren't an exhaustive table
  catalogue, and narrowed the al-data-modeling-review.md cue to require
  positive evidence (a type-specific naming suffix, key shape, or
  usage) before worklisting, instead of a bare keys/primary-key
  declaration.

* Narrow upgrade-tag nesting cue to match revised article

Cue now flags only nested upgrade-tag checks or functionally unrelated
migrations under one tag, and explicitly excludes record loops and
business-data safety guards belonging to a single migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Jesper Schulz-Wedde <jesper.schulzwedde@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Michael Dieringer 2026-09-29 17:12:51 +02:00 • committed by GitHub
parent 4287233f80
commit f63943dcfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1612 additions and 2 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,73 @@
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";
CustomerNo: Code[20];
IsInitialized: Boolean;
local procedure InitTest()
var
Customer: Record Customer;
begin
// Do not assume a customer already exists: a BCPT run may target an
// otherwise-empty environment. Create one if none is found instead
// of failing on FindFirst().
if not Customer.FindFirst() then begin
Customer.Init();
Customer."No." := GenerateUniqueCode(MaxStrLen(Customer."No."));
Customer.Insert(true);
end;
CustomerNo := Customer."No.";
end;
local procedure GenerateUniqueCode(Length: Integer): Code[20]
begin
// A GUID-derived code, not a session-local counter: it stays unique
// across concurrent BCPT sessions and repeated runs against the
// same environment, which an in-memory counter reset per session
// cannot guarantee.
exit(CopyStr(DelChr(Format(CreateGuid()), '=', '{}-'), 1, Length));
end;
local procedure CreateServiceRequest(var BCPTTestContext: Codeunit "BCPT Test Context")
var
ServiceRequestHeader: Record "Service Request Header";
ServiceRequestLine: Record "Service Request Line";
begin
BCPTTestContext.StartScenario('Create Service Request Header');
ServiceRequestHeader.Init();
ServiceRequestHeader."No." := GenerateUniqueCode(MaxStrLen(ServiceRequestHeader."No."));
ServiceRequestHeader.Validate("Customer No.", CustomerNo);
ServiceRequestHeader.Insert(true);
BCPTTestContext.EndScenario('Create Service Request Header');
BCPTTestContext.UserWait();
BCPTTestContext.StartScenario('Add Service Request Line');
ServiceRequestLine.Init();
ServiceRequestLine."Document No." := ServiceRequestHeader."No.";
ServiceRequestLine."Line No." := 10000;
ServiceRequestLine.Description := 'Performance test line';
ServiceRequestLine.Insert(true);
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 implementing `"BCPT Test Param. Provider"`, building its own test data in a local `InitTest()` procedure rather than depending on hardcoded records. Beyond that shared shape, the interface details are context-dependent, not fixed requirements: most of Microsoft's own shipped BCPT samples declare `SingleInstance = true`, but `codeunit "BCPT Create Customer"` does not, relying instead on `OnRun` calling `InitTest()` unconditionally every run. Likewise, wrapping the operation under test in `BCPTTestContext.StartScenario()` / `EndScenario()` is a real, available pattern for splitting one codeunit's run into several separately measured steps — useful when a regression in one step should not hide inside a coarser, whole-`OnRun` measurement — but it is not what every sample does; `"BCPT Create Customer"` measures its entire `OnRun` as a single implicit scenario and never calls `StartScenario`/`EndScenario` at all. Choose per-step scenarios when step-level granularity matters to the flow being tested; otherwise a single measured `OnRun` is a legitimate, simpler choice.
See sample: [`bcpt-scenarios-must-be-app-specific.good.al`](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`](bcpt-scenarios-must-be-app-specific.bad.al).

View file

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

View file

@ -0,0 +1,20 @@
[Test]
procedure PostSalesOrder_CreatesInvoice()
var
Customer: Record Customer;
SalesHeader: Record "Sales Header";
SalesInvoiceHeader: Record "Sales Invoice Header";
InvoiceNo: Code[20];
begin
// [GIVEN] a customer
LibrarySales.CreateCustomer(Customer);
// [GIVEN] a sales order for that customer
LibrarySales.CreateSalesOrderForCustomerNo(SalesHeader, Customer."No.");
SalesHeader.Validate("Posting Date", WorkDate());
SalesHeader.Modify(true);
// [WHEN]
InvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, false, true);
// [THEN]
SalesInvoiceHeader.Get(InvoiceNo);
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`](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`](given-blocks-must-cover-full-precondition-chain.bad.al).

View file

@ -0,0 +1,33 @@
codeunit 50102 "Item Price Testing"
{
Subtype = Test;
var
LibrarySales: Codeunit "Library - Sales";
LibraryInventory: Codeunit "Library - Inventory";
LibraryPriceCalculation: Codeunit "Library - Price Calculation";
Assert: Codeunit "Library Assert";
[Test]
procedure Test1()
var
Customer: Record Customer;
Item: Record Item;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// setup mixed with assertions, no clear layers, no FEATURE/SCENARIO/GIVEN/WHEN/THEN tags
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", '');
end;
}

View file

@ -0,0 +1,40 @@
// [FEATURE] Item Price — price cascade (Customer -> Price Group -> All Customers)
codeunit 50103 "Item Price Testing"
{
Subtype = Test;
var
LibrarySales: Codeunit "Library - Sales";
LibraryInventory: Codeunit "Library - Inventory";
LibraryPriceCalculation: Codeunit "Library - Price Calculation";
Assert: Codeunit "Library Assert";
[Test]
procedure GetPrice_CustomerPrice_ReturnsUnitPrice()
var
Customer: Record Customer;
Item: Record Item;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// [SCENARIO] Customer with a specific price list line gets that unit price
// [GIVEN] a customer and an item with a customer-specific sales price list line
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// CreatePriceHeader leaves the list in Draft status, which price calculation ignores.
PriceListHeader.Validate(Status, PriceListHeader.Status::Active);
PriceListHeader.Modify(true);
// [WHEN] a sales line is created for that customer and item
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
// [THEN] the sales line picks up the customer's price list line
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", '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 — Microsoft's own guidance allows setting it once for the whole codeunit, inherited by every test in it. Put `[SCENARIO]`, matching the current BCApps corpus, as the first comment inside each test procedure's body (after `begin`), describing the scenario in business language that complements — not duplicates — the procedure name, followed by `[GIVEN]` marking the precondition setup, `[WHEN]` marking the single action under test, and `[THEN]` marking the assertions. 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`](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`](test-feature-scenario-tags.bad.al).

View file

@ -0,0 +1,32 @@
[Test]
procedure GetPrice_ThenGetPriceLines_ReturnsCorrectValues()
var
Customer: Record Customer;
Item: Record Item;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// [GIVEN] ...
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// [WHEN] first action
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
// [WHEN] second action — this is a second test in disguise
PriceListLine.Validate("Minimum Quantity", 10);
PriceListLine.Modify(true);
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// [THEN] asserting two unrelated things
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", '');
PriceListLine.SetRange("Price List Code", PriceListHeader.Code);
Assert.AreEqual(2, PriceListLine.Count(), '');
end;

View file

@ -0,0 +1,56 @@
[Test]
procedure GetPrice_CustomerPrice_ReturnsCorrectUnitPrice()
var
Customer: Record Customer;
Item: Record Item;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// [GIVEN] a customer with a price list line for the item
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// CreatePriceHeader leaves the list in Draft status, which price calculation ignores.
PriceListHeader.Validate(Status, PriceListHeader.Status::Active);
PriceListHeader.Modify(true);
// [WHEN]
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
// [THEN]
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", 'Unit price must match price list');
end;
[Test]
procedure GetPriceLines_TwoMinimumQuantityLines_ReturnsBoth()
var
Customer: Record Customer;
Item: Record Item;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
begin
// [GIVEN] a customer price list with two minimum-quantity price lines for the same item
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
PriceListLine.Validate("Minimum Quantity", 10);
PriceListLine.Modify(true);
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
PriceListLine.Validate("Minimum Quantity", 50);
PriceListLine.Modify(true);
// [WHEN]
PriceListLine.SetRange("Price List Code", PriceListHeader.Code);
// [THEN]
Assert.AreEqual(2, PriceListLine.Count(), 'Exactly two price lines 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
This is a testing-design practice, not a BC platform requirement — no AL API enforces it, and it should not gate a change the way a platform-contradicted claim would. 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`](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`](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,27 @@
codeunit 50104 "Item Price Testing"
{
Subtype = Test;
[Test]
procedure ApplyDiscount_LogicTest()
var
Assert: Codeunit "Library Assert";
begin
// logic test — fine on its own, but not paired with a UI test below
Assert.AreEqual(90, ApplyDiscount(100, 10), 'A 10% discount on 100 must yield 90');
end;
local procedure ApplyDiscount(UnitPrice: Decimal; DiscountPct: Decimal): Decimal
begin
exit(UnitPrice - (UnitPrice * DiscountPct / 100));
end;
[Test]
procedure CustomerCard_Opens_UT()
var
CustomerCard: TestPage "Customer Card";
begin
// UI test mixed into a logic-test codeunit, and the codeunit lacks the _UT suffix
CustomerCard.OpenNew();
end;
}

View file

@ -0,0 +1,42 @@
codeunit 50105 "Item Price Testing"
{
Subtype = Test;
[Test]
procedure ApplyDiscount_ReducesUnitPrice()
var
Assert: Codeunit "Library Assert";
DiscountedPrice: Decimal;
begin
DiscountedPrice := ApplyDiscount(100, 10);
Assert.AreEqual(90, DiscountedPrice, 'A 10% discount on 100 must yield 90');
end;
local procedure ApplyDiscount(UnitPrice: Decimal; DiscountPct: Decimal): Decimal
begin
exit(UnitPrice - (UnitPrice * DiscountPct / 100));
end;
}
codeunit 50106 "Item Price Testing_UT"
{
Subtype = Test;
[Test]
procedure CustomerCard_SetName_UpdatesField()
var
Customer: Record Customer;
CustomerCard: TestPage "Customer Card";
Assert: Codeunit "Library Assert";
LibrarySales: Codeunit "Library - Sales";
begin
LibrarySales.CreateCustomer(Customer);
CustomerCard.OpenEdit();
CustomerCard.GoToRecord(Customer);
CustomerCard.Name.SetValue('Updated Name');
CustomerCard.Close();
Customer.Get(Customer."No.");
Assert.AreEqual('Updated Name', Customer.Name, 'Name must be updated through the page');
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [ui-test, testpage, naming, suffix, codeunit, page-testing, team-convention]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Separate UI-layer and logic-layer tests into different codeunits
## 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. The `_UT` suffix and adjacent-object-ID pairing below are one team's naming convention for making that split visible, not a BCApps-wide naming standard — BCApps itself uses `UT` for unit tests generally, not specifically to mean "UI layer," and does not treat adjacent object IDs as a semantic pairing mechanism. Apply the suffix only on a project that has explicitly adopted this convention.
## Best Practice
Keep UI-layer (`TestPage`-driven) and logic-layer tests in separate codeunits regardless of naming. Projects that adopt a `_UT`-style suffix convention should apply it consistently to every UI-layer test codeunit, keep the corresponding logic-only codeunit unsuffixed, and document the convention where the team's other naming rules live.
See sample: [`ui-test-codeunit-naming.good.al`](ui-test-codeunit-naming.good.al).
## Anti Pattern
One codeunit that mixes a direct logic-call test and a `TestPage`-driven test side by side — a failing test no longer tells a reader which layer actually broke. On a project that has adopted the `_UT` convention, a UI-layer codeunit missing the suffix is also an instance of this anti-pattern; on a project that has not adopted it, the suffix itself is not required.
See sample: [`ui-test-codeunit-naming.bad.al`](ui-test-codeunit-naming.bad.al).