Skip to main content

Business Central Test Codeunit Generator

Generates production-ready test codeunits and test runner codeunits for Business Central extensions. Tests existing module functionality including page navigation, codeunit operations, setup validation, and data integrity.

Overview

Test codeunits (Subtype = Test) contain test methods that verify module behavior. Each test method runs in its own database transaction (rolled back after execution by default). Test runner codeunits (Subtype = TestRunner) manage execution of test codeunits and integrate with test management frameworks.

This skill generates:

  • Test codeunits with [Test] attribute methods
  • Handler methods for UI automation ([MessageHandler], [ConfirmHandler], [PageHandler], [ModalPageHandler])
  • Test runner codeunits with OnBeforeTestRun/OnAfterTestRun triggers
  • Integration with standard BC test libraries (Library Assert, Library - Sales, Library - ERM, Library - Inventory, Library - Random)
  • Given/When/Then test naming convention
  • Test data factory patterns

Complete examples and patterns: references/test-examples.md

Prerequisites

  • AL workspace with established object ID range and prefix
  • Test project with separate app.json referencing the App project as a dependency
  • Test framework dependencies declared in test project app.json:
    • dd0be2ea-f733-4d65-bb34-a28f4624fb14 — Library Assert
    • e7320ebb-08b3-4e1e-8c4b-37ae5bb1f994 — Library Variable Storage
    • 5d86850b-0d76-4eca-bd7b-951ad998e997 — Any (test runner)
    • 9856ae4f-d1a7-46ef-89bb-6ef056398228 — System Application Test Library
  • Existing module objects (tables, pages, codeunits) to test

Test Method Types

AttributePurposeBehavior
[Test]Main test methodRuns as a test case, reported as pass/fail
[MessageHandler]Handles Message() callsReceives message text, validates with Library Assert
[ConfirmHandler]Handles Confirm() dialogsReturns reply boolean, validates question text
[PageHandler]Handles Page.Run() on non-modal pagesReceives page instance for interaction
[ModalPageHandler]Handles Page.RunModal() on modal pagesReceives page instance, simulates OK/Cancel
[HyperlinkHandler]Handles Hyperlink() callsReceives URL string
[SendNotificationHandler]Handles Notification.Send()Receives notification, validates message
[RecallNotificationHandler]Handles Notification.Recall()Receives notification being recalled
[RequestPageHandler]Handles report request pagesSets report parameters
[ReportHandler]Handles Report.Run()Receives report instance
[FilterPageHandler]Handles filter page dialogsSets filter values
[StrMenuHandler]Handles StrMenu() callsSets menu choice
[SessionSettingsHandler]Handles session settings updateControls session restart
[Normal]Helper methodRegular procedure for test setup/utilities

Quick Start — Test Codeunit

codeunit [TestID] "[Prefix] [Feature] Tests"
{
Subtype = Test;

var
Assert: Codeunit "Library Assert";
LibraryVariableStorage: Codeunit "Library Variable Storage";
IsInitialized: Boolean;

local procedure Initialize()
begin
LibraryVariableStorage.Clear();
if IsInitialized then
exit;

// One-time test setup
IsInitialized := true;
Commit();
end;

[Test]
procedure GivenSetup_WhenOpenSetupPage_ThenPageOpens()
var
SetupPage: TestPage "[Prefix] Module Setup";
begin
// [GIVEN] Module is installed
Initialize();

// [WHEN] Setup page is opened
SetupPage.OpenEdit();

// [THEN] Page opens without error
SetupPage.Close();
end;
}

Quick Start — Test Runner Codeunit

codeunit [RunnerID] "[Prefix] Test Runner"
{
Subtype = TestRunner;
TestIsolation = Codeunit;

trigger OnRun()
begin
// Run test codeunits
Codeunit.Run(Codeunit::"[Prefix] [Feature] Tests");
end;

trigger OnBeforeTestRun(
CodeunitId: Integer;
CodeunitName: Text;
FunctionName: Text;
Permissions: TestPermissions): Boolean
begin
exit(true);
end;

trigger OnAfterTestRun(
CodeunitId: Integer;
CodeunitName: Text;
FunctionName: Text;
Permissions: TestPermissions;
IsSuccess: Boolean)
begin
end;
}

Test Naming Convention

Follow Given/When/Then pattern:

[Given<Precondition>]_When<Action>_Then<ExpectedResult>

Examples:

  • GivenValidCustomer_WhenCreatingOrder_ThenOrderIsCreated
  • GivenSetupRecord_WhenOpenSetupPage_ThenFieldsAreVisible
  • GivenBlankSetup_WhenRunInstall_ThenSetupRecordExists
  • GivenStatAccount_WhenPostJournal_ThenLedgerEntryCreated

Test Categories

1. Setup and Configuration Tests

Verify setup table initialization, default values, and page navigation:

