The AL test framework
By Emil Björk · Microsoft business apps consultant, Gothenburg
Writing automated tests in AL — test codeunits, test runners, TestPage, mocking, test isolation, and CI with AL-Go.
On this page (13)
Business Central ships with a full test framework for AL that is the right tool for both unit tests and integration tests of your extensions. Adopting it is a one-time investment that pays back the first time you regression-test a major release wave update, and it is what separates an extension a partner can safely hand off from one only its original author dares to touch.
Test codeunits
A test codeunit is a normal codeunit with the Subtype = Test property set. Each test method is decorated with [Test], optionally [HandlerFunctions(...)] to register UI handlers, and [TransactionModel(...)] to control transactional isolation. The test runner discovers test codeunits in installed extensions and executes them in containers, one method at a time, reporting pass, fail, or skipped for each.
The idiomatic shape is arrange, act, assert: build the scenario (create a customer, an item, a sales order), call the one procedure under test, then assert on the result. Keep each test method to one behaviour — a test that asserts five unrelated things fails opaquely and is expensive to debug.
codeunit 50100 "Sales Discount Tests"
{
Subtype = Test;
[Test]
procedure LineDiscountAppliesAboveThreshold()
var
Customer: Record Customer;
Item: Record Item;
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
LibrarySales: Codeunit "Library - Sales";
LibraryInventory: Codeunit "Library - Inventory";
Assert: Codeunit Assert;
begin
// Arrange
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", 100);
// Act
SalesLine.Validate("Quantity", 100);
SalesLine.Modify(true);
// Assert
Assert.IsTrue(SalesLine."Line Discount %" > 0, 'Expected a volume discount above threshold.');
end;
}
Test isolation
By default, each test runs in a rolled-back transaction, so changes don't persist between tests. This is what makes the suite re-runnable against the same database without a reset step, and why AL tests can run in parallel against a shared sandbox faster than most integration-test setups in other stacks. For tests that genuinely need to commit — testing a sequence of posted documents that a later test method depends on, or code that explicitly starts a new transaction — TransactionModel::AutoCommit is available on the [Test] attribute; use it sparingly, since committing tests must clean up their own data or later runs see stale state.
Test libraries
Microsoft publishes a set of test libraries as separate AL packages — Test Library - Sales, Test Library - Inventory, Test Library - ERM, Test Library - Purchase, and more. They give you helper functions like CreateCustomer, CreateItem, PostSalesDocument that produce valid, referentially-correct test data without you wiring up every mandatory field by hand. They live on AppSource and are installed into the sandbox environment where tests run — never into a production tenant. Reach for the library helper before writing your own setup code; it is maintained against every release wave, so your tests keep working when Microsoft adds a new mandatory field to a standard table.
TestPage — driving pages without a browser
Where a test needs to exercise page logic (OnValidate trigger on a page field, an action, a FactBox), AL provides the TestPage object type: a strongly-typed handle onto a page that runs in-process, with no browser and no rendering. TestPage."Sales Order".OpenNew() opens the page, .Customer.SetValue(...) sets a field the way a user typing into it would (running validation), and .Post.Invoke() fires the action. Because it drives the real page object, a TestPage test catches bugs a codeunit-level test cannot — a field made read-only by a page-level Editable expression, or an action wired to the wrong trigger.
Mocking and event subscribers
AL has no dependency-injection container, so "mocking" means one of three things in practice:
- Event subscriber substitution — subscribe a test-only codeunit to the same event the production subscriber uses, and have it record what happened instead of doing the real integration (useful for verifying an outbound webhook or Service Bus call fires with the right payload, without a live endpoint).
- Interface swapping — if the production code depends on an AL
interface, a test app can register a different implementation for the same interface and inject it via dependency codeunits, letting tests substitute a fake connector for a real external service. - Table isolation — because every test runs in a rolled-back transaction, "mocking" data often just means creating real records with the test libraries; there is rarely a need to fake a table the way you would in an object-oriented unit-test framework.
Handler functions
AL tests run headless, so any dialogs or pages the code under test opens need a handler. A [MessageHandler], [ConfirmHandler], [ModalPageHandler], or [ReportHandler] registered on the test codeunit catches the call and lets the test assert on its content and respond (confirm yes/no, dismiss a message, or read back what a modal page would have shown). A test that forgets a handler for code that pops a confirm dialog does not fail cleanly — it hangs the run until the container's timeout kills it, which is the most common early debugging surprise for developers new to the framework.
Assertions
The Assert codeunit provides IsTrue, IsFalse, AreEqual, AreNotEqual, AreNearlyEqual (for decimals), RecordCount, RecordIsEmpty, and string/error-message variants including ExpectedError for asserting that a specific error was raised. Pass a clear failure message as the last argument to every assertion — when a suite runs in CI months later and one test fails, that message is often the only context available.
Permissions in tests
Test codeunits run under the permissions of whichever user context the container test session uses, which is normally SUPER during local development. If the extension ships its own permission sets, add a test — or a dedicated permission-set test codeunit — that runs the main scenarios under the actual shipped permission set rather than SUPER, so a missing table permission surfaces in CI instead of in the customer's first week live.
Running tests
Inside Business Central, the Test Tool page lists test codeunits, lets you run them interactively one at a time or as a suite, and shows pass/fail with the failure message and stack trace for anything red. This is the fast local-development loop while you're writing tests. From CI, the Run-TestsInBcContainer PowerShell cmdlet (part of BcContainerHelper) or AL-Go for GitHub's built-in test step runs the same suite headless and emits results in JUnit XML, which any CI dashboard (GitHub Actions, Azure DevOps) can render as pass/fail annotations on the pull request.
CI with AL-Go for GitHub
AL-Go for GitHub is Microsoft's template repository and set of reusable GitHub Actions workflows for AL projects. For testing specifically, the relevant pieces are:
- Project structure — the app under test and its test app live as separate projects in the same repository (or
.AL-Go/settings.jsonapp-dependency chain), so AL-Go builds both and installs the test app only in the build/test container, never in the release artifact. - The build workflow — on every push and pull request, AL-Go spins up a fresh container, compiles both apps, installs them, and runs the test suite; a failing test fails the workflow and blocks merge if branch protection requires it.
- Test results as a check — the JUnit output is surfaced as a GitHub check run, so a reviewer sees pass/fail without opening logs.
- Nightly or scheduled runs — because Business Central ships a new minor release on a schedule, a scheduled AL-Go workflow re-running the suite against the current sandbox build catches a breaking platform change before your own release wave update lands for customers, not after.
Code coverage
AL has built-in code coverage reporting: the test runner can produce a coverage map showing which lines of the system-under-test were exercised, viewable in VS Code or exported for a CI coverage gate. Coverage percentage is a useful trend line, not a target to game — 100% coverage of getter-setter boilerplate is worth less than 60% coverage concentrated on posting logic and event subscribers.
Common pitfalls
- Forgetting a handler for a confirm/message dialog, which hangs the test run rather than failing it.
- Testing through the UI when a codeunit test would do — reach for
TestPageonly when the behaviour you're testing genuinely lives on the page, not as a default way to exercise everything. - Committing data without cleanup in an
AutoCommittest, which then breaks every test that runs after it in the same container. - One test app testing everything — as an extension's test suite grows, split it by module (finance tests, inventory tests) so a single slow or flaky area doesn't block the whole pipeline.
Where to start
Don't try for 100% coverage on day one. Start by testing the posting paths that matter most to the business — your sales discount calculation, your custom price formulas, your event subscribers on standard posting codeunits — and grow the suite from there. A extension with even a thin regression suite around its riskiest customisations is measurably safer to upgrade through a release wave than one with none.
Frequently asked questions
Do AL tests need a real Business Central environment?
- Yes. AL tests run inside a Business Central container or sandbox, not an isolated unit-test runner outside the product. 'Headless' means no UI interaction is needed, not that there's no server — AL-Go and BcContainerHelper both provision a container as part of the pipeline.
Should test code live in the same app as production code?
- No. The standard pattern is a separate test app that depends on the app under test, so the Test Runner dependency, test libraries, and test codeunits never ship to a production tenant.
What's the difference between the Test Runner and the test libraries?
- The Test Runner (built into the platform) discovers and executes codeunits with Subtype = Test and reports pass/fail. The test libraries are separate AppSource apps (Test Library - Sales, - Inventory, - ERM, and so on) that supply helper functions for building valid test data quickly — they're a convenience layer on top of the Test Runner, not a replacement for it.
Further reading
Related guides
- Business Central CI/CD with AL-GoHow AL-Go for GitHub turns an AL extension repo into a build-test-deploy pipeline — secrets, environments, and continuous delivery.
- Writing your first AL extensionA first-walkthrough of building, publishing, and running a basic AL extension for Business Central — toolchain, project structure, and deployment.
- The Business Central API and OData servicesHow external systems talk to Business Central — the v2.0 REST API, OData web services, bound actions, and call limits.
- AL events and integration patternsHow AL events let extensions hook into Business Central — business events, integration events, subscriber patterns, and what to avoid.
- AL extension architectureHow AL extensions are structured in Business Central — objects, namespaces, app.json, dependencies, and the runtime model.
Browse every guide in Business Central or just AL & development.
Was this helpful?
Signals which guides land and which need work. No account, no comment box — corrections go through the contact page.
Spot something wrong or want a topic covered? Send a correction or a topic request — both are welcome.