A single test flask fed by many data rows, surrounded by an orbit of six lifecycle checkpoints, with pass and fail markers on the results.

Testing in AL 18.0: Data-Driven Tests and TestHandlers

6 min read

Two patterns show up in almost every AL test suite I review.

The first is the copy-pasted test: TestLateFee_0Days, TestLateFee_15Days, TestLateFee_30Days, TestLateFee_31Days - the same twenty lines, four times, with one number changed. The second is the copy-pasted plumbing: every test codeunit starting with the same timing code, the same cleanup, because there is nowhere else to put it.

AL 18.0 (Business Central 2026 release wave 2) adds a fix for each:

  • Data-driven tests with the [TestDataSource] attribute - one test procedure, many cases.
  • TestHandlers - lifecycle callbacks around codeunits, procedures and cases, attached by a property instead of code.

I built both in a demo app against the BC 29 symbols from Microsoft's public symbol feed, compiled with AL 18.1. Short version: the data-driven API that shipped is different from the one in the AL changelog, so if you copy the changelog sample it will not compile. Everything below is the version that does.

Note

Both features need "runtime": "18.0". The types live in two namespaces you have to import: System.Test (data-driven tests) and System.Testability (test handlers).

Part 1 - Data-driven tests

Changelog vs. what shipped

The AL 18.0 changelog describes an ITestDataSource with a single GetDataRows method returning a list of contexts, and an ITestContext with an Identifier() method. Neither exists in the BC 29 System app. I checked two platform builds - 29.0.55597.0 from the public symbol feed and 29.0.56480.0, the newest one altool downloadsymbols pulled when I wrote this - and both have the same API. Here is what is actually in the symbols:

ChangelogBC 29 symbols
ITestContextIdentifier(): Textno methods - a marker interface
ITestDataSourceGetDataRows(DataSetIdentifier, Context): List of [Interface ITestContext]ListTestCases(DataSetIdentifier: Text; Context: DataSourceContext): List of [Text] and GetTestCase(DataSetIdentifier: Text; TestCaseIndex: Integer; TestCaseIdentifier: Text; Context: DataSourceContext): Interface ITestContext
Test parameterITestContext "or an interface that extends it"exactly Interface ITestContext

Microsoft Learn is split on this too. The TestDataSource attribute page already says the data set identifier is "passed to ITestDataSource.ListTestCases and GetTestCase", while the data-driven tests section of the how-to article still describes GetDataRows and Identifier(), and says the parameter may be an interface that extends ITestContext. The compiler is the tie-breaker, and everything below is what it accepts.

The shipped design is two-phase, and better for it: the framework first asks for the list of case identifiers, then asks for each case on demand. Identifiers are plain text you choose, so they are stable between runs by construction.

sequenceDiagram
  participant F as Test framework
  participant D as Data source codeunit
  participant T as Test procedure
  F->>D: ListTestCases('LateFeeTestData')
  D-->>F: ['0 days', '15 days', '30 days', '31 days']
  loop each identifier
    F->>D: GetTestCase('LateFeeTestData', index, '15 days')
    D-->>F: Interface ITestContext
    F->>T: LateFeeTest(context)
  end

The code under test

AL
codeunit 50150 "Late Fee Calculator"
{
    procedure CalcLateFee(DaysOverdue: Integer; Amount: Decimal): Decimal
    begin
        case true of
            DaysOverdue <= 0:
                exit(0);
            DaysOverdue <= 15:
                exit(Round(Amount * 0.01));
            DaysOverdue <= 30:
                exit(Round(Amount * 0.025));
            else
                exit(Round(Amount * 0.05));
        end;
    end;
}

The context

ITestContext is empty, so you extend it with whatever your test needs:

AL
namespace SSOSIC.Showcase.Testing;

using System.Test;

interface "ILate Fee Test Context" extends ITestContext
{
    procedure DaysOverdue(): Integer;
    procedure Amount(): Decimal;
    procedure ExpectedFee(): Decimal;
}

And a codeunit that implements it:

AL
codeunit 50151 "Late Fee Test Case" implements "ILate Fee Test Context"
{
    var
        Days: Integer;
        Amt: Decimal;
        Expected: Decimal;

    procedure Init(NewDays: Integer; NewAmount: Decimal; NewExpected: Decimal)
    begin
        Days := NewDays;
        Amt := NewAmount;
        Expected := NewExpected;
    end;

    procedure DaysOverdue(): Integer
    begin
        exit(Days);
    end;

    procedure Amount(): Decimal
    begin
        exit(Amt);
    end;

    procedure ExpectedFee(): Decimal
    begin
        exit(Expected);
    end;
}

The data source

