Move testing knowledge from community to microsoft layer

Relocates the six P1+P2 testing-domain articles (18 files: .md + .good.al + .bad.al each) from community/knowledge/testing/ to microsoft/knowledge/testing/ per maintainer request. Pure git-mv rename; no content or frontmatter changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jesper Schulz-Wedde 2026-06-29 16:21:29 +02:00
parent 6c02d68b4e
commit 587f199814
18 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,18 @@
codeunit 50409 "Test AssertError Bad"
{
Subtype = Test;
[Test]
procedure BlankNameIsRejectedWithSpecificError()
var
Customer: Record Customer;
begin
Customer.Init();
Customer.Name := '';
// Bare asserterror: passes if ANY error is raised. A relation error,
// a permission error, or a typo elsewhere would all satisfy it so
// this never proves the blank-name guard is the thing that fired.
asserterror Customer.TestField(Name);
end;
}

View file

@ -0,0 +1,23 @@
codeunit 50408 "Test AssertError Good"
{
Subtype = Test;
[Test]
procedure BlankNameIsRejectedWithSpecificError()
var
Customer: Record Customer;
begin
Customer.Init();
Customer.Name := '';
// [WHEN] a mandatory field is blank
asserterror Customer.TestField(Name);
// [THEN] verify the SPECIFIC failure message and code not just "any error"
Assert.ExpectedError('Name must have a value');
Assert.ExpectedErrorCode('TestField');
end;
var
Assert: Codeunit "Library Assert";
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [asserterror, expectederror, expectederrorcode, negative-test, error-code]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Pin asserterror to a specific error with ExpectedError and ExpectedErrorCode
## Description
`asserterror` passes when the guarded statement raises any error at all. That is too permissive for a negative test: a typo, a missing setup record, or a permission failure all raise errors, so a bare `asserterror` can go green while never exercising the rule it claims to verify — false confidence that the validation works. Constrain it. `Assert.ExpectedError(text)` checks the message of the error that was actually raised, and `Assert.ExpectedErrorCode(code)` checks its error code. Together they assert that the specific failure occurred, turning "something went wrong" into "the right thing went wrong for the right reason".
## Best Practice
Follow every `asserterror` with a verification of the error it expects: assert the action raises, then `Assert.ExpectedError` with the message — or a stable substring of it — and, where the code is known, `Assert.ExpectedErrorCode` (for example `'TestField'` for a mandatory-field check). Prefer matching on a code or an invariant fragment over the full localized sentence so the test survives caption changes without going blind to the wrong error.
See sample: `asserterror-needs-expectederror-and-code.good.al`.
## Anti Pattern
`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.
See sample: `asserterror-needs-expectederror-and-code.bad.al`.

View file

@ -0,0 +1,32 @@
codeunit 50407 "Test Handler Match Bad"
{
Subtype = Test;
// The path raises a Confirm AND a Message, but only the Confirm handler
// is listed. The Message has no handler -> unhandled-UI runtime abort.
// The mirror mistake listing a third handler the path never hits
// instead fails with "handler function was not executed".
[Test]
[HandlerFunctions('ConfirmHandlerYes')]
procedure PostWithConfirmAndMessage()
begin
RunPostingThatConfirmsAndMessages();
end;
local procedure RunPostingThatConfirmsAndMessages()
begin
if Confirm('Post this document?', false) then
Message('Posting completed.');
end;
[ConfirmHandler]
procedure ConfirmHandlerYes(Question: Text; var Reply: Boolean)
begin
Reply := true;
end;
[MessageHandler]
procedure PostMessageHandler(Message: Text)
begin
end;
}

View file

@ -0,0 +1,34 @@
codeunit 50406 "Test Handler Match Good"
{
Subtype = Test;
[Test]
[HandlerFunctions('ConfirmHandlerYes,PostMessageHandler')]
procedure PostWithConfirmAndMessage()
begin
// The path below raises BOTH a Confirm and a Message, and the
// attribute names exactly those two handlers no more, no less.
RunPostingThatConfirmsAndMessages();
end;
local procedure RunPostingThatConfirmsAndMessages()
begin
if Confirm('Post this document?', false) then
Message('Posting completed.');
end;
[ConfirmHandler]
procedure ConfirmHandlerYes(Question: Text; var Reply: Boolean)
begin
Reply := true;
end;
[MessageHandler]
procedure PostMessageHandler(Message: Text)
begin
LibraryVariableStorage.Enqueue(Message);
end;
var
LibraryVariableStorage: Codeunit "Library - Variable Storage";
}

View file

@ -0,0 +1,24 @@
---
bc-version: [all]
domain: testing
keywords: [handlerfunctions, handler, ui-path, not-executed, wiring]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Keep [HandlerFunctions] in sync with the UI the path actually hits
## Description
`[HandlerFunctions]` is a two-sided contract with the executed code path. Every UI call the path raises must have a handler named in the list, and every handler named in the list must be exercised by the path. Miss a handler the path hits and the platform throws an unhandled-UI error. Name a handler the path never reaches and the platform fails the test with a "handler function was not executed" error at the end of the method. Both are runtime failures. So the list must track the scenario's real UI interactions exactly — not a superset "just in case", not a subset that happens to work today.
## Best Practice
List precisely the handlers the scenario triggers, comma-separated, in any order. When a path raises both a `Confirm` and a `Message`, register both: `[HandlerFunctions('ConfirmHandlerYes,PostMessageHandler')]`. When you change the scenario so it no longer hits a dialog, remove that handler from the list. Treat "handler function was not executed" as a signal that the path diverged from what the test claims to exercise, and reconcile the two.
## Anti Pattern
Listing only one of the handlers a path needs — the other UI call goes unhandled and aborts — or padding the list with a handler the path never reaches, which fails the test for the unused handler. Either way the attribute lies about the path, and the failure points at wiring rather than behavior.
See samples: `handlerfunctions-attribute-must-match-ui-path.good.al`, `handlerfunctions-attribute-must-match-ui-path.bad.al`.

View file

@ -0,0 +1,29 @@
codeunit 50405 "Test Enqueue Handler Bad"
{
Subtype = Test;
[Test]
[HandlerFunctions('PostMessageHandler')]
procedure PostingShowsConfirmationMessage()
begin
RunPostingThatMessages();
// The verdict was delegated to the handler below a failed
// expectation there may never surface as this test's result.
end;
local procedure RunPostingThatMessages()
begin
Message('Posting completed.');
end;
[MessageHandler]
procedure PostMessageHandler(Message: Text)
begin
// Asserting inside the handler: if this is wrong the failure can be
// swallowed by the Message call, leaving the test falsely green.
Assert.AreEqual('Posting completed.', Message, 'Unexpected confirmation message.');
end;
var
Assert: Codeunit "Library Assert";
}

View file

@ -0,0 +1,36 @@
codeunit 50404 "Test Enqueue Handler Good"
{
Subtype = Test;
[Test]
[HandlerFunctions('PostMessageHandler')]
procedure PostingShowsConfirmationMessage()
var
ActualMessage: Text;
begin
// [WHEN] the code under test posts and raises a Message
RunPostingThatMessages();
// [THEN] the body not the handler owns the verdict
ActualMessage := LibraryVariableStorage.DequeueText();
Assert.AreEqual('Posting completed.', ActualMessage, 'Unexpected confirmation message.');
LibraryVariableStorage.AssertEmpty();
end;
local procedure RunPostingThatMessages()
begin
// Stands in for the production routine that ends with a Message.
Message('Posting completed.');
end;
[MessageHandler]
procedure PostMessageHandler(Message: Text)
begin
// Capture only never assert here.
LibraryVariableStorage.Enqueue(Message);
end;
var
Assert: Codeunit "Library Assert";
LibraryVariableStorage: Codeunit "Library - Variable Storage";
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [handler, enqueue, variable-storage, assert, verdict]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Enqueue from handlers; assert in the test body
## Description
A UI handler function runs in its own invocation context, separate from the `[Test]` method's verdict scope. An assertion that fails inside a handler does not reliably surface as the test's failure: the error can be swallowed by the calling UI operation or reported in a way that masks which test failed, so a broken expectation can silently pass. The reliable pattern is to make handlers capture, not judge — push the values they observe into `LibraryVariableStorage.Enqueue` — and let the test body dequeue those values and assert on them, where the verdict belongs. Finishing with `LibraryVariableStorage.AssertEmpty` confirms every expected interaction actually fired and nothing was left unconsumed.
## Best Practice
In the handler, `Enqueue` the message text, the page values, or the confirm question. In the test body, after acting, `Dequeue` each value and verify it with `Assert`; then call `LibraryVariableStorage.AssertEmpty` to prove the handler ran exactly as often as expected. This keeps the pass/fail decision in the method the runner scores and turns a missed or extra UI call into a real failure.
See sample: `handlers-enqueue-never-assert.good.al`.
## Anti Pattern
Calling `Assert.AreEqual` (or `Error`) directly inside a `[MessageHandler]` or `[ConfirmHandler]`. If the expectation is wrong, the failure may never reach the test verdict, so the suite reports green while the behavior is broken — the most dangerous kind of test, one that cannot fail.
See sample: `handlers-enqueue-never-assert.bad.al`.

View file

@ -0,0 +1,32 @@
codeunit 50403 "Test Table Relation Bad"
{
Subtype = Test;
[Test]
procedure SalesLineAcceptsExistingItem()
var
Customer: Record Customer;
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
LibrarySales.CreateCustomer(Customer);
LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
// No item was created. Validating "No." against a non-existent item
// raises a TableRelation error here, aborting the test at runtime
// before any assertion runs.
SalesLine.Init();
SalesLine."Document Type" := SalesHeader."Document Type";
SalesLine."Document No." := SalesHeader."No.";
SalesLine."Line No." := 10000;
SalesLine.Type := SalesLine.Type::Item;
SalesLine.Validate("No.", 'GHOST');
SalesLine.Insert(true);
Assert.AreEqual('GHOST', SalesLine."No.", 'Unreachable: validation already failed.');
end;
var
Assert: Codeunit "Library Assert";
LibrarySales: Codeunit "Library - Sales";
}

View file

@ -0,0 +1,29 @@
codeunit 50402 "Test Table Relation Good"
{
Subtype = Test;
[Test]
procedure SalesLineAcceptsExistingItem()
var
Customer: Record Customer;
Item: Record Item;
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// [GIVEN] the parents exist first: customer, then item
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
// [WHEN] a dependent sales line references the existing item
LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", 1);
// [THEN] the TableRelation on "No." resolves and the line persists
Assert.AreEqual(Item."No.", SalesLine."No.", 'Sales line should carry the created item.');
end;
var
Assert: Codeunit "Library Assert";
LibrarySales: Codeunit "Library - Sales";
LibraryInventory: Codeunit "Library - Inventory";
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [tablerelation, prerequisite, validate, foreign-key, test-data]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Create related records before inserting test data that points to them
## Description
A field with a `TableRelation` is checked when you `Validate` it or call `Insert(true)`: the platform confirms the referenced parent record exists. Test data assembled bottom-up — a sales line before its item, a ledger entry before its account — fails this check with a relation error, again a runtime abort rather than an assertion. Order matters: every record a foreign key points to must already exist when the dependent record is validated or inserted. The fix is to build fixtures top-down, parent before child, so each `TableRelation` resolves.
## Best Practice
Create prerequisite records first, then reference their primary keys from dependent records. Use the test Library codeunits, which create valid parents that satisfy mandatory fields and number series: `LibraryInventory.CreateItem` before a sales line that points at it, `LibrarySales.CreateCustomer` before a sales header. `Validate` the foreign-key field so the relation — and any field-validation logic — runs exactly as it would in production.
See sample: `tablerelation-requires-prerequisite-records.good.al`.
## Anti Pattern
Assigning an invented foreign key — `SalesLine."No." := 'GHOST'` — and calling `Insert(true)` (or `Validate`) without creating the parent. The `TableRelation` check rejects the row, the test aborts before its assertions, and the failure reads as a data error instead of the missing-setup bug it is.
See sample: `tablerelation-requires-prerequisite-records.bad.al`.

View file

@ -0,0 +1,26 @@
codeunit 50401 "Test UI Handlers Bad"
{
Subtype = Test;
// No [HandlerFunctions] and no ConfirmHandler: the Confirm below has
// nothing to intercept it, so this test fails at runtime with an
// "unhandled UI" error before any assertion is evaluated.
[Test]
procedure DeleteDocumentConfirmsAndProceeds()
var
Deleted: Boolean;
begin
Deleted := TryDeleteWithConfirm();
Assert.IsTrue(Deleted, 'Routine should proceed after confirmation.');
end;
local procedure TryDeleteWithConfirm(): Boolean
begin
if not Confirm('Delete this document?', false) then
exit(false);
exit(true);
end;
var
Assert: Codeunit "Library Assert";
}

View file

@ -0,0 +1,34 @@
codeunit 50400 "Test UI Handlers Good"
{
Subtype = Test;
[Test]
[HandlerFunctions('ConfirmHandlerYes')]
procedure DeleteDocumentConfirmsAndProceeds()
var
Deleted: Boolean;
begin
// [WHEN] the code under test guards the delete with a Confirm
Deleted := TryDeleteWithConfirm();
// [THEN] the handler answered yes, so the routine proceeded
Assert.IsTrue(Deleted, 'Routine should proceed after confirmation.');
end;
local procedure TryDeleteWithConfirm(): Boolean
begin
// Stands in for the production routine that confirms before deleting.
if not Confirm('Delete this document?', false) then
exit(false);
exit(true);
end;
[ConfirmHandler]
procedure ConfirmHandlerYes(Question: Text; var Reply: Boolean)
begin
Reply := true;
end;
var
Assert: Codeunit "Library Assert";
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [handler, ui, confirm, unhandled-ui, headless]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Intercept every UI call with a registered test handler
## Description
Any platform UI interaction the code under test triggers — `Confirm`, `Message`, error dialogs, `Page.Run`/`RunModal`, `Report.Run`/`RunModal`, request pages, `StrMenu`, `Hyperlink`, `Notification.Send` — must be intercepted by a handler function carrying the matching handler attribute and registered on the test method via `[HandlerFunctions]`. A test runs headless: there is no interactive user to dismiss a dialog. If the executed path raises a UI call with no registered handler, the platform throws an "unhandled UI" error and aborts the method. This is a runtime failure, not an assertion failure — the test never reaches its verification, so a reviewer sees an infrastructure error instead of a verdict on the behavior under test.
## Best Practice
For each UI call the scenario can hit, add a handler procedure with the correct attribute (`[ConfirmHandler]`, `[MessageHandler]`, `[StrMenuHandler]`, `[ModalPageHandler]`, `[ReportHandler]`/`[RequestPageHandler]`, `[SendNotificationHandler]`, `[HyperlinkHandler]`) and name it in the test's `[HandlerFunctions(...)]` list. The handler decides the response — `[ConfirmHandler]` sets `Reply`, a page handler fills and runs the test page — so the path completes deterministically without human input.
See sample: `ui-calls-require-test-handlers.good.al`.
## Anti Pattern
Writing a `[Test]` method that drives code which calls `Confirm` (or any UI) without declaring a handler. It may pass when run interactively in the client but fails in CI with an unhandled-UI runtime error, looking like a flaky pipeline rather than the missing-handler wiring it actually is.
See sample: `ui-calls-require-test-handlers.bad.al`.

View file

@ -0,0 +1,25 @@
codeunit 50411 "Test Library Fixtures Bad"
{
Subtype = Test;
[Test]
procedure OrderUsesHandRolledFixtures()
var
Customer: Record Customer;
SalesHeader: Record "Sales Header";
begin
// Hand-rolled customer: a chosen "No." with no number-series entry and
// none of the mandatory fields a real customer carries. Bypasses the
// setup production code assumes and breaks when the schema adds a
// required field this test does not set.
Customer.Init();
Customer."No." := 'X';
Customer.Insert();
SalesHeader.Init();
SalesHeader."Document Type" := SalesHeader."Document Type"::Order;
SalesHeader."No." := 'SO-X';
SalesHeader.Validate("Sell-to Customer No.", Customer."No.");
SalesHeader.Insert(true);
end;
}

View file

@ -0,0 +1,28 @@
codeunit 50410 "Test Library Fixtures Good"
{
Subtype = Test;
[Test]
procedure OrderUsesLibraryCreatedFixtures()
var
Customer: Record Customer;
Item: Record Item;
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// Library codeunits create valid parents: number series, mandatory
// fields and table relations are all handled for you.
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", LibraryRandom.RandInt(10));
Assert.AreEqual(Customer."No.", SalesHeader."Sell-to Customer No.", 'Header should use the created customer.');
end;
var
Assert: Codeunit "Library Assert";
LibrarySales: Codeunit "Library - Sales";
LibraryInventory: Codeunit "Library - Inventory";
LibraryRandom: Codeunit "Library - Random";
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [library-codeunits, fixtures, test-data, number-series, prerequisite]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Build fixtures with the test Library codeunits, not hand-rolled Init/Insert
## Description
BC ships a layer of test Library codeunits — `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom` and many more — whose job is to create valid records. `CreateCustomer` assigns a number from the customer number series, fills the mandatory fields, and satisfies the table relations the platform enforces; `CreateItem` does the same for items. Hand-rolling `Customer.Init`/`Customer.Insert` with invented values skips the number series and any field a future app version adds as mandatory, so the fixture is invalid the moment it is created and rots silently as the schema evolves. Prefer the Library codeunits for prerequisite data: they encode the setup the platform requires and are maintained alongside the base app.
## Best Practice
Reach for the matching Library codeunit before writing manual record setup: `LibrarySales.CreateCustomer`, `LibrarySales.CreateSalesHeader`/`CreateSalesLine`, `LibraryInventory.CreateItem`, `LibraryERM.CreateGLAccount`, and `LibraryRandom.RandInt`/`RandDec` for values. Pass the records they return into the code under test. The fixtures stay valid across upgrades because the library — not your test — owns the knowledge of what a well-formed record requires.
See sample: `use-library-codeunits-for-test-fixtures.good.al`.
## Anti Pattern
`Customer.Init(); Customer."No." := 'X'; Customer.Insert();` — a record with a hand-picked primary key, no number-series entry, and none of the mandatory fields a real customer needs. It compiles and may even insert, but it bypasses setup the production code assumes, and it breaks the first time the schema gains a required field the test does not know about.
See sample: `use-library-codeunits-for-test-fixtures.bad.al`.