mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 14:46:55 +01:00
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:
parent
4287233f80
commit
f63943dcfd
59 changed files with 1612 additions and 2 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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).
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
26
microsoft/knowledge/testing/test-feature-scenario-tags.md
Normal file
26
microsoft/knowledge/testing/test-feature-scenario-tags.md
Normal 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).
|
||||
32
microsoft/knowledge/testing/test-one-when-per-test.bad.al
Normal file
32
microsoft/knowledge/testing/test-one-when-per-test.bad.al
Normal 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;
|
||||
56
microsoft/knowledge/testing/test-one-when-per-test.good.al
Normal file
56
microsoft/knowledge/testing/test-one-when-per-test.good.al
Normal 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;
|
||||
34
microsoft/knowledge/testing/test-one-when-per-test.md
Normal file
34
microsoft/knowledge/testing/test-one-when-per-test.md
Normal 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.
|
||||
27
microsoft/knowledge/testing/ui-test-codeunit-naming.bad.al
Normal file
27
microsoft/knowledge/testing/ui-test-codeunit-naming.bad.al
Normal 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;
|
||||
}
|
||||
42
microsoft/knowledge/testing/ui-test-codeunit-naming.good.al
Normal file
42
microsoft/knowledge/testing/ui-test-codeunit-naming.good.al
Normal 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;
|
||||
}
|
||||
26
microsoft/knowledge/testing/ui-test-codeunit-naming.md
Normal file
26
microsoft/knowledge/testing/ui-test-codeunit-naming.md
Normal 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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue