Environment Interfaces - Part 3 (Business Central)
Environment Interfaces - Part 3
The theme for this series is Super fast tests covering 100% of your code.
This 2025 series of articles is a continuation of the 2024 series called the Clean Code Initiative.
Clean Code is a condition for writing good tests, but it does not guarantee code coverage by tests.
Introduction
I will introduce three different contexts of Environment Interfaces. Discussed in part 1, 2 and 3.
- Interface against the system environment (Sandbox or Production)
- Interface against an external API
- Interface against the standard application
Scenario
The app we are building communicates with an external API.
We face several key requirements:
- The app can only be in production mode when running in a production environment. If running in a sandbox, it must operate in test mode.
- Our tests must cover all code paths, including those only reachable in production mode.
- Our tests cannot call the actual API, but we still need to verify both the requests we send and how we decode the API responses.
- Tests must be extremely fast, with runtimes measured in milliseconds.
- Writing tests should be quick and cost-effective.
- We want to test only our own code, assuming that dependencies and external systems work as expected. We assume everyone else knows what they are doing
Environment Interfaces are the solution to this apparent paradox.
Interface against Standard Business Central
In part 1 and 2, we have seen how interfaces enables us to reach code we could otherwise never reach in tests.
However, there are other important constraints: we cannot spend too much time writing tests, and we only want to test our own code.
This is why we sometimes have to develop an interface against the standard application.
Finance interface
In this case we are handling payments and refunds.
namespace MicrosoftPartner.AppName.Finance;
using Microsoft.Finance.GeneralLedger.Journal;
using Microsoft.Sales.History;
interface IFinance
{
procedure CreatePaymentLine(
var PaymentRequest: Record "Payment Request";
var Setup: Record Setup;
var GenJournalBatch: Record "Gen. Journal Batch";
var GenJournalLine: Record "Gen. Journal Line";
var GenJournalTemplate: Record "Gen. Journal Template";
var PostedSalesInvoiceHeader: Record "Sales Invoice Header")
procedure CreateRefundLine(
var RefundRequest: Record "Refund Request";
var Setup: Record Setup;
var GenJournalBatch: Record "Gen. Journal Batch";
var GenJournalLine: Record "Gen. Journal Line";
var GenJournalTemplate: Record "Gen. Journal Template";
var SalesCrMemoHeader: Record "Sales Cr.Memo Header")
}
Implementing the App version of the interface
The implementation is as you would expect. It is the code you would normally have written. We are inserting a GenJournalLine and we are using .Validate to ensure that the line is created with all the logic on the fields
codeunit 50019 "App Finance" implements IFinance
{
procedure CreatePaymentLine(...)
begin
GenJournalLine.Init();
GenJournalLine.Validate("Journal Template Name", GenJournalTemplate.Name);
GenJournalLine.Validate("Journal Batch Name", GenJournalBatch.Name);
GenJournalLine.Validate("Document Type", Enum::"Gen. Journal Document Type"::Payment);
GenJournalLine.Validate("Document No.", PaymentRequest."Request Reference");
...
GenJournalLine.Insert(true);
end;
procedure CreateRefundLine(...)
begin
GenJournalLine.Init();
...
GenJournalLine.Insert(true);
end;
}
Implementing the Test App version of the interface
This is where it becomes interesting.
Stub Finance
The stub function simply returns what I have asked it to return. This is how I control which part of my code is touched.
namespace MicrosoftPartner.AppName.Test.Helpers;
using Microsoft.Finance.GeneralLedger.Journal;
using Microsoft.Sales.History;
codeunit 50120 "Stub Finance" implements IFinance
{
Description = 'Stub Finance';
var
SetupVar: Record Setup;
GenJournalBatchVar: Record "Gen. Journal Batch";
GenJournalLineVar: Record "Gen. Journal Line";
GenJournalTemplateVar: Record "Gen. Journal Template";
PaymentRequestVar: Record "Payment Request";
RefundRequestVar: Record "Refund Request";
PostedSalesCrMemoHeaderVar: Record "Sales Cr.Memo Header";
PostedSalesInvoiceHeaderVar: Record "Sales Invoice Header";
internal procedure CreatePaymentLine(var PaymentRequest: Record "Payment Request"; var Setup: Record Setup; var GenJournalBatch: Record "Gen. Journal Batch"; var GenJournalLine: Record "Gen. Journal Line"; var GenJournalTemplate: Record "Gen. Journal Template"; var PostedSalesInvoiceHeader: Record "Sales Invoice Header")
begin
PaymentRequest := this.PaymentRequestVar;
Setup := this.SetupVar;
GenJournalBatch := this.GenJournalBatchVar;
GenJournalLine := this.GenJournalLineVar;
GenJournalTemplate := this.GenJournalTemplateVar;
PostedSalesInvoiceHeader := this.PostedSalesInvoiceHeaderVar;
end;
internal procedure SetupPaymentResponse(var PaymentRequest: Record "Payment Request"; var Setup: Record Setup; var GenJournalBatch: Record "Gen. Journal Batch"; var GenJournalLine: Record "Gen. Journal Line"; var GenJournalTemplate: Record "Gen. Journal Template"; var PostedSalesInvoiceHeader: Record "Sales Invoice Header")
begin
this.PaymentRequestVar := PaymentRequest;
this.SetupVar := Setup;
this.GenJournalBatchVar := GenJournalBatch;
this.GenJournalLineVar := GenJournalLine;
this.GenJournalTemplateVar := GenJournalTemplate;
this.PostedSalesInvoiceHeaderVar := PostedSalesInvoiceHeader;
end;
internal procedure CreateRefundLine(var RefundRequest: Record "Refund Request"; var Setup: Record Setup; var GenJournalBatch: Record "Gen. Journal Batch"; var GenJournalLine: Record "Gen. Journal Line"; var GenJournalTemplate: Record "Gen. Journal Template"; var SalesCrMemoHeader: Record "Sales Cr.Memo Header")
begin
RefundRequest := this.RefundRequestVar;
Setup := this.SetupVar;
GenJournalBatch := this.GenJournalBatchVar;
GenJournalLine := this.GenJournalLineVar;
GenJournalTemplate := this.GenJournalTemplateVar;
SalesCrMemoHeader := this.PostedSalesCrMemoHeaderVar;
end;
internal procedure SetupRefundResponse(var RefundRequest: Record "Refund Request"; var Setup: Record Setup; var GenJournalBatch: Record "Gen. Journal Batch"; var GenJournalLine: Record "Gen. Journal Line"; var GenJournalTemplate: Record "Gen. Journal Template"; var SalesCrMemoHeader: Record "Sales Cr.Memo Header")
begin
this.RefundRequestVar := RefundRequest;
this.SetupVar := Setup;
this.GenJournalBatchVar := GenJournalBatch;
this.GenJournalLineVar := GenJournalLine;
this.GenJournalTemplateVar := GenJournalTemplate;
this.PostedSalesCrMemoHeaderVar := SalesCrMemoHeader;
end;
}
Conclusion
Running logic in the base application introduces several challenges:
Setting up tests is time-consuming due to dependencies on G/L Accounts, Banks, General Journal Batch, General Journal Template, Number Series, and more — a rabbit hole that can consume significant amounts of time.
It creates a strong dependency on the base app, increasing maintenance overhead.
It is slow, as it writes to the database and rolls back changes, preventing the use of temporary variables.
By using Environment Interfaces, I have avoided these issues. My tests are fast, independent, and focused on my code. While this approach requires a clear understanding of what Business Central returns when creating a General Journal Line, it ensures that I am testing my code, not the base application itself.
You might say that this is cheating. That I am not really testing the code. And you are right in the sense that I have to understand perfectly what Business Central is returning when it creates the General Journal Line. But I am not testing that Business Central is working; I am testing that my code is working.
Clean AL Code Initiative
- Part 1: Rulesets in Business Central
- Part 2: Namespaces in AL
- Part 3: VS Code Extensions for AL
- Part 4: Automated Tests in AL
- Part 5: Advanced CodeCop Analyzer and Custom Rulesets
- Part 6: How to make a code review
- Part 7: Preconditions and TryFunctions in AL
Super fast tests covering 100% of your code
- Part 1: Environment Interfaces - Part 1 - System Environment
- Part 2: Environment Interfaces - Part 2 - External API
- Part 3: Environment Interfaces - Part 3 - Standard Application
- Part 4: Temporary Tables in Tests
London Style Terminology
Term | Description |
---|---|
Stub | A fake object that returns fixed data and does not track usage. |
Mock | A fake that also verifies how it was used (e.g., was method X called?). |
Fake | A working implementation with simplified behavior (e.g., in-memory database). |
Spy | Like a stub, but records how it was called for later verification. |