[Test]
procedure GivenFirstInstall_WhenOpenSetup_ThenRecordCreated()
var
Setup: Record "[Prefix] Module Setup";
SetupPage: TestPage "[Prefix] Module Setup";
begin
Initialize();

// [GIVEN] No setup record
Setup.DeleteAll();

// [WHEN] Open setup page
SetupPage.OpenEdit();

// [THEN] Setup record auto-created
Assert.IsTrue(Setup.Get(), 'Setup record should be created on page open');
SetupPage.Close();
end;

2. Page Navigation Tests

Verify pages open, fields display correctly, and actions work:

[Test]
procedure GivenModule_WhenOpenListPage_ThenPageOpens()
var
ListPage: TestPage "[Prefix] Record List";
begin
Initialize();

// [WHEN] Open list page
ListPage.OpenView();

// [THEN] Page opens without error
ListPage.Close();
end;

[Test]
procedure GivenRecordExists_WhenOpenCardPage_ThenFieldsPopulated()
var
MyRecord: Record "[Prefix] My Record";
CardPage: TestPage "[Prefix] My Record Card";
begin
Initialize();

// [GIVEN] Record exists
CreateTestRecord(MyRecord);

// [WHEN] Open card for record
CardPage.OpenEdit();
CardPage.GoToRecord(MyRecord);

// [THEN] Fields are populated
Assert.AreEqual(MyRecord."No.", CardPage."No.".Value(), 'No. field should show record number');
CardPage.Close();
end;

3. Codeunit Logic Tests

Verify business logic in codeunits:

[Test]
procedure GivenValidData_WhenRunProcess_ThenResultIsCorrect()
var
ProcessMgmt: Codeunit "[Prefix] Process Management";
InputRecord: Record "[Prefix] Input Table";
ResultRecord: Record "[Prefix] Result Table";
begin
Initialize();

// [GIVEN] Valid input data
CreateTestInput(InputRecord);

// [WHEN] Run processing
ProcessMgmt.ProcessRecord(InputRecord);

// [THEN] Result record exists with correct values
ResultRecord.SetRange("Source Entry No.", InputRecord."Entry No.");
Assert.RecordIsNotEmpty(ResultRecord);
end;

4. Error Validation Tests

Verify proper error handling:

[Test]
procedure GivenInvalidData_WhenValidate_ThenErrorRaised()
var
MyRecord: Record "[Prefix] My Record";
begin
Initialize();

// [GIVEN] Record with invalid data
MyRecord.Init();

// [WHEN/THEN] Validation raises error
asserterror MyRecord.Validate("Required Field", '');
Assert.ExpectedError('Required Field must have a value');
end;

5. Event Subscriber Tests

Verify event subscribers fire and produce expected results:

[Test]
procedure GivenSubscriber_WhenEventFires_ThenHandlerExecutes()
var
SourceRecord: Record "Source Table";
ExtendedField: Record "[Prefix] Extended Table";
begin
Initialize();

// [GIVEN] Source record
CreateSourceRecord(SourceRecord);

// [WHEN] Action that triggers event
SourceRecord.Modify(true);

// [THEN] Subscriber should have updated extended data
ExtendedField.SetRange("Source No.", SourceRecord."No.");
Assert.RecordIsNotEmpty(ExtendedField);
end;

Handler Method Patterns

MessageHandler

[MessageHandler]
procedure MessageHandler(Message: Text[1024])
begin
// Option A: Verify specific message
Assert.ExpectedMessage(LibraryVariableStorage.DequeueText(), Message);

// Option B: Just suppress (do nothing)
end;

ConfirmHandler

[ConfirmHandler]
procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean)
begin
Reply := LibraryVariableStorage.DequeueBoolean();
end;

ModalPageHandler

[ModalPageHandler]
procedure ModalPageHandler(var TestPage: TestPage "Target Page")
begin
// Simulate user interaction
TestPage."Field Name".SetValue(LibraryVariableStorage.DequeueText());
TestPage.OK().Invoke();
end;

PageHandler

[PageHandler]
procedure PageHandler(var TestPage: TestPage "Target Page")
begin
// Verify page content
Assert.AreEqual(
LibraryVariableStorage.DequeueText(),
TestPage."Field Name".Value(),
'Field should match expected value');
TestPage.Close();
end;

TestPage Methods Reference

MethodPurpose
OpenNew()Open page in insert mode
OpenEdit()Open page in edit mode
OpenView()Open page in read-only mode
Close()Close the page
GoToRecord(Record)Navigate to a specific record
GoToKey(Values...)Navigate to record by primary key
First() / Last() / Next() / Previous()Navigate records on list pages
New()Insert new record on list page
"FieldName".SetValue(Value)Set a field value
"FieldName".Value()Read field display value
"FieldName".AssistEdit()Trigger AssistEdit
"FieldName".Lookup()Trigger field lookup
"FieldName".DrillDown()Trigger drilldown
"ActionName".Invoke()Run a page action
OK().Invoke()Click OK button
Cancel().Invoke()Click Cancel button

