Add testing knowledge: UI handlers, table relations, asserterror, fixtures (P1+P2) (#62)
Some checks failed
Validate knowledge index / validate-index (push) Has been cancelled
Validate frontmatter and structure / validate (push) Has been cancelled

* Add testing knowledge: UI handlers, table relations, asserterror, fixtures (P1+P2)

Six BC-specific testing-domain knowledge articles in community/knowledge/testing/, each with .good.al/.bad.al samples:

- ui-calls-require-test-handlers
- tablerelation-requires-prerequisite-records
- handlers-enqueue-never-assert
- handlerfunctions-attribute-must-match-ui-path
- asserterror-needs-expectederror-and-code
- use-library-codeunits-for-test-fixtures

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

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

* Address review: merge handler articles, adopt enqueue-driven pattern

Respond to @nikolakukrika's review on #62:

- Merge ui-calls-require-test-handlers, handlerfunctions-attribute-must-match-ui-path
  and handlers-enqueue-never-assert into a single ui-handlers-in-tests article.
- Adopt the enqueue-from-test / dequeue-and-assert-in-handler pattern using
  Assert.ExpectedConfirm/ExpectedMessage (substring match), with Initialize()
  clearing LibraryVariableStorage and AssertEmpty() proving exact call counts.
- asserterror sample now uses Assert.ExpectedTestFieldError + FieldCaption instead
  of hardcoded message/code; article text points to the library helpers.
- Drop the tablerelation article and fold its test-relevant ordering point
  (relations checked on Validate/Insert(true); build parents first) into
  use-library-codeunits-for-test-fixtures.

Article count 198 -> 195.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Jesper Schulz-Wedde <jesper.schulzwedde@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jesper Schulz-Wedde 2026-07-09 10:24:51 +02:00 committed by GitHub
parent 47babc5ef0
commit 3aa3581f95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 277 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,26 @@
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 through a reusable Library helper
// instead of hardcoding the localized message and the 'TestField' code.
// ExpectedTestFieldError centralizes that knowledge, so the test keeps
// working when the caption or code changes; FieldCaption avoids pinning
// the field name as a literal.
Assert.ExpectedTestFieldError(Customer.FieldCaption(Name), '');
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, and prefer the reusable `Library Assert` helpers over hardcoded literals. For a mandatory-field check, `Assert.ExpectedTestFieldError(FieldCaption, ExpectedValue)` encapsulates both the message and the `TestField` code, so the test survives caption or code changes and does not repeat that knowledge in every method. For other errors, pair `Assert.ExpectedError` with a stable substring — ideally a shared `Label`, not an inline sentence — and, where known, `Assert.ExpectedErrorCode`. When a needed check is missing from the shared library, extend `Library Assert` (or your own assert library) with a helper rather than hardcoding message text and codes across tests; matching on a code or an invariant fragment keeps the test from going blind to the wrong error when a caption is localized.
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,43 @@
codeunit 50401 "Test UI Handlers Bad"
{
Subtype = Test;
// Several wiring mistakes, each of which fails at runtime rather than as a
// clean assertion the reviewer can read:
// * A UI call with no listed handler -> "unhandled UI" abort (the Message
// below has no handler).
// * The mirror mistake, listing a handler the path never hits, instead
// fails with "handler function was not executed".
// * A handler that hardcodes its answer and asserts inline, with no
// enqueue/dequeue -> nothing proves the RIGHT dialog fired the RIGHT
// number of times, and a failed inline assert can be swallowed by the
// calling UI operation.
[Test]
[HandlerFunctions('ConfirmHandler')]
procedure PostDocumentConfirmsAndMessages()
begin
// No Initialize(): a value leaked by an earlier test corrupts this one.
RunPostingThatConfirmsAndMessages();
// No AssertEmpty(): a missing or extra dialog goes unnoticed.
end;
local procedure RunPostingThatConfirmsAndMessages()
begin
// Raises a Confirm AND a Message, but only ConfirmHandler is listed:
// the Message has nothing to intercept it -> unhandled-UI runtime abort.
if Confirm('Post this document?', false) then
Message('Posting completed.');
end;
[ConfirmHandler]
procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean)
begin
// Hardcoded expectation and hardcoded reply. If the wrong dialog fires,
// this inline assert may never surface as the test's verdict.
Assert.AreEqual('Post this document?', Question, 'Wrong confirm.');
Reply := true;
end;
var
Assert: Codeunit "Library Assert";
}

View file

