14.2 Authoring Test Codeunits & UI Handler Functions
Key Takeaways
- Test codeunits are defined with Subtype = Test and contain individual test scenarios decorated with the [Test] attribute and procedure-level [TransactionModel(TransactionModel::AutoRollback)].
- Automated headless test execution strictly forbids unhandled client interactions; all interactive UI prompts (Confirm, Message, Modal Pages, Reports, Notifications) must be intercepted via [HandlerFunctions('Handler1,Handler2')].
- UI handler attributes enforce strict parameter signatures ([ConfirmHandler], [MessageHandler], [ModalPageHandler], [PageHandler], [ReportHandler], [RequestPageHandler], [SendNotificationHandler]).
- The Business Central test runtime enforces strict handler verification: every handler declared in [HandlerFunctions] MUST be invoked during test execution, or the test fails with an unhandled handler runtime exception.
- Verification is conducted using the Assert codeunit methods, while negative exception testing pairs the asserterror statement with Assert.ExpectedError() or Assert.ExpectedErrorCode() to validate business rule failures without terminating test execution.
14.2 Authoring Test Codeunits & UI Handler Functions
Authoring high-quality automated tests in AL requires an in-depth understanding of test codeunit architecture, procedure attributes, UI handler interception, and assertion mechanisms. Because Business Central automated test runners and CI/CD pipelines execute in non-interactive, headless background sessions, any unhandled user interface interaction—such as a confirmation prompt, informational message dialog, modal lookup page, or report request page—will immediately terminate test execution with a runtime exception: "A user interface was invoked in a non-interactive session." For the MB-820 certification exam, developers must master authoring [Test] procedures, configuring UI handler attributes, manipulating TestPage objects, and asserting expected outcomes and errors using the Assert codeunit.
1. Test Codeunit Anatomy, Attributes & Lifecycle
A test codeunit is defined by declaring Subtype = Test; in the codeunit header. Within this codeunit, individual test scenarios are authored as public procedures decorated with the [Test] attribute and transaction management attributes.
codeunit 50140 "Sales Pricing Tests"
{
Subtype = Test;
TestPermissions = Disabled;
var
LibrarySales: Codeunit "Library - Sales";
LibraryInventory: Codeunit "Library - Inventory";
LibraryAssert: Codeunit "Assert";
IsInitialized: Boolean;
[Test]
[TransactionModel(TransactionModel::AutoRollback)]
procedure VerifyVolumeDiscountAppliedOnSalesOrder()
var
Customer: Record Customer;
Item: Record Item;
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
ExpectedDiscountPct: Decimal;
begin
// [GIVEN] Initialize environment and create test master data
Initialize();
ExpectedDiscountPct := 15.0;
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
SetupVolumeDiscount(Customer."No.", Item."No.", ExpectedDiscountPct);
// [WHEN] Create a Sales Order with quantity exceeding volume threshold (100 units)
LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", 100);
// [THEN] Verify the Line Discount % matches the expected volume discount rate
LibraryAssert.AreEqual(ExpectedDiscountPct, SalesLine."Line Discount %", 'Line Discount % was not correctly applied.');
end;
local procedure Initialize()
var
LibraryERMCountryData: Codeunit "Library - ERM Country Data";
begin
if IsInitialized then
exit;
// One-time setup logic for this test codeunit
LibraryERMCountryData.CreateVATData();
LibraryERMCountryData.UpdateGeneralPostingSetup();
IsInitialized := true;
Commit(); // Allowed during Initialize() to establish baseline state
end;
local procedure SetupVolumeDiscount(CustNo: Code[20]; ItemNo: Code[20]; DiscountPct: Decimal)
var
SalesPrice: Record "Sales Price";
begin
// Helper routine to configure test pricing matrix
end;
}
Key Test Codeunit Attributes & Properties
Subtype = Test: Designates the codeunit as a container of test methods recognizable by the AL Test Tool and Test Runner engine.[Test]: Decorates individual procedures to be discovered and executed as standalone test scenarios.[TransactionModel(TransactionModel::AutoRollback)]: Specifies transaction management at the procedure level:TransactionModel::AutoRollback(Industry Standard & Default for tests): Any database transaction initiated during the test procedure is automatically rolled back upon completion or failure, maintaining a clean slate.TransactionModel::AutoCommit: Automatically commits transactions after each procedure (leaves residual data; not recommended for automated regression).TransactionModel::None: Disables automatic transaction management for the procedure.
TestPermissionsProperty: Can be set toDisabled,Restrictive,Inherent, orLocalto govern permission evaluation during test runs.- The
Initialize()Pattern: Uses a module-levelIsInitialized: Booleanflag to ensure expensive environment initialization (such as VAT setups or number series) runs only once per codeunit run rather than redundantly before every single test method.
2. UI Handlers & Interception Architecture
In headless automated test execution, invoking an unhandled client UI interaction (such as Confirm(), Message(), Page.RunModal(), Report.Run(), or Notification.Send()) immediately throws a fatal runtime exception. To allow automated tests to exercise code paths containing user interface interactions, AL provides UI Handler Procedures.
The [Test] procedure registers which handler procedures it expects to invoke using the [HandlerFunctions('Handler1,Handler2,...')] attribute, providing a comma-separated list of local or global handler procedure names.
Comprehensive UI Handler Reference & Signatures
Every UI handler must be decorated with its corresponding handler attribute and must implement the exact platform signature required by the Business Central compiler:
| Handler Attribute | Intercepted UI Action | Exact Parameter Signature & Return Mechanics |
|---|---|---|
[ConfirmHandler] | Confirm(Question, Default) | procedure MyConfirmHandler(Question: Text[1024]; var Reply: Boolean)<br/>Sets Reply := true or false to simulate the user clicking 'Yes' or 'No'. |
[MessageHandler] | Message(Text, ...) | procedure MyMessageHandler(Message: Text[1024])<br/>Receives the displayed message text for verification without interrupting execution. |
[ModalPageHandler] | Page.RunModal(PageId, Rec) | procedure MyModalHandler(var TargetPage: TestPage "Page Name")<br/>Interacts with or closes modal test pages programmatically. |
[PageHandler] | Page.Run(PageId, Rec) | procedure MyPageHandler(var TargetPage: TestPage "Page Name")<br/>Interacts with non-modal pages launched asynchronously by business logic. |
[ReportHandler] | Report.Run(ReportId, ...) | procedure MyReportHandler(var TargetReport: Report "Report Name")<br/>Executes or inspects reports invoked during processing. |
[RequestPageHandler] | Report.RunModal(...) (Request Page) | procedure MyRequestPageHandler(var TargetRequestPage: TestRequestPage "Report Name")<br/>Sets request page filters, options, and invokes report execution programmatically. |
[SendNotificationHandler] | Notification.Send() | procedure MyNotificationHandler(var TheNotification: Notification): Boolean<br/>Intercepts toast notifications and returns true if handled. |
[HyperlinkHandler] | Hyperlink(Url) | procedure MyHyperlinkHandler(Url: Text)<br/>Captures outgoing web hyperlinks without launching an external browser. |
[FilterPageHandler] | FilterPageBuilder.RunModal() | procedure MyFilterPageHandler(var RecordRef: RecordRef): Boolean<br/>Handles dynamic filter page builders and returns true if filters applied. |
Example: Multi-Handler Interception Workflow
[Test]
[HandlerFunctions('ConfirmPostHandler,InvoicePostedMessageHandler')]
procedure VerifySalesOrderPostingPrompts()
var
SalesHeader: Record "Sales Header";
SalesPost: Codeunit "Sales-Post";
LibrarySales: Codeunit "Library - Sales";
LibraryAssert: Codeunit "Assert";
begin
// [GIVEN] Create a valid released sales order
LibrarySales.CreateSalesOrder(SalesHeader);
// [WHEN] Run posting routine which triggers Confirm and Message dialogs
SalesPost.Run(SalesHeader);
// [THEN] Confirmation and Message dialogs are intercepted by registered handlers
end;
[ConfirmHandler]
procedure ConfirmPostHandler(Question: Text[1024]; var Reply: Boolean)
begin
// Verify the question text and answer 'Yes'
if Question.Contains('Do you want to post') then
Reply := true
else
Reply := false;
end;
[MessageHandler]
procedure InvoicePostedMessageHandler(Message: Text[1024])
var
LibraryAssert: Codeunit "Assert";
begin
LibraryAssert.IsTrue(Message.Contains('posted'), 'Unexpected posting message text received.');
end;
[!CRITICAL] Strict Handler Invocation Rule: If a test method declares a handler in
[HandlerFunctions('ConfirmPostHandler')], but the code executed under test does not invokeConfirm(), the test runner will fail the test with an unhandled handler runtime error: "The handler function ConfirmPostHandler was not called." Every declared handler MUST be invoked during test execution.
3. Testing User Interface Pages with TestPage & TestRequestPage
Automated UI testing in AL does not require a web browser or Selenium scripts. Instead, the AL platform provides TestPage and TestRequestPage data types that instantiate pages in memory, enabling full programmatic control over fields, actions, fasttabs, and validation logic.
Interacting with TestPage Objects
[Test]
procedure VerifyCustomerCardCreditLimitValidation()
var
Customer: Record Customer;
CustomerCard: TestPage "Customer Card";
LibrarySales: Codeunit "Library - Sales";
LibraryAssert: Codeunit "Assert";
begin
// [GIVEN] Create an isolated customer record
LibrarySales.CreateCustomer(Customer);
// [WHEN] Open the Customer Card in test mode and edit fields
CustomerCard.OpenEdit();
CustomerCard.GoToRecord(Customer);
// Validate field properties and assign new values
LibraryAssert.IsTrue(CustomerCard."Credit Limit (LCY)".Editable(), 'Credit Limit field must be editable.');
CustomerCard."Credit Limit (LCY)".SetValue(75000);
// Invoke Page Actions programmatically
CustomerCard.PostPaymentTerms.Invoke();
// Close the test page
CustomerCard.Close();
// [THEN] Verify the persisted value on the physical record buffer
Customer.Get(Customer."No.");
LibraryAssert.AreEqual(75000, Customer."Credit Limit (LCY)", 'Credit Limit was not saved.');
end;
TestPage Core Methods & Capabilities
- Page Lifecycle:
TestPage.OpenView(),TestPage.OpenEdit(),TestPage.OpenNew(),TestPage.Close(). - Record Navigation:
TestPage.GoToRecord(Record),TestPage.First(),TestPage.Next(),TestPage.Last(). - Field Inspection & Manipulation:
TestPage.FieldName.SetValue(Value),TestPage.FieldName.Value(),TestPage.FieldName.Editable(),TestPage.FieldName.Visible(),TestPage.FieldName.AssertEquals(ExpectedValue). - Action Execution:
TestPage.ActionName.Invoke(),TestPage.OK().Invoke(),TestPage.Cancel().Invoke(). - Subform / Part Access: Subpages on document cards (e.g.,
SalesOrder.SalesLines) can be traversed and manipulated directly viaCustomerOrder.SalesLines.Quantity.SetValue(10).
Automating Report Testing with TestRequestPage
Report request pages can be tested programmatically using TestRequestPage variables inside a [RequestPageHandler]:
[RequestPageHandler]
procedure SalesInvoiceReportRequestPageHandler(var StandardSalesInvoice: TestRequestPage "Standard Sales - Invoice")
begin
// Set request page options and filters programmatically
StandardSalesInvoice.PrintCompanyAddress.SetValue(true);
StandardSalesInvoice.SaveAsPdf('C:\Temp\Invoice.pdf');
end;
4. Assertions & Negative Testing with asserterror
In enterprise ERP applications, validating that invalid transactions are rejected with descriptive errors is just as critical as testing successful operations. In AL, negative exception testing is implemented using the asserterror keyword combined with the Assert codeunit (Codeunit 130000 "Assert").
The Assert Codeunit Reference
| Method | Purpose & Usage |
|---|---|
Assert.AreEqual(Expected, Actual, Msg) | Evaluates equality between two primitives, codes, decimals, or dates. Throws formatted error if mismatch. |
Assert.AreNotEqual(Expected, Actual, Msg) | Validates that two expressions do not evaluate to the same value. |
Assert.IsTrue(Condition, Msg) | Evaluates a Boolean condition; fails if Condition = false. |
Assert.IsFalse(Condition, Msg) | Evaluates a Boolean condition; fails if Condition = true. |
Assert.RecordIsEmpty(Record) | Validates that the filtered recordset contains zero records. |
Assert.RecordIsNotEmpty(Record) | Validates that the filtered recordset contains at least one record. |
Assert.TableIsEmpty(TableNo) | Validates that the physical SQL table contains zero rows. |
Assert.ExpectedMessage(Expected, Actual) | Validates exact or wildcard substring match on message texts. |
Assert.ExpectedError(ExpectedErrorText) | Validates that the error thrown by the preceding asserterror statement matches the expected string. |
Assert.ExpectedErrorCode(ExpectedCode) | Validates the structured error code on modern ErrorInfo exceptions. |
Negative Testing Mechanics with asserterror
- The
asserterrorStatement: Precedes a statement or procedure call expected to throw a runtime error (such asError(),TestField(),FieldError(), or validation rejections).- If the statement throws an error:
asserterrorcatches the runtime exception, suppresses transaction termination, and execution advances directly to the next AL statement. - If the statement succeeds without error:
asserterrorimmediately causes the test to fail with the error: "An error was expected, but the statement completed successfully."
- If the statement throws an error:
Assert.ExpectedError(ExpectedText): Must be invoked immediately following theasserterrorstatement to verify that the runtime error thrown matches the exact error message or error substring expected by business specifications.
[Test]
procedure VerifyBlockedCustomerCannotReleaseOrder()
var
Customer: Record Customer;
SalesHeader: Record "Sales Header";
ReleaseSalesDoc: Codeunit "Release Sales Document";
LibrarySales: Codeunit "Library - Sales";
LibraryAssert: Codeunit "Assert";
begin
// [GIVEN] Create a sales order for a customer who is blocked for all transactions
LibrarySales.CreateCustomer(Customer);
Customer.Blocked := Customer.Blocked::All;
Customer.Modify();
LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
// [WHEN] Attempt to release the sales order
// [THEN] Releasing must fail with customer blocked error
asserterror ReleaseSalesDoc.Run(SalesHeader);
LibraryAssert.ExpectedError(StrSubstNo('Customer %1 is blocked for all transactions', Customer."No."));
end;
[!IMPORTANT] Why
asserterrorinstead of[TryFunction]? In AL tests, never use[TryFunction]to catch errors in business logic. TryFunctions suppress database modifications and cannot perform database writes or transaction rollbacks. Theasserterrorstatement is specifically engineered for test codeunits to validate database validation triggers and posting routines safely.
A developer authors a test procedure decorated with [Test] and [HandlerFunctions('ConfirmPostHandler')]. During automated execution, the business logic under test completes successfully without ever calling Confirm(). What is the outcome of the test run?
A developer needs to write an automated test verifying that releasing a Sales Order with a negative total amount is rejected with the runtime error message 'Total Amount cannot be negative.'. Which AL pattern correctly implements this negative test?
A business process under test invokes the standard confirmation dialog: if Confirm('Post batch entries?', false) then PostEntries();. Which UI handler attribute and procedure signature must be implemented to intercept this dialog and return true during an automated test?
A developer needs to test custom validation on the Customer Card page without opening a graphical web browser. How can the developer open the page, enter a credit limit, and verify the editable state of the field in AL?