agentleFS
Sign inSign up

skill-testing

javiarmesto/ALDC-AL-Development-Collection/.claude/skills/skill-testing/SKILL.md

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.

Skill108 starsChanged 11 days ago
---
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

When BC29 test lifecycle instrumentation is needed, read the test section of
[AL18 capability checks](../skill-migrate/references/al18-capabilities.md).
`TestHandlers` / `ITestHandler` require target declaration checks and do not
replace the approved runner. Test-library versions need not equal the app version:
check dependency identity, minimum version, runtime and APIs. Unknown compatibility
limits the affected validation; it does not automatically block drafting the spec.

### Pattern 1: Given/When/Then Test Structure

Every test follows GWT with explicit comments and a descriptive name:

```al
codeunit 50100 "Discount Calculation Tests"
{
    Subtype = Test;
    TestPermissions = Disabled;   // runs as SUPER; not a rollback setting

    var
        Assert: Codeunit Assert;
        LibrarySales: Codeunit "Library - Sales";
        LibraryRandom: Codeunit "Library - Random";
        IsInitialized: Boolean;

    [Test]
    procedure CalculateLineDiscount_VolumeOver100_Applies15Percent()
    var
        SalesLine: Record "Sales Line";
        DiscountMgt: Codeunit "Contoso Discount Management";
        Result: Decimal;
    begin
        // [SCENARIO] Volume discount is correctly applied for quantities ≥ 100
        Initialize();

        // [GIVEN] A sales line with quantity 100 and unit price 10
        CreateSalesLineWithQty(SalesLine, 100, 10);

        // [GIVEN] Volume discount setup: 100+ units → 15%
        CreateVolumeDiscountSetup(100, 15);

        // [WHEN] Discount is calculated
        Result := DiscountMgt.CalculateLineDiscount(SalesLine);

        // [THEN] Discount percentage is 15%
        Assert.AreEqual(15, Result, 'Volume discount not applied for qty >= 100');

        // [THEN] Line amount reflects the discount
        Assert.AreEqual(850, SalesLine."Line Amount",
            'Line amount should be 100 * 10 * (1 - 0.15) = 850');
    end;

    local procedure Initialize()
    begin
        if IsInitialized then
            exit;
        // 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:

```al
codeunit 50200 "Library - Contoso Sales"
{
    /// Creates a customer with standard defaults for testing.
    procedure CreateCustomer(var Customer: Record Customer)
    var
        LibrarySales: Codeunit "Library - Sales";
    begin
        LibrarySales.CreateCustomer(Customer);   // standard BC library
        Customer."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)
    var
        SalesLine: Record "Sales Line";
        LibrarySales: Codeunit "Library - Sales";
    begin
        LibrarySales.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:

```al
codeunit 50201 "Sales Order Builder"
{
    var
        SalesHeader: Record "Sales Header";
        LineCount: Integer;

    procedure Create(): Codeunit "Sales Order Builder"
    begin
        SalesHeader.Init();
        SalesHeader."Document Type" := SalesHeader."Document Type"::Order;
        SalesHeader.Insert(true);
        exit(this);
    end;

    procedure WithCustomer(CustomerNo: Code[20]): Codeunit "Sales Order Builder"
    begin
        SalesHeader.Validate("Sell-to Customer No.", CustomerNo);
        SalesHeader.Modify(true);
        exit(this);
    end;

    procedure WithLine(ItemNo: Code[20]; Qty: Decimal): Codeunit "Sales Order Builder"
    var
        SalesLine: Record "Sales Line";
    begin
        LineCount += 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"
    begin
        exit(SalesHeader);
    end;
}

// Usage in test:
[Test]
procedure PostOrder_TwoLines_CreatesInvoice()
var
    SalesHeader: Record "Sales Header";
    Builder: Codeunit "Sales Order Builder";
begin
    Initialize();
    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:

```al
[Test]
[HandlerFunctions('ConfirmYesHandler,MessageHandler')]
procedure PostSalesOrder_WithValidation_CreatesLedgerEntry()
var
    SalesHeader: Record "Sales Header";
    LibrarySales: Codeunit "Library - Sales";
begin
    // [SCENARIO] Posting order with custom validation creates ledger entry
    Initialize();

    // [GIVEN] A released sales order
    CreateReleasedOrder(SalesHeader);

    // [WHEN] Order is posted (triggers confirm + message)
    LibrarySales.PostSalesDocument(SalesHeader, true, true);

    // [THEN] Custom ledger entry exists
    VerifyCustomLedgerEntry(SalesHeader."No.");
end;

[ConfirmHandler]
procedure ConfirmYesHandler(Question: Text; var Reply: Boolean)
begin
    Reply := true;
end;

[MessageHandler]
procedure MessageHandler(Msg: Text)
begin
    // Absorb expected messages
end;

[PageHandler]
procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card")
begin
    CustomerCard."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

```al
[Test]
procedure CustomerCard_SetHighCreditLimit_ShowsWarning()
var
    Customer: Record Customer;
    CustomerCard: TestPage "Customer Card";
begin
    // [SCENARIO] Setting very high credit limit shows warning
    Initialize();
    CreateCustomerWithSalesHistory(Customer, 10000);

    // [WHEN] User opens card and sets excessive credit limit
    CustomerCard.OpenEdit();
    CustomerCard.GoToRecord(Customer);

    // [THEN] Validation error is raised
    asserterror CustomerCard."Credit Limit (LCY)".SetValue(99999999);
    Assert.ExpectedError('Credit limit exceeds');
end;

[Test]
procedure DiscountList_FilterByCustomerGroup_ShowsFiltered()
var
    DiscountList: TestPage "Contoso Discount List";
begin
    Initialize();
    CreateDiscountsForGroups();

    DiscountList.OpenView();
    DiscountList.Filter.SetFilter("Customer Group", 'PREMIUM');

    // [THEN] Only premium discounts visible
    Assert.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):

```al
codeunit 50210 "Copilot Suggestion Tests"
{
    Subtype = Test;
    TestPermissions = Disabled;   // runs as SUPER; not a rollback setting

    var
        Assert: Codeunit Assert;
        AITTestContext: Codeunit "AIT Test Context";

    [Test]
    procedure GenerateSuggestion_ValidInput_ReturnsExpectedFormat()
    var
        TestInput: Text;
        TestOutput: Text;
    begin
        // [SCENARIO] Copilot suggestion generates correct structured output
        Initialize();

        // [GIVEN] A valid input prompt from the test dataset
        TestInput := AITTestContext.GetInput().ValueAsText();

        // [WHEN] The AI generation procedure is invoked
        TestOutput := GenerateSuggestion(TestInput);

        // [THEN] Output is non-empty and contains expected structure
        Assert.AreNotEqual('', TestOutput, 'AI should return non-empty suggestion');
        AITTestContext.SetTestOutput(TestOutput);
    end;
}
```

**AI Test Toolkit workflow:**
1. Create test suite in BC: search "AI Test Suite" page
2. Define input datasets (prompts + expected behavior descriptions)
3. Run suite — each input is passed via `AITTestContext.GetInput()`
4. Validate output structure, not exact text (AI responses vary)
5. 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:
```
.claude/plans/{req_name}.spec.md          ← acceptance criteria to test
.claude/plans/{req_name}.architecture.md   ← components to cover
.claude/plans/{req_name}.test-plan.md      ← existing plan (if any)
.claude/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 `.claude/plans/{req_name}.test-plan.md` using `${CLAUDE_PROJECT_DIR}/claude-plugin/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 requirement
  2. Run: al_build → verify compilation
  3. Run test → confirm it FAILS (no implementation yet)

GREEN phase:
  4. Implement minimum code to make test(s) pass
  5. Run test → confirm it PASSES

REFACTOR phase:
  6. Improve code quality while keeping tests green
  7. Run all tests → confirm no regressions
```

**When called standalone (tests for existing code):**
1. Create test codeunit per feature: `"Feature Name Tests"`
2. Create library codeunit per domain: `"Library - Feature Name"`
3. Implement tests following GWT pattern (Pattern 1)
4. Add handlers (Pattern 4) for any dialogs
5. Run: `al_build` + test execution

### Step 4: Test Isolation

Each test MUST be independent — no shared mutable state between tests:

```al
// ✅ Initialize procedure resets state
local procedure Initialize()
begin
    if IsInitialized then
        exit;
    // 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()
begin
    Initialize();
    CreateOwnTestData();  // isolated
    // ...
end;

[Test]
procedure Test_B()
begin
    Initialize();
    CreateOwnTestData();  // independent from Test_A
    // ...
end;
```

**Transaction isolation**: rollback is controlled by `TransactionModel` on the test method — `AutoRollback`, the default, rolls back the method's own transaction — and by `TestIsolation` on the *test runner* codeunit, where `Function` and `Codeunit` roll back after each method or each codeunit while the default `Disabled` rolls back nothing. Only `TestIsolation` reverts what a test committed explicitly with `Commit`.

**`TestPermissions` is not a rollback setting.** It selects the permission context: the default `Restrictive` starts each test at `D365 Full Access` and expects it to lower permissions, while `Disabled` skips that and runs every test as SUPER. Use `Disabled` only for tests that are not about permissions, and `Restrictive` when they are — see `skill-permissions`.

### Step 5: Validate and Report

1. Run full test suite
2. Verify all tests pass — zero tolerance for flaky tests
3. Update coverage metrics in `.claude/plans/{req_name}.test-plan.md`
4. Update `memory.md` with test results summary

## References

- [AL Test Framework — Microsoft Docs](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-testing-application)
- [Test Codeunits and Methods](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-test-codeunits-and-test-methods)
- [TestPage Data Type](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/testpage/testpage-data-type)
- [Handler Functions](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-creating-handler-methods-in-tests)
- [AI Test Toolkit](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-ai-test-toolkit)
- [Library Assert](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-library-assert)
- [TestIsolation Property](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-testisolation-property)
- [TestPermissions Property](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-testpermissions-property)
- [TransactionModel Attribute](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/attributes/devenv-transactionmodel-attribute)

## 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` → load `skill-permissions.md`

Discussion

Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.

Posts are public.Sign in to post

No one has posted yet. Be the first.