AL
codeunit 50152 "Late Fee Test Data Source" implements ITestDataSource
{
    // Step 1: the framework asks for the identifiers of every case.
    procedure ListTestCases(DataSetIdentifier: Text; Context: DataSourceContext): List of [Text]
    var
        Identifiers: List of [Text];
    begin
        Identifiers.Add('0 days');
        Identifiers.Add('15 days');
        Identifiers.Add('30 days');
        Identifiers.Add('31 days');
        exit(Identifiers);
    end;

    // Step 2: it asks for each case, one at a time, by index and identifier.
    procedure GetTestCase(DataSetIdentifier: Text; TestCaseIndex: Integer; TestCaseIdentifier: Text; Context: DataSourceContext): Interface ITestContext
    var
        LateFeeCase: Codeunit "Late Fee Test Case";
    begin
        case TestCaseIdentifier of
            '0 days':
                LateFeeCase.Init(0, 1000, 0);
            '15 days':
                LateFeeCase.Init(15, 1000, 10);
            '30 days':
                LateFeeCase.Init(30, 1000, 25);
            '31 days':
                LateFeeCase.Init(31, 1000, 50);
            else
                Error('Unknown test case %1 in data set %2', TestCaseIdentifier, DataSetIdentifier);
        end;
        exit(LateFeeCase);
    end;
}

Because each case is built in its own GetTestCase call, each one gets a fresh codeunit instance. The "every case holds the last values" trap you would worry about with one shared list simply can't happen.

The test

Two rules the compiler enforces that are easy to trip over:

Text
error AL0189: Attribute 'Test' cannot be specified, because 'TestDataSource' is already specified

[TestDataSource] replaces [Test] - don't use both.

Text
error AL0240: The signature of procedure 'LateFeeTest' does not match the signature required by
attribute 'TestDataSource': parameter 1 is expected to be of type 'interface ITestContext' but found
type 'Interface SSOSIC.Showcase.Testing."ILate Fee Test Context"'.
The expected signature is: LateFeeTest(Context: interface ITestContext).

The parameter must be exactly Interface ITestContext. You get to your own interface with the as operator:

AL
codeunit 50160 "Late Fee Tests"
{
    Subtype = Test;

    var
        Assert: Codeunit "Library Assert";

    [TestDataSource(Codeunit::"Late Fee Test Data Source", 'LateFeeTestData')]
    procedure LateFeeTest(Context: Interface ITestContext)
    var
        Calculator: Codeunit "Late Fee Calculator";
        LateFeeCase: Interface "ILate Fee Test Context";
    begin
        LateFeeCase := Context as "ILate Fee Test Context";
        Assert.AreEqual(
            LateFeeCase.ExpectedFee(),
            Calculator.CalcLateFee(LateFeeCase.DaysOverdue(), LateFeeCase.Amount()),
            'Late fee');
    end;
}

The second argument of [TestDataSource] is just text, passed to both data source methods as DataSetIdentifier. Use it to serve several data sets from one data source codeunit, or as a resource name to load cases from JSON.

Each identifier you return from ListTestCases names one case. Learn describes the case identifier as what names the case in the test results and in TestHandlerContext.TestCaseName, and says it "must be deterministic and remain stable between runs" - with the shipped API that is automatic, because you write the identifiers yourself.

When to use it

  • Boundary tables: fees, discounts, rounding, date calculations.
  • The same scenario across several setups (currency, VAT posting group, dimension rules).
  • Regression lists - every bug report becomes a new identifier instead of a new procedure.

Don't use it for tests that need different steps, only different data. If the cases need if statements to decide what to do, they are different tests.

Part 2 - TestHandlers

Nested brackets showing codeunit, procedure and case levels of a test run with hook points at each boundary.

What they are

A test handler is a codeunit that implements ITestHandler and receives callbacks at every stage of a test run. You attach it to test codeunits with a property - the test code itself does not change.

ElementNamespacePurpose
ITestHandler interfaceSystem.TestabilitySix lifecycle callbacks.
TestHandler enum (2000000023)System.TestabilityExtensible registry mapping values to handler codeunits.
DefaultTestHandler enumSystem.TestabilitySame, but handlers registered here run for every test codeunit.
TestHandlers propertyComma-separated list of TestHandler values on a test codeunit.
TestHandlerContextRead-only info about the running test.

The six callbacks are OnBefore/OnAfter + TestCodeunitRun, TestProcedureRun and TestCaseRun. Every one of them has a default (empty) body - ITestHandler is itself built on the new default interface methods. The compiler tells you which ones you are inheriting:

Text
info AL0925: Interface 'ITestHandler' provides a default implementation for method
'OnBeforeTestCodeunitRun'. Override it to customize the behavior.

The callbacks and their order

From the changelog, for a codeunit with two test procedures where the second is data-driven with two cases:

