mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 06:36:55 +01:00
3 AL/BC patterns from CURABIS's internal automated-testing training material (#158)
* Add 3 more AL/BC patterns from CURABIS Academy testing course material Third batch from CURABIS ApS: item-ledger-entry document-no lookup after Ship-and-Invoice posting, TestPage.Visible()/.Enabled() as the mechanism for verifying field UI state, and LibraryUtility.GenerateGUID() for collision-free test fixture values. * Address Jesper Schulz-Wedde's review on PR #158 - use-generateguid-for-unique-test-fixture-values.md: GenerateGUID() is a Code[10] number-series value, not a real GUID; truncating it with CopyStr for a shorter field cuts off the changing digits. Point to GenerateRandomCode/GenerateRandomCodeWithLength/GenerateRandomXMLText instead, which verify uniqueness against the actual table. - Split use-testpage-visible-enabled-to-verify-field-ui-state.md: drop its editability claim (the sample opens with OpenView() and asserts Enabled(), which verifies enabled state, not editability — Editable() and Enabled() are distinct TestField methods). New companion article use-testpage-editable-to-verify-field-editability.md covers Editable() with OpenEdit() specifically. - Wire GenerateGUID/CopyStr and TestPage Visible/Enabled/Editable cues into al-testing-review.md, and the Item Ledger Entry/Last Shipping No. posting cue into al-data-modeling-review.md. The Item Ledger Entry article itself was independently verified against current BCApps source and needs no changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Address second round of Jesper Schulz-Wedde's review on PR #158 - use-generateguid-for-unique-test-fixture-values.md/.good.al: documented each LibraryUtility helper's actual behavior, verified against LibraryUtility.Codeunit.al. GenerateRandomCode opens the target table as a temporary RecordRef, so despite taking TableNo it never checks real data. GenerateRandomXMLText performs no table lookup at all. Only GenerateRandomCodeWithLength/GenerateRandomCode20 (capped at Code[10]/ Code[20]) genuinely verify against the real table. Fixture switched to GenerateRandomCodeWithLength where the comment claims verified uniqueness. - al-testing-review.md: rewired the cue to catch the actual anti-pattern (hardcoded literals, hand-built uniqueness, short-field GUID truncation) instead of only matching the compliant GenerateGUID()+CopyStr shape; broadened tokens to include TestPage, Library - Utility, and .Visible()/.Enabled()/.Editable(). - al-data-modeling-review.md: restricted the Item Ledger Entry Last-Shipping-No. cue to sales combined posting; purchase combined posting is Receive+Invoice and uses different fields entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Fix remaining correctness issues from Jesper's 2026-09-15 re-review - use-generateguid-for-unique-test-fixture-values.md: narrowed the collision rule to primary-key/unique-lookup fields - an ordinary descriptive field carries no uniqueness constraint, so a hardcoded or deterministic value there isn't the anti-pattern (the article and its al-testing-review.md worklist cue both said "primary-key or descriptive field"). Also corrected GenerateRandomCode: it opens its target table as a temporary RecordRef that starts and stays empty, so its repeat/until loop always exits after one iteration and never retries even within a single test run - the "non-colliding within a test run" claim was false. It's the rightmost N characters of GenerateGUID()'s sequential series, so a short field's value cycles (Code[1] repeats every 10 calls, Code[2] every 100). Verified against LibraryUtility.Codeunit.al in the BCApps reference clone. - item-ledger-entry-document-no-follows-last-shipping-no: both fixtures called FindSet() without consuming its optional Boolean, which raises a runtime error on an empty result set - the opposite of the article's own claimed "silently matches zero rows, no error" behavior. Wrapped in `if ... then;` per the existing guard-database-reads.good.al idiom. - al-data-modeling-review.md: widened both not-applicable scope clauses (intro and outcome) to include dimension wiring, posting- routine structure, and Item Ledger Entry document-number lookups - the leaf declared itself not-applicable outside setup/master/key/ numbering/block/audit surfaces despite having a targeted cue for this PR's own new article. - Converted this PR's 8 plain-backtick "See sample: `x.good.al`." references (across all 4 new articles) to the READ-convention markdown-link form required by Knowledge-Retrieval.ps1. Rebased onto upstream/main (one conflict in al-data-modeling-review.md intro wording, merged). * Stop routing GenerateRandomCode20 as compliant for shorter fields al-testing-review.md's cue presented GenerateRandomCodeWithLength and GenerateRandomCode20 as interchangeable options for "a shorter field needing real verified uniqueness." They aren't: verified against LibraryUtility.Codeunit.al, GenerateRandomCode20 truncates GenerateGUID()'s sequential value down to the target field's length by keeping the leftmost (slowest-changing) characters via PadStr, so its retry loop against a field shorter than 20 can churn through the same truncated prefix for a long time. GenerateRandomCodeWithLength has no such problem (it generates exactly the requested length of random text). The knowledge article itself already scoped GenerateRandomCode20 to Code[20] correctly - only the skill cue needed narrowing to match. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0a8c9a8556
commit
186691f815
14 changed files with 255 additions and 3 deletions
|
|
@ -0,0 +1,12 @@
|
|||
codeunit 50130 "Sample Item Ledger Lookup"
|
||||
{
|
||||
procedure GetPostedItemLedgerEntries(var SalesHeader: Record "Sales Header"; var ItemLedgerEntry: Record "Item Ledger Entry")
|
||||
var
|
||||
LibrarySales: Codeunit "Library - Sales";
|
||||
InvoiceNo: Code[20];
|
||||
begin
|
||||
InvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, true, true);
|
||||
ItemLedgerEntry.SetRange("Document No.", InvoiceNo);
|
||||
if ItemLedgerEntry.FindSet() then;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
codeunit 50130 "Sample Item Ledger Lookup"
|
||||
{
|
||||
procedure GetPostedItemLedgerEntries(var SalesHeader: Record "Sales Header"; var ItemLedgerEntry: Record "Item Ledger Entry")
|
||||
var
|
||||
LibrarySales: Codeunit "Library - Sales";
|
||||
ShippingNo: Code[20];
|
||||
begin
|
||||
LibrarySales.PostSalesDocument(SalesHeader, true, true);
|
||||
ShippingNo := SalesHeader."Last Shipping No.";
|
||||
ItemLedgerEntry.SetRange("Document No.", ShippingNo);
|
||||
if ItemLedgerEntry.FindSet() then;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [item-ledger-entry, document-no, last-shipping-no, ship-and-invoice, posting, sales-order]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# After Ship-and-Invoice posting, Item Ledger Entry carries the shipment document number
|
||||
|
||||
## Description
|
||||
|
||||
Posting a sales order with both Ship and Invoice in one call creates the Item Ledger Entry during the shipment leg of that combined post, so the entry's `Document No.` is stamped with the value assigned to the shipment — `Sales Header."Last Shipping No."` — not the posted sales invoice number the posting call returns. Code that filters Item Ledger Entry by the invoice number instead finds nothing: `SetRange`/`FindSet` simply return zero rows, with no error to signal the mistake.
|
||||
|
||||
## Best Practice
|
||||
|
||||
After posting a sales order with Ship and Invoice together, read `SalesHeader."Last Shipping No."` (populated during the post) and filter Item Ledger Entry by that value, not by the invoice number the posting routine returns.
|
||||
|
||||
See sample: [`item-ledger-entry-document-no-follows-last-shipping-no.good.al`](item-ledger-entry-document-no-follows-last-shipping-no.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Filtering Item Ledger Entry by the posted sales invoice number after a combined Ship-and-Invoice post. The filter compiles and runs without error but matches zero rows, because the entry belongs to the shipment leg of the posting, not the invoice leg.
|
||||
|
||||
See sample: [`item-ledger-entry-document-no-follows-last-shipping-no.bad.al`](item-ledger-entry-document-no-follows-last-shipping-no.bad.al).
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
codeunit 50132 "Sample Customer Type Library"
|
||||
{
|
||||
procedure CreateCustomerType(var CustomerType: Record "Customer Type")
|
||||
begin
|
||||
CustomerType.Init();
|
||||
CustomerType.Code := 'TEST001';
|
||||
CustomerType.Description := 'Test Customer Type';
|
||||
CustomerType.Insert(true);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
codeunit 50132 "Sample Customer Type Library"
|
||||
{
|
||||
var
|
||||
LibraryUtility: Codeunit "Library - Utility";
|
||||
|
||||
procedure CreateCustomerType(var CustomerType: Record "Customer Type")
|
||||
begin
|
||||
CustomerType.Init();
|
||||
// Code is shorter than GenerateGUID()'s 10 characters, and this field's
|
||||
// uniqueness matters, so use GenerateRandomCodeWithLength: it opens the
|
||||
// real (non-temporary) table and loops until the value doesn't collide.
|
||||
// GenerateRandomCode would not do this — it opens the table as temporary,
|
||||
// so its own emptiness check never inspects real rows.
|
||||
CustomerType.Code :=
|
||||
LibraryUtility.GenerateRandomCodeWithLength(CustomerType.FieldNo(Code), Database::"Customer Type", MaxStrLen(CustomerType.Code));
|
||||
// Description is long enough to hold the full GenerateGUID() value
|
||||
// untruncated, and only needs to be incidental, not verified-unique.
|
||||
CustomerType.Description := CopyStr(LibraryUtility.GenerateGUID(), 1, MaxStrLen(CustomerType.Description));
|
||||
CustomerType.Insert(true);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: testing
|
||||
keywords: [generateguid, library-utility, test-fixtures, uniqueness, generaterandomcode, maxstrlen]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Generate unique test fixture values with LibraryUtility helpers, not hardcoded literals
|
||||
|
||||
## Description
|
||||
|
||||
A fixture helper that assigns a hardcoded literal to a primary-key field, or to any field the test relies on as a unique lookup identifier, collides the moment two tests, or two runs of the same test, create that fixture without cleanup — and a literal longer than the field allows raises a truncation or insert error. An ordinary descriptive field carries no such constraint: two rows with the same description do not collide on insert, and a deterministic descriptive value is often exactly what an exact-match assertion needs, so none of this applies to it. `LibraryUtility.GenerateGUID()` is not a real GUID — it is a `Code[10]` number-series value (`GU00000000`–`GU99999999`) — and it returns the full 10 characters unshortened. Truncating it yourself with `CopyStr(..., 1, MaxStrLen(ShorterField))` for a field under 10 characters is unsafe: the changing digits sit at the right end and are exactly what gets cut off, so consecutive calls into a short field can produce the same truncated value. `GenerateGUID()` is only safe as-is for a field that holds the full 10 characters.
|
||||
|
||||
## Best Practice
|
||||
|
||||
For a field that holds the full 10 characters, assign `LibraryUtility.GenerateGUID()` directly. For a shorter field, do not truncate a GUID yourself — but also do not assume every `LibraryUtility` helper verifies uniqueness against the real table, because they don't all behave the same way:
|
||||
|
||||
- `GenerateRandomCode(FieldNo, TableNo)` opens the target table as a **temporary** `RecordRef`: the buffer starts and stays empty, so its `repeat...until RecRef.IsEmpty()` loop always exits after one iteration — despite taking `TableNo`, it never checks the real table, and it never retries even within its own call. Its value is the rightmost `FieldRef.Length` characters of `GenerateGUID()`'s sequential `GU00000000`–`GU99999999` series, so for a short field that window of digits cycles: a 1-character field repeats every 10 calls, a 2-character field every 100, and so on. It is a finite short-field namespace with a low collision *chance* within one test run — not a guarantee at any scope, unlike the table-checking helpers below.
|
||||
- `GenerateRandomCodeWithLength(FieldNo, TableNo, CodeLength)` opens the real (non-temporary) table and loops until the generated value doesn't collide — a genuine verified-unique guarantee — but it returns `Code[10]` regardless of the requested `CodeLength`, so it's only useful for a field of 10 characters or fewer.
|
||||
- `GenerateRandomCode20(FieldNo, TableNo)` is the same real, verified-against-the-table pattern as `GenerateRandomCodeWithLength`, sized for a `Code[20]` field.
|
||||
- `GenerateRandomXMLText(Length)` performs no table lookup at all — it's a plain random-text generator, appropriate for a descriptive/incidental field where uniqueness doesn't matter, not for a value that needs to be collision-checked.
|
||||
|
||||
Pick `GenerateRandomCodeWithLength`/`GenerateRandomCode20` when the test genuinely needs a code verified unique against the table; use `GenerateRandomCode`/`GenerateGUID`/`GenerateRandomXMLText` for incidental values where a low collision *chance* is enough.
|
||||
|
||||
See sample: [`use-generateguid-for-unique-test-fixture-values.good.al`](use-generateguid-for-unique-test-fixture-values.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Hardcoding a primary-key or unique-lookup fixture value such as `'TEST001'`, which collides across parallel or repeated test runs — a fixed descriptive value is not this anti-pattern, since the field carries no uniqueness constraint. Equally an anti-pattern: truncating `GenerateGUID()`'s result with `CopyStr(..., 1, MaxStrLen(Field))` for a field shorter than 10 characters — the truncation removes the part of the value that actually varies.
|
||||
|
||||
See sample: [`use-generateguid-for-unique-test-fixture-values.bad.al`](use-generateguid-for-unique-test-fixture-values.bad.al).
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
codeunit 50134 "Sample Customer Type Edit Test"
|
||||
{
|
||||
Subtype = Test;
|
||||
|
||||
[Test]
|
||||
procedure CustomerTypeFieldNotEditable_WhenLocked()
|
||||
var
|
||||
Assert: Codeunit Assert;
|
||||
CustomerType: Record "Customer Type";
|
||||
CustomerTypeCard: TestPage "Customer Type Card";
|
||||
begin
|
||||
// [GIVEN] a customer type record whose Locked flag is set
|
||||
CustomerType.Init();
|
||||
CustomerType.Locked := true;
|
||||
CustomerType.Insert(true);
|
||||
|
||||
// [WHEN] the page is opened in VIEW mode — editability logic that only
|
||||
// applies in edit mode is not exercised the same way
|
||||
CustomerTypeCard.OpenView();
|
||||
CustomerTypeCard.GoToRecord(CustomerType);
|
||||
|
||||
// [THEN] wrong function: Enabled() does not verify editability
|
||||
Assert.IsFalse(CustomerTypeCard.Description.Enabled(), 'Description should not be editable while Locked is set.');
|
||||
|
||||
CustomerTypeCard.Close();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
codeunit 50133 "Sample Customer Type Edit Test"
|
||||
{
|
||||
Subtype = Test;
|
||||
|
||||
[Test]
|
||||
procedure CustomerTypeFieldNotEditable_WhenLocked()
|
||||
var
|
||||
Assert: Codeunit Assert;
|
||||
CustomerType: Record "Customer Type";
|
||||
CustomerTypeCard: TestPage "Customer Type Card";
|
||||
begin
|
||||
// [GIVEN] a customer type record whose Locked flag is set
|
||||
CustomerType.Init();
|
||||
CustomerType.Locked := true;
|
||||
CustomerType.Insert(true);
|
||||
|
||||
// [WHEN] the page is opened in edit mode on that record
|
||||
CustomerTypeCard.OpenEdit();
|
||||
CustomerTypeCard.GoToRecord(CustomerType);
|
||||
|
||||
// [THEN] the field's actual editable state reflects the lock
|
||||
Assert.IsFalse(CustomerTypeCard.Description.Editable(), 'Description should not be editable while Locked is set.');
|
||||
|
||||
CustomerTypeCard.Close();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: testing
|
||||
keywords: [testpage, editable, openedit, ui-state, field-verification]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Verify field editability with TestPage.Editable(), opened in edit mode
|
||||
|
||||
## Description
|
||||
|
||||
Whether a field can actually be changed is a distinct state from whether it is shown or enabled — `Editable()` and `Enabled()` are separate `TestField` functions. Verifying editability also requires opening the `TestPage` with `OpenEdit()`, not `OpenView()`: `OpenView()` opens the page in view mode, so it does not exercise the field's own conditional editability logic the way an actual edit-mode session does.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Open the `TestPage` with `OpenEdit()`, navigate to the relevant record, then assert against `TestPageField.Editable()` to verify whether the field can be changed under the given precondition.
|
||||
|
||||
See sample: [`use-testpage-editable-to-verify-field-editability.good.al`](use-testpage-editable-to-verify-field-editability.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Asserting `Enabled()` (or checking nothing at all) when the actual claim is about editability, or opening the page with `OpenView()` when the field's editability depends on business logic that only applies in edit mode.
|
||||
|
||||
See sample: [`use-testpage-editable-to-verify-field-editability.bad.al`](use-testpage-editable-to-verify-field-editability.bad.al).
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
codeunit 50131 "Sample Customer Type UI Test"
|
||||
{
|
||||
Subtype = Test;
|
||||
|
||||
[Test]
|
||||
procedure CustomerTypeFieldIsEnabledOnCustomerCard()
|
||||
var
|
||||
CustomerCard: TestPage "Customer Card";
|
||||
begin
|
||||
// Confirms only that the page opens - never checks the field's actual UI state
|
||||
CustomerCard.OpenView();
|
||||
CustomerCard.Close();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
codeunit 50131 "Sample Customer Type UI Test"
|
||||
{
|
||||
Subtype = Test;
|
||||
|
||||
[Test]
|
||||
procedure CustomerTypeFieldIsEnabledOnCustomerCard()
|
||||
var
|
||||
Assert: Codeunit Assert;
|
||||
CustomerCard: TestPage "Customer Card";
|
||||
begin
|
||||
CustomerCard.OpenView();
|
||||
Assert.IsTrue(CustomerCard."Customer Type".Enabled(), 'Customer Type should be enabled on the Customer Card.');
|
||||
Assert.IsTrue(CustomerCard."Customer Type".Visible(), 'Customer Type should be visible on the Customer Card.');
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: testing
|
||||
keywords: [testpage, visible, enabled, ui-state, headless-test, field-verification]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Verify field visibility and enabled state with TestPage.Visible()/.Enabled()
|
||||
|
||||
## Description
|
||||
|
||||
A UI test codeunit does not need to inspect table or page properties indirectly to confirm a field is shown or enabled under given conditions. The `TestPage` object exposes a `Visible()` and an `Enabled()` function on each field, reflecting the page's actual rendered state, callable directly from a `[Test]` procedure. `Enabled()` and `Editable()` are distinct states — this article covers visibility/enabled state specifically; see `use-testpage-editable-to-verify-field-editability.md` for verifying whether a field can actually be changed.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Open the `TestPage`, navigate to the relevant record if needed, then assert against `TestPageField.Visible()` and `TestPageField.Enabled()` to verify the field's shown/enabled state, rather than checking an unrelated table/page property or skipping the check.
|
||||
|
||||
See sample: [`use-testpage-visible-enabled-to-verify-field-ui-state.good.al`](use-testpage-visible-enabled-to-verify-field-ui-state.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A test that opens the `TestPage` but never asserts against `Visible()`/`Enabled()` on the field in question — confirming only that the page opens, not that the field behaves as expected.
|
||||
|
||||
See sample: [`use-testpage-visible-enabled-to-verify-field-ui-state.bad.al`](use-testpage-visible-enabled-to-verify-field-ui-state.bad.al).
|
||||
Loading…
Add table
Add a link
Reference in a new issue