Test Runner Codeunit

Purpose

Test runner codeunits (Subtype = TestRunner) manage execution of test codeunits and integrate with test management/reporting frameworks.

Properties

PropertyValuesDescription
SubtypeTestRunnerMarks codeunit as a test runner
TestIsolationDisabled, Codeunit, FunctionControls transaction rollback scope
  • Disabled: No automatic rollback
  • Codeunit: Roll back changes after each test codeunit completes
  • Function: Roll back changes after each test method completes (most isolation)

Triggers

TriggerParametersPurpose
OnRunNoneEntry point — run test codeunits here
OnBeforeTestRunCodeunitId, CodeunitName, FunctionName, PermissionsBooleanPre-test hook; return false to skip test
OnAfterTestRunCodeunitId, CodeunitName, FunctionName, Permissions, IsSuccessPost-test hook; log results

Important: OnBeforeTestRun and OnAfterTestRun always run in their own transactions regardless of TestIsolation or TransactionModel settings.

When FunctionName is empty in OnBeforeTestRun/OnAfterTestRun, it refers to the OnRun trigger itself.

Updating an Existing Test Runner

When adding a new test codeunit to an existing test runner:

  1. Open the test runner codeunit
  2. Add Codeunit.Run(Codeunit::"[Prefix] New Test Codeunit"); in the OnRun trigger
  3. Keep consistent order (group by feature area)

Workflow

Step 1: Analyze the Module

Before generating tests, identify what to test:

  1. Search the workspace for all objects in the module (tables, pages, codeunits, reports)
  2. Read each object to understand its structure and logic
  3. Identify testable elements:
    • Page opens and navigation (card, list, API pages)
    • Setup page initialization
    • Codeunit procedures and business logic
    • Table validations and triggers
    • Event subscribers
    • Error handling paths

Step 2: Determine Test Codeunit Structure

  • One test codeunit per feature area or per source codeunit
  • Name: [Prefix] [Feature] Tests (max 30 characters)
  • ID: Next available in the test ID range

Step 3: Create Test Codeunit

  1. Create the file in the test project folder
  2. Add Subtype = Test property
  3. Declare standard test library variables (Assert, LibraryVariableStorage, etc.)
  4. Add Initialize() procedure for shared setup
  5. Create [Test] methods following Given/When/Then naming
  6. Add handler methods for any UI interactions
  7. Add helper procedures for test data creation

Step 4: Create or Update Test Runner

If no test runner exists:

  1. Create new codeunit with Subtype = TestRunner
  2. Set TestIsolation = Codeunit
  3. Add Codeunit.Run() calls for each test codeunit in OnRun
  4. Implement OnBeforeTestRun returning true
  5. Implement OnAfterTestRun (empty or with logging)

If test runner exists:

  1. Open existing test runner
  2. Add new Codeunit.Run(Codeunit::"[Prefix] New Tests"); to OnRun

Step 5: Build and Validate

  1. Build the test project to verify compilation
  2. Run tests via test runner or VS Code Test Explorer
  3. Verify all tests pass

Design Guidelines

  • One assertion per test when practical — makes failures easier to diagnose
  • Independent tests — each test should set up its own data, not depend on other tests
  • Use Initialize() pattern for expensive one-time setup shared across tests
  • Commit() after Initialize() — required so test transactions work correctly
  • Use asserterror for negative tests — validates expected errors
  • Prefer Library Assert methods over raw Error() comparisons
  • Use LibraryVariableStorage to pass values between test methods and handlers
  • Enqueue before invoke — always enqueue values in LibraryVariableStorage before triggering actions that invoke handlers
  • No hardcoded IDs — use LibraryRandom for quantities and amounts
  • Use standard libraries — prefer Library - Sales, Library - ERM for creating standard BC records
  • Clean descriptive names — test names should describe the scenario without reading the code
  • Test both positive and negative paths — verify success cases AND error handling

File Naming Convention

  • Test codeunit: [Prefix][Feature]Tests.Codeunit.al
  • Test runner: [Prefix]TestRunner.Codeunit.al

Examples:

  • BCSStatAccTests.Codeunit.al / BCSTestRunner.Codeunit.al
  • BCSSetupTests.Codeunit.al

Place in: test project src/[Feature]Tests/ or test/[Feature]/ folder.

Checklist

Before completing test codeunit generation:

  • Subtype = Test set on test codeunit
  • [Test] attribute on each test method
  • Given/When/Then naming convention followed
  • Initialize() procedure with IsInitialized guard and Commit()
  • Library Assert used for all assertions
  • Handler methods declared for any UI interactions triggered by tests
  • LibraryVariableStorage used for passing data to handlers
  • Values enqueued before actions that invoke handlers
  • Test data created within each test (independent tests)
  • Both positive and error paths tested
  • Test runner codeunit created or updated with new test codeunit
  • TestIsolation = Codeunit set on test runner
  • OnBeforeTestRun returns true
  • File follows naming convention

External References