Text
OnBeforeTestCodeunitRun          - once, before any procedure
  OnBeforeTestProcedureRun       - before procedure 1
  OnAfterTestProcedureRun        - after procedure 1
  OnBeforeTestProcedureRun       - before procedure 2
    OnBeforeTestCaseRun          - before case A of procedure 2
    OnAfterTestCaseRun           - after case A of procedure 2
    OnBeforeTestCaseRun          - before case B of procedure 2
    OnAfterTestCaseRun           - after case B of procedure 2
  OnAfterTestProcedureRun        - after procedure 2
OnAfterTestCodeunitRun           - once, after all procedures

The case-level callbacks only fire for [TestDataSource] procedures - which is why these two features belong in the same post.

Learn's lifecycle handlers section documents the same nesting: procedure callbacks inside the codeunit callbacks, and case callbacks inside the procedure callbacks of a data-driven procedure.

TestHandlerContext gives you CodeunitId, CodeunitName, ProcedureName (empty at codeunit level), TestCaseName (only in case callbacks), and Success (only meaningful in OnAfter*).

It also has one member the changelog doesn't mention: Skip(Reason). Called from OnBeforeTestProcedureRun or OnBeforeTestCaseRun, it skips the test body and reports the procedure or case as skipped with your reason; from an after callback it does nothing. That makes a handler a clean place for pipeline rules like "don't run the slow tests on every push":

AL
codeunit 50154 "Skip Slow Tests Handler" implements ITestHandler
{
    procedure OnBeforeTestProcedureRun(Context: TestHandlerContext)
    begin
        if Context.ProcedureName.EndsWith('_Slow') then
            Context.Skip('Slow tests are skipped in this pipeline');
    end;
}

It compiles against the BC 29 symbols; the behaviour described is from the TestHandlerContext reference.

Example: a timing handler

AL
namespace SSOSIC.Showcase.Testing;

using System.Testability;

codeunit 50153 "Test Timing Handler" implements ITestHandler
{
    SingleInstance = true;

    var
        StartedAt: DateTime;
        TimingMsg: Label '%1.%2 took %3 ms (success: %4)', Locked = true;

    procedure OnBeforeTestProcedureRun(Context: TestHandlerContext)
    begin
        StartedAt := CurrentDateTime();
    end;

    procedure OnAfterTestProcedureRun(Context: TestHandlerContext)
    begin
        Session.LogMessage('SSOSIC-TEST-0001',
            StrSubstNo(TimingMsg,
                Context.CodeunitName, Context.ProcedureName,
                CurrentDateTime() - StartedAt, Context.Success),
            Verbosity::Normal, DataClassification::SystemMetadata,
            TelemetryScope::ExtensionPublisher, 'Category', 'Tests');
    end;
}

SingleInstance = true keeps StartedAt alive between the before and after callbacks. Neither the changelog nor Learn says how the framework creates handler instances, so this makes the timing correct either way.

Register it and opt a codeunit in:

AL
enumextension 50150 "SSOSIC Test Handlers" extends TestHandler
{
    value(50150; Timing)
    {
        Implementation = ITestHandler = "Test Timing Handler";
    }
}
AL
codeunit 50160 "Late Fee Tests"
{
    Subtype = Test;
    TestHandlers = Timing;
    // ...
}

Multiple handlers are separated by commas and run in the order listed. The property only works on test codeunits:

Text
error AL0223: The Property 'TestHandlers' can only be used if the property 'Subtype' is set to 'Test'

Handlers for everything: DefaultTestHandler

Extend DefaultTestHandler instead, and the handler runs for every test codeunit without anyone listing it:

AL
enumextension 50151 "SSOSIC Default Test Handlers" extends DefaultTestHandler
{
    value(50151; GlobalTiming)
    {
        Implementation = ITestHandler = "Test Timing Handler";
    }
}

Default handlers run before codeunit-specific ones.

Learn describes this as the way "to register a handler for every test codeunit", with no mention of app boundaries. So treat a default handler as global: keep it cheap and free of side effects, and put it in a test-only app that you install only where you want it - not in a shared test library that ends up in every CI container.

Good uses, bad uses

Good:

  • Timing and telemetry for tests, so slow tests show up in CI.
  • Cleanup that has to happen whether or not the test passed.
  • Custom reporting (writing results somewhere your pipeline can read).

Bad:

  • Test setup that individual tests depend on. If a test only passes because a handler created data first, that dependency is invisible when you read the test. Keep Initialize() in the test codeunit.

Summary

Data-driven tests remove duplicated test procedures; TestHandlers remove duplicated test plumbing. Both need runtime 18.0, and both need a using you might not guess (System.Test, System.Testability). And if you start from the changelog: the shipped data source API is ListTestCases + GetTestCase, ITestContext is empty, and the test parameter is exactly Interface ITestContext.

Next in this area: running all of this from the command line with altool runtests - see AL without VS Code.

Related: Mocking HTTP Calls in AL Tests.

Related posts

Comments

    Leave a comment

    Reviewed before it appears.