Environment Interfaces - Part 1 (Business Central)
Environment Interfaces - Part 1
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 prerequisite 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 parts 1, 2, and 3.
- Interface against the system environment (Sandbox or Production)
- Interface against an external API
- Interface against the standard application
Environment interfaces usually have a single implementation within the application but can have several implementations in the test application. There is no corresponding
Enum
implementing theInterface
.
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 the system environment
You can find the code for this article on GitHub.
github.com/finnpedersenfrance/BC-Environment-Interfaces
System Environment Enum
First, we need a new Enum. If you are running BC in the cloud, there are only two options. You are either in a Sandbox or a Production environment.
enum 50004 "System Environment"
{
value(0; " ")
{
Caption = ' ', Locked = true;
}
value(1; Sandbox)
{
Caption = 'Sandbox';
}
value(2; Production)
{
Caption = 'Production';
}
}
Environment Interface
Then we need an interface.
The interface is the recipe for which functions need to be implemented.
interface IEnvironment
{
/// <summary>
/// Returns the state of the current system environment. That is either Production, Sandbox or empty.
/// </summary>
/// <param></param>
/// <returns>System Environment</returns>
procedure SystemEnvironment(): Enum "System Environment"
/// <summary>
/// Returns the name of the current company.
/// </summary>
/// <param></param>
/// <returns>Company Name</returns>
procedure ThisCompanyName(): Text[30]
/// <summary>
/// Returns the state of the current company. That is either Production or Evaluation.
/// </summary>
/// <param></param>
/// <returns>False if Production Company. True if Evaluation Company.</returns>
procedure IsEvaluationCompany(): Boolean
}
Implementing the App version of the interface
The implementation is as you would expect. It is the code you would normally have written.
codeunit 50010 "App Environment" implements IEnvironment
{
internal procedure SystemEnvironment(): Enum "System Environment"
var
EnvironmentInformation: Codeunit System.Environment."Environment Information";
begin
if EnvironmentInformation.IsProduction() then
exit(Enum::"System Environment"::Production);
if EnvironmentInformation.IsSandbox() then
exit(Enum::"System Environment"::Sandbox);
exit(Enum::"System Environment"::" ");
end;
internal procedure ThisCompanyName(): Text[30]
begin
exit(CopyStr(CompanyName(), 1, 30));
end;
internal procedure IsEvaluationCompany(): Boolean
var
Company: Record System.Environment.Company;
begin
Company.Get(CompanyName());
exit(Company."Evaluation Company");
end;
}
This also allows us to replace the booleans with an enum. Which is much nicer.
Notice that
ThisCompanyName()
also solves the CodeCop Warning AA0139 (overflow).CompanyName()
returns a Text which causes an overflow warning when you assign it to aCompany Name
field of type Text[30]. With this interface function, I have solved this everywhere I needed to useCompanyName()
.
Implementing the Test App version of the interface
This is where it becomes interesting.
Stub Production
I have written a Stub Production implementation of the environment. This allows me to fool my code into believing that it is in production.
codeunit 50102 "Stub Production Environment" implements IEnvironment
{
Description = 'Stub Production Environment';
procedure SystemEnvironment(): Enum "System Environment"
begin
exit(Enum::"System Environment"::Production);
end;
procedure ThisCompanyName(): Text[30]
begin
exit('Production Company Name');
end;
procedure IsEvaluationCompany(): Boolean
begin
exit(false);
end;
}
Take a minute to look at how elegantly this allows us to test our code as if we were live in a production environment.
Stub Test
And a Stub Test implementation of the environment.
codeunit 50103 "Stub Test Environment" implements IEnvironment
{
Description = 'Stub Test Environment';
procedure SystemEnvironment(): Enum "System Environment"
begin
exit(Enum::"System Environment"::Sandbox);
end;
procedure ThisCompanyName(): Text[30]
begin
exit('Test Company Name');
end;
procedure IsEvaluationCompany(): Boolean
begin
exit(true);
end;
}
And now we are in an evaluation company in a sandbox.
Using it
It is crusial for my application code to know if the application is Production or in a Test setup.
For this, I am using an enum
Application State.
enum 50006 "Application State"
{
Extensible = true;
value(0; " ")
{
Caption = ' ', Locked = true;
}
value(1; Test)
{
Caption = 'Test';
}
value(2; Production)
{
Caption = 'Production';
}
}
I have written a function to calculate the state of the application based on the environment and the setup. The functions are in my Setup table.
internal procedure ValidateApplicationState(Environment: Interface IEnvironment)
begin
Rec."Application State" := this.ApplicationState(Rec, Environment);
end;
internal procedure ApplicationState(var Setup: Record Setup; Environment: Interface IEnvironment): Enum "Application State"
begin
case true of
(Setup.Licensee = Environment.ThisCompanyName()) and
(Environment.SystemEnvironment() = Enum::"System Environment"::Production) and
(not Environment.IsEvaluationCompany()):
exit(Enum::"Application State"::Production);
(Setup.Licensee = Environment.ThisCompanyName()):
exit(Enum::"Application State"::Test);
else
exit(Enum::"Application State"::" ");
end;
end;
We are in production mode, only and only if
- the app is in a Production environment and
- it is not an evaluation company, and
- the Licensee matches the name of the current company are we in
We are in a test mode if
- the solution is running in a sandbox or
- the company is an evaluation company
- the Licensee still has to matche the name of the current company
An important safety feature
If someone makes a copy of the company, the company name will differ from the Licensee name in our setup, which automatically will disable the solution and prevent anyone from using the solution by mistake.
Now I can call this function in both my tests and production, reaching all the code.
Testing in the context of a test environment
Testing that the application is in test mode in a sandbox.
[Test]
internal procedure TestTestMode()
var
TempSetup: Record Setup temporary;
StubTestEnvironment: Codeunit "Stub Test Environment";
begin
// [SCENARIO #003] Application State in test
// [GIVEN] a setup with a valide licensee
// [WHEN] calling ValidateApplicationState
// [THEN] the application state should be test
this.TestHelper.InitializeSetup(TempSetup);
TempSetup.Licensee := StubTestEnvironment.ThisCompanyName();
TempSetup.ValidateApplicationState(StubTestEnvironment);
this.Assert.AreEqual(Enum::"Application State"::Test, TempSetup."Application State", 'Expected Application State to be Test.');
end;
Testing in the context of a production environment
Testing in our simulated production environment.
[Test]
internal procedure TestProductionMode()
var
TempSetup: Record Setup temporary;
StubProductionEnvironment: Codeunit "Stub Production Environment";
begin
// [SCENARIO #002] Application State in production
// [GIVEN] a setup with a valide licensee
// [WHEN] calling ValidateApplicationState
// [THEN] the application state should be prduction
this.TestHelper.InitializeSetup(TempSetup);
TempSetup.Licensee := StubProductionEnvironment.ThisCompanyName();
TempSetup.ValidateApplicationState(StubProductionEnvironment);
this.Assert.AreEqual(Enum::"Application State"::Production, TempSetup."Application State", 'Expected Application State to be Production.');
end;
Testing that the solution is disabled if there is no licensee.
[Test]
internal procedure TestNoCompanyName()
var
TempSetup: Record Setup temporary;
StubProductionEnvironment: Codeunit "Stub Production Environment";
begin
// [SCENARIO #001] No company name in disabled environment
// [GIVEN] a setup with no licensee
// [WHEN] calling ValidateApplicationState
// [THEN] the application state should be empty. I.e. disabled.
this.TestHelper.InitializeSetup(TempSetup);
TempSetup.Licensee := '';
TempSetup.ValidateApplicationState(StubProductionEnvironment);
this.Assert.AreEqual(Enum::"Application State"::" ", TempSetup."Application State", 'Expected Application State to be disabled.');
end;
The code in the App
I am not testing the following code at the highest level. I call the function from the Setup page.
trigger OnModifyRecord(): Boolean
var
AppEnvironment: Codeunit "App Environment";
begin
Rec.ValidateApplicationState(AppEnvironment);
end;
Bonus Safety feature
You can implement one more safety feature. Just to be sure that you solution is disabled in the copied company.
Subscribe to the OnAfterCopyCompanyOnAction
event.
codeunit 50031 "Event Subscribers Copy Company"
{
Description = 'Event Subscribers for Copy Company. This ensures that the copied company cannot be used by mistake.';
[EventSubscriber(ObjectType::Page, Page::Companies, OnAfterCopyCompanyOnAction, '', false, false)]
local procedure OnAfterCopyCompanyOnAction(CompanyName: Text[30])
var
Setup: Record Setup;
begin
Setup.ChangeCompany(CompanyName);
if Setup.Get() then begin
Setup.Licensee := '';
Setup."Application State" := Enum::"Application State"::" ";
Setup."Job Scheduler on Standby" := true;
Setup.Modify(false);
end;
end;
}
Conclusion
Bugs are often found in code that was never executed before. Think about it. If I had a bug in the part of my code that would only be reached in production, I would otherwise never find it during tests.
With this design, I am quite confident that my code will act as planned when put in production and my tests cover 100% of my code.
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
When writing tests in Business Central, you may come across different types of test doubles—objects that replace real dependencies in your code. The “London style” (or “mockist”) terminology is widely used in the testing community to describe these patterns. Understanding these terms will help you communicate more clearly about your test design and choose the right approach for your scenario.
Term | Description |
---|---|
Dummy | An object passed as a parameter but never used. Only exists to fill parameter lists. |
Stub | A fake object that returns fixed data and does not track usage. |
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. |
Mock | A fake that also verifies how it was used (e.g., was method X called?). |