Skill v1.0.1
currentAutomated scan100/100+3 new
version: "1.0.1" name: skill-testing description: "AL test development patterns for Business Central. Use when creating test codeunits, writing Given/When/Then test procedures, using Library Assert, configuring test projects, or implementing TDD workflows."
Skill: AL Testing & Test Strategy
Purpose
Design test strategies, implement Given/When/Then test patterns, build reusable library codeunits, test Copilot features with AI Test Toolkit, and integrate testing into the conductor's TDD cycle for AL Business Central extensions.
When to Load
This skill should be loaded when:
- A test strategy or test plan needs to be designed for a feature
- Test codeunits need to be created (unit, integration, UI)
- The conductor runs a TDD cycle (RED → GREEN → REFACTOR)
- Copilot/AI-powered features need testing with AI Test Toolkit
- Test data builders or library codeunits need to be created
- Test failures need analysis or coverage gaps need to be addressed
Core Patterns
Pattern 1: Given/When/Then Test Structure
Every test follows GWT with explicit comments and a descriptive name:
codeunit 50100 "Discount Calculation Tests"{Subtype = Test;TestPermissions = Disabled;varAssert: Codeunit Assert;LibrarySales: Codeunit "Library - Sales";LibraryRandom: Codeunit "Library - Random";IsInitialized: Boolean;[Test]procedure CalculateLineDiscount_VolumeOver100_Applies15Percent()varSalesLine: Record "Sales Line";DiscountMgt: Codeunit "Contoso Discount Management";Result: Decimal;begin// [SCENARIO] Volume discount is correctly applied for quantities ≥ 100Initialize();// [GIVEN] A sales line with quantity 100 and unit price 10CreateSalesLineWithQty(SalesLine, 100, 10);// [GIVEN] Volume discount setup: 100+ units → 15%CreateVolumeDiscountSetup(100, 15);// [WHEN] Discount is calculatedResult := DiscountMgt.CalculateLineDiscount(SalesLine);// [THEN] Discount percentage is 15%Assert.AreEqual(15, Result, 'Volume discount not applied for qty >= 100');// [THEN] Line amount reflects the discountAssert.AreEqual(850, SalesLine."Line Amount",'Line amount should be 100 * 10 * (1 - 0.15) = 850');end;local procedure Initialize()beginif IsInitialized thenexit;// One-time setup: number series, general setup, etc.IsInitialized := true;end;}
Naming convention: Action_Condition_ExpectedOutcome — reads as a sentence.
Pattern 2: Library Codeunit (Reusable Test Helpers)
Encapsulate test data creation in library codeunits — one per domain:
codeunit 50200 "Library - Contoso Sales"{/// Creates a customer with standard defaults for testing.procedure CreateCustomer(var Customer: Record Customer)varLibrarySales: Codeunit "Library - Sales";beginLibrarySales.CreateCustomer(Customer); // standard BC libraryCustomer."Credit Limit (LCY)" := 100000;Customer.Modify();end;/// Creates a released sales order with one line.procedure CreateReleasedSalesOrder(var SalesHeader: Record "Sales Header";CustomerNo: Code[20];ItemNo: Code[20];Qty: Decimal)varSalesLine: Record "Sales Line";LibrarySales: Codeunit "Library - Sales";beginLibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, CustomerNo);LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, ItemNo, Qty);LibrarySales.ReleaseSalesDocument(SalesHeader);end;}
Rules:
- Always delegate to standard BC library codeunits (
Library - Sales,Library - Inventory,Library - ERM,Library - Random) for base data creation - Add extension-specific fields on top
- Keep helpers stateless — no global variables in library codeunits
- Place in
Test/src/Libraries/folder
Pattern 3: Test Data Builder (Fluent API)
For complex test scenarios with many optional parameters:
codeunit 50201 "Sales Order Builder"{varSalesHeader: Record "Sales Header";LineCount: Integer;procedure Create(): Codeunit "Sales Order Builder"beginSalesHeader.Init();SalesHeader."Document Type" := SalesHeader."Document Type"::Order;SalesHeader.Insert(true);exit(this);end;procedure WithCustomer(CustomerNo: Code[20]): Codeunit "Sales Order Builder"beginSalesHeader.Validate("Sell-to Customer No.", CustomerNo);SalesHeader.Modify(true);exit(this);end;procedure WithLine(ItemNo: Code[20]; Qty: Decimal): Codeunit "Sales Order Builder"varSalesLine: Record "Sales Line";beginLineCount += 10000;SalesLine.Init();SalesLine."Document Type" := SalesHeader."Document Type";SalesLine."Document No." := SalesHeader."No.";SalesLine."Line No." := LineCount;SalesLine.Insert(true);SalesLine.Validate(Type, SalesLine.Type::Item);SalesLine.Validate("No.", ItemNo);SalesLine.Validate(Quantity, Qty);SalesLine.Modify(true);exit(this);end;procedure Build(): Record "Sales Header"beginexit(SalesHeader);end;}// Usage in test:[Test]procedure PostOrder_TwoLines_CreatesInvoice()varSalesHeader: Record "Sales Header";Builder: Codeunit "Sales Order Builder";beginInitialize();SalesHeader := Builder.Create().WithCustomer('C001').WithLine('ITEM-A', 10).WithLine('ITEM-B', 5).Build();// ...end;
Pattern 4: Handler Functions (Dialogs, Messages, Confirms)
When the code under test raises dialogs, declare handlers at the test procedure level:
[Test][HandlerFunctions('ConfirmYesHandler,MessageHandler')]procedure PostSalesOrder_WithValidation_CreatesLedgerEntry()varSalesHeader: Record "Sales Header";LibrarySales: Codeunit "Library - Sales";begin// [SCENARIO] Posting order with custom validation creates ledger entryInitialize();// [GIVEN] A released sales orderCreateReleasedOrder(SalesHeader);// [WHEN] Order is posted (triggers confirm + message)LibrarySales.PostSalesDocument(SalesHeader, true, true);// [THEN] Custom ledger entry existsVerifyCustomLedgerEntry(SalesHeader."No.");end;[ConfirmHandler]procedure ConfirmYesHandler(Question: Text; var Reply: Boolean)beginReply := true;end;[MessageHandler]procedure MessageHandler(Msg: Text)begin// Absorb expected messagesend;[PageHandler]procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card")beginCustomerCard."Credit Limit (LCY)".SetValue(50000);CustomerCard.OK().Invoke();end;
Handler types: ConfirmHandler, MessageHandler, PageHandler, ModalPageHandler, ReportHandler, RequestPageHandler, SendNotificationHandler, RecallNotificationHandler, HyperlinkHandler, StrMenuHandler.
Pattern 5: TestPage for UI Testing
[Test]procedure CustomerCard_SetHighCreditLimit_ShowsWarning()varCustomer: Record Customer;CustomerCard: TestPage "Customer Card";begin// [SCENARIO] Setting very high credit limit shows warningInitialize();CreateCustomerWithSalesHistory(Customer, 10000);// [WHEN] User opens card and sets excessive credit limitCustomerCard.OpenEdit();CustomerCard.GoToRecord(Customer);// [THEN] Validation error is raisedasserterror CustomerCard."Credit Limit (LCY)".SetValue(99999999);Assert.ExpectedError('Credit limit exceeds');end;[Test]procedure DiscountList_FilterByCustomerGroup_ShowsFiltered()varDiscountList: TestPage "Contoso Discount List";beginInitialize();CreateDiscountsForGroups();DiscountList.OpenView();DiscountList.Filter.SetFilter("Customer Group", 'PREMIUM');// [THEN] Only premium discounts visibleAssert.IsTrue(DiscountList.First(), 'Should have at least one premium discount');Assert.AreEqual('PREMIUM', DiscountList."Customer Group".Value,'Filtered record should be PREMIUM group');end;
Pattern 6: AI Test Toolkit (Copilot Feature Testing)
For testing Copilot capabilities (PromptDialog pages, AI-generated suggestions):
codeunit 50210 "Copilot Suggestion Tests"{Subtype = Test;TestPermissions = Disabled;varAssert: Codeunit Assert;AITTestContext: Codeunit "AIT Test Context";[Test]procedure GenerateSuggestion_ValidInput_ReturnsExpectedFormat()varTestInput: Text;TestOutput: Text;begin// [SCENARIO] Copilot suggestion generates correct structured outputInitialize();// [GIVEN] A valid input prompt from the test datasetTestInput := AITTestContext.GetInput().ValueAsText();// [WHEN] The AI generation procedure is invokedTestOutput := GenerateSuggestion(TestInput);// [THEN] Output is non-empty and contains expected structureAssert.AreNotEqual('', TestOutput, 'AI should return non-empty suggestion');AITTestContext.SetTestOutput(TestOutput);end;}
AI Test Toolkit workflow:
- Create test suite in BC: search "AI Test Suite" page
- Define input datasets (prompts + expected behavior descriptions)
- Run suite — each input is passed via
AITTestContext.GetInput() - Validate output structure, not exact text (AI responses vary)
- Use
AITTestContext.SetTestOutput()to log results for review
Key assertions for AI features:
- Output is non-empty
- Output contains required fields/structure (JSON schema validation)
- No hallucinated object IDs (validate against real BC data)
- Response time within acceptable bounds
Workflow
Step 1: Design Test Strategy
Read the requirement contracts before creating any tests:
.github/plans/{req_name}.spec.md ← acceptance criteria to test.github/plans/{req_name}.architecture.md ← components to cover.github/plans/{req_name}.test-plan.md ← existing plan (if any).github/plans/memory.md ← context and conventions
Categorize test scenarios:
- Unit — isolated logic: calculations, validations, transformations
- Integration — component interaction: posting, event subscribers, API calls
- UI — page behavior: field validation, actions, navigation (TestPage)
- Edge/Error — boundaries, invalid inputs, missing data, permission errors
- AI — Copilot features (AI Test Toolkit)
Coverage targets:
| Area | Target | |
|---|---|---|
| Core business logic | 95% | |
| Integration paths | 85% | |
| UI interactions | 70% | |
| Error handling paths | 100% | |
| Overall | 85%+ |
Step 2: Create Test Plan Document
Create .github/plans/{req_name}.test-plan.md using .github/docs/templates/test-plan-template.md:
- List every scenario as Given/When/Then with a test method name
- Group by unit / integration / UI / edge case
- Define library codeunits needed
- Set coverage targets
- PAUSE — wait for user approval before implementing
Step 3: Implement Tests (TDD Integration)
When called by `al-conductor` in TDD cycle:
RED phase:1. Write failing test(s) for the current requirement2. Run: al_build → verify compilation3. Run test → confirm it FAILS (no implementation yet)GREEN phase:4. Implement minimum code to make test(s) pass5. Run test → confirm it PASSESREFACTOR phase:6. Improve code quality while keeping tests green7. Run all tests → confirm no regressions
When called standalone (tests for existing code):
- Create test codeunit per feature:
"Feature Name Tests" - Create library codeunit per domain:
"Library - Feature Name" - Implement tests following GWT pattern (Pattern 1)
- Add handlers (Pattern 4) for any dialogs
- Run:
al_build+ test execution
Step 4: Test Isolation
Each test MUST be independent — no shared mutable state between tests:
// ✅ Initialize procedure resets statelocal procedure Initialize()beginif IsInitialized thenexit;// Setup number series, general posting setup, etc.// Use standard Library codeunits:// LibraryERM.SetupGenPostingGroups()// LibrarySales.SetupNoSeries()IsInitialized := true;end;// ✅ Each test creates its own data[Test]procedure Test_A()beginInitialize();CreateOwnTestData(); // isolated// ...end;[Test]procedure Test_B()beginInitialize();CreateOwnTestData(); // independent from Test_A// ...end;
Transaction isolation: AL test framework auto-rolls back after each [Test] procedure when TestPermissions = Disabled. No manual cleanup needed.
Step 5: Validate and Report
- Run full test suite
- Verify all tests pass — zero tolerance for flaky tests
- Update coverage metrics in
.github/plans/{req_name}.test-plan.md - Update
memory.mdwith test results summary
References
- AL Test Framework — Microsoft Docs
- Test Codeunits and Methods
- TestPage Data Type
- Handler Functions
- AI Test Toolkit
- Library Assert
Constraints
- This skill covers active test design, patterns, and TDD integration — it does NOT duplicate passive rules in
al-testing.instructions.md(auto-applied to**/test/**/*.al) - Tests MUST live in the Test project, NEVER in the App folder (per AL-Go structure)
- Do NOT generate tests without explicit user request
- Do NOT create interdependent tests that rely on execution order
- Do NOT write tests without assertions — every
[Test]must verify something - Do NOT test private implementation details — test public contracts only
- For debugging test failures → load
skill-debug.md - For event subscriber testing → load
skill-events.md - For permission testing with
TestPermissions = Restrictive→ loadskill-permissions.md