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.

  1. Interface against the system environment (Sandbox or Production)
  2. Interface against an external API
  3. 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

Super fast tests covering 100% of your code

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.