From 3aa3581f9563b64e6fa370cc722dbca23775f225 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 9 Jul 2026 10:24:51 +0200 Subject: [PATCH] Add testing knowledge: UI handlers, table relations, asserterror, fixtures (P1+P2) (#62) * 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 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...terror-needs-expectederror-and-code.bad.al | 18 ++++++ ...error-needs-expectederror-and-code.good.al | 26 +++++++++ ...sserterror-needs-expectederror-and-code.md | 26 +++++++++ .../testing/ui-handlers-in-tests.bad.al | 43 ++++++++++++++ .../testing/ui-handlers-in-tests.good.al | 57 +++++++++++++++++++ .../knowledge/testing/ui-handlers-in-tests.md | 28 +++++++++ ...library-codeunits-for-test-fixtures.bad.al | 25 ++++++++ ...ibrary-codeunits-for-test-fixtures.good.al | 28 +++++++++ ...use-library-codeunits-for-test-fixtures.md | 26 +++++++++ 9 files changed, 277 insertions(+) create mode 100644 microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.bad.al create mode 100644 microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.good.al create mode 100644 microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md create mode 100644 microsoft/knowledge/testing/ui-handlers-in-tests.bad.al create mode 100644 microsoft/knowledge/testing/ui-handlers-in-tests.good.al create mode 100644 microsoft/knowledge/testing/ui-handlers-in-tests.md create mode 100644 microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.bad.al create mode 100644 microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.good.al create mode 100644 microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md diff --git a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.bad.al b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.bad.al new file mode 100644 index 0000000..26b0ee4 --- /dev/null +++ b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.bad.al @@ -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; +} diff --git a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.good.al b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.good.al new file mode 100644 index 0000000..fcbcfe6 --- /dev/null +++ b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.good.al @@ -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"; +} diff --git a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md new file mode 100644 index 0000000..0dee645 --- /dev/null +++ b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md @@ -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`. diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al b/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al new file mode 100644 index 0000000..1ecf47d --- /dev/null +++ b/microsoft/knowledge/testing/ui-handlers-in-tests.bad.al @@ -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"; +} diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.good.al b/microsoft/knowledge/testing/ui-handlers-in-tests.good.al new file mode 100644 index 0000000..f955477 --- /dev/null +++ b/microsoft/knowledge/testing/ui-handlers-in-tests.good.al @@ -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"; +} diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.md b/microsoft/knowledge/testing/ui-handlers-in-tests.md new file mode 100644 index 0000000..338e9ec --- /dev/null +++ b/microsoft/knowledge/testing/ui-handlers-in-tests.md @@ -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`. diff --git a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.bad.al b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.bad.al new file mode 100644 index 0000000..cf7805a --- /dev/null +++ b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.bad.al @@ -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; +} diff --git a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.good.al b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.good.al new file mode 100644 index 0000000..66d4b32 --- /dev/null +++ b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.good.al @@ -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"; +} diff --git a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md new file mode 100644 index 0000000..270a146 --- /dev/null +++ b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md @@ -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`.