@ -0,0 +1,57 @@
codeunit 50400 "Test UI Handlers Good"
{
Subtype = Test;
[Test]
[HandlerFunctions('ConfirmHandler,PostMessageHandler')]
procedure PostDocumentConfirmsAndMessages()
begin
Initialize();
// [GIVEN] the test enqueues, in interaction order, what each handler
// will see and how it should answer: the Confirm's expected
// question plus the reply to return, then the expected Message.
LibraryVariableStorage.Enqueue('Post this document?'); // expected question (substring)
LibraryVariableStorage.Enqueue(true); // reply ConfirmHandler returns
LibraryVariableStorage.Enqueue('Posting completed.'); // expected message (substring)
// [WHEN] the code under test raises the Confirm and then the Message
RunPostingThatConfirmsAndMessages();
// [THEN] every enqueued expectation was consumed exactly once
LibraryVariableStorage.AssertEmpty();
end;
local procedure Initialize()
begin
// Clear leftover values so a value leaked by an earlier test cannot
// cascade into this one.
LibraryVariableStorage.Clear();
end;
local procedure RunPostingThatConfirmsAndMessages()
begin
// Stands in for the production routine that confirms, then messages.
if Confirm('Post this document?', false) then
Message('Posting completed.');
end;
[ConfirmHandler]
procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean)
begin
// Verify the RIGHT dialog fired (substring match), then return the
// reply the test enqueued for it.
Assert.ExpectedConfirm(LibraryVariableStorage.DequeueText(), Question);
Reply := LibraryVariableStorage.DequeueBoolean();
end;
[MessageHandler]
procedure PostMessageHandler(Message: Text[1024])
begin
Assert.ExpectedMessage(LibraryVariableStorage.DequeueText(), Message);
end;
var
Assert: Codeunit "Library Assert";
LibraryVariableStorage: Codeunit "Library - Variable Storage";
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: testing
keywords: [handler, handlerfunctions, confirm, message, strmenu, variable-storage, enqueue, unhandled-ui]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Wire and verify UI handlers with enqueue-driven expectations
## Description
A test runs headless: there is no interactive user to answer a dialog. Every UI call the executed path raises — `Confirm`, `Message`, error dialogs, `Page.Run`/`RunModal`, `Report.Run`/`RunModal`, request pages, `StrMenu`, `Notification.Send` — must be intercepted by a handler carrying the matching attribute (`[ConfirmHandler]`, `[MessageHandler]`, `[StrMenuHandler]`, `[ModalPageHandler]`, …) and named in the method's `[HandlerFunctions(...)]`. The list is a two-sided contract: raise a UI call with no listed handler and the platform aborts with an *unhandled UI* error; list a handler the path never hits and it fails with *"handler function was not executed"*. Both are runtime failures — the test never reaches its verdict, so a reviewer sees an infrastructure error instead of a result on the behavior under test.
Getting the handler *present* is only half the job; the handler must also verify the *right* dialog fired the *right* number of times. Do that by driving handlers from the test, not by hardcoding answers inside them.
## Best Practice
Make the test own the expectations and the handlers consume them. Before acting, the test `Enqueue`s — in interaction order — the expected text (a stable substring) and any reply each handler must return. The handler `Dequeue`s the expected text, verifies it with the purpose-built asserts (`Assert.ExpectedMessage`, `Assert.ExpectedConfirm`, `Assert.ExpectedStrMenu` — which match on a fragment, not the full localized caption), then `Dequeue`s and returns its reply. Finish the test body with `LibraryVariableStorage.AssertEmpty` to prove every enqueued interaction fired exactly once, and start each test with an `Initialize` that calls `LibraryVariableStorage.Clear` so a value leaked by an earlier test cannot cascade. List in `[HandlerFunctions]` precisely the handlers the scenario triggers — no superset "just in case", no subset that happens to work today.
See sample: `ui-handlers-in-tests.good.al`.
## Anti Pattern
Omitting a handler for a UI call the path raises (unhandled-UI abort), padding the list with a handler the path never reaches ("handler function was not executed"), or writing handlers that hardcode their answer and assert inline with no enqueue/dequeue. The last is the subtle one: nothing proves the correct dialog fired the expected number of times, and an inline assertion that fails inside a handler can be swallowed by the calling UI operation, leaving the suite green while the behavior is broken. Skipping `Initialize`/`AssertEmpty` hides both a leaked queue and a missing or extra dialog.
See sample: `ui-handlers-in-tests.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. The library codeunits also encode fixture *ordering*: because a `TableRelation` field is checked on `Validate` and `Insert(true)`, every parent a foreign key points to must already exist when the dependent record is built. Assemble fixtures top-down — customer and item before the sales line that references them — or the relation check aborts the test at runtime with a data error rather than an assertion. 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. Create the prerequisite parents first and reference their primary keys from dependent records, and `Validate` the foreign-key field so the `TableRelation` — and any field-validation logic — runs exactly as it would in production. 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`.