You are currently viewing Testing in isolation

Testing in isolation

  • Post category:Testing
  • Post comments:14 Comments
  • Reading time:40 mins read

An AL developer gets fired from his job for writing inefficient tests. With his LinkedIn profile proudly showing off his extensive testing experience, a car manufacturer hires him to test cars. His first assignment: test the oil lamp. So he imagines a test, applying his vast experience:

// [GIVEN] A car
// [GIVEN] Enough fuel
// [GIVEN] Engine oil within operational limits
// [GIVEN] Engine runs long enough

// [WHEN] Oil level drops below operational minimum

// [THEN] The oil lamp turns on

Spoiler alert: the guy’s gonna get fired again.

I have never worked in the car industry, but I believe there must be better ways of testing the oil lamp. Like, maybe, isolating the lamp and its wiring from the rest of the car, and then stimulate the sensor to see if the lamp turns on.

Testing in isolation is a process in which each individual component, or unit of code, is tested independent from the rest of the system. In other words, each individual component is tested in isolation of other components. Even though in any complex system the components are designed to work together, testing in isolation will validate functionality of each component individually, independently.

Consider this picture:

This picture represents a typical dependency chain in Business Central. When you post a warehouse shipment for a sales order, the warehouse shipment posting routine depends on sales document posting routine, and sales document posting routine depends on item journal posting routine. Depending on setup, item journal posting routine will depend on inventory cost adjustment. There is nothing bad about this process flow or the fact that these component depend on each other.

What is bad is that the dependencies between these components are so tight that if you want to test the warehouse shipment posting process, you can’t test only the warehouse shipment posting; it will invoke sales posting, and item posting, and all other components downstream the dependency chain.

Testing in isolation is simple when you start at the end of the dependency chain. In the example above, it’s fairly easy to design tests that test only inventory cost adjustment. However, when you move upstream, things can get more complicated.

One step up, we have item journal posting routine. You can argue that you can isolate this particular one easily through setup. Fair enough. But imagine there was no setup. There are plenty other components in the base app where setup doesn’t allow you this type of isolation, I merely used this example because great majority of AL developers have done customizations along the path in my diagram, many times. So – imagine there was no setup. Testing the journal posting routine requires you to also run the cost adjustment routine, which you already tested. If cost adjustment takes only 10ms on average, every test that involves it will now run those 10ms slower. And performance is the least of your problems in fact.

The more you move up this dependency chain, the more this dependency on downstream components becomes painful during testing. Setting up tests becomes more complex and less direct and obvious, and your tests become progressively slower.

(Yes, I know that there are a bunch of events with the so-called “handled pattern” in between all these components, and you could hook into any one of them to “isolate” any component from anything that happens downstream during testing, but I hate to spoil it for you: “handled pattern” is bad at decoupling, and not just at decoupling. I am not going to say that this approach can’t be used for testing in isolation – yes, it can – but it’s just not a good approach, and I talked about that in the past and will talk about that again.)

Being able to test components in isolation from each other is at the heart of unit testing. But you can’t just test things in isolation; the system must be designed to support it. If component A depends on component B and they are tightly coupled, then you can’t test component A, without involving component B in that test. And if you are completely unconcerned with decoupling, you will end up having systems that can’t be tested in isolation. And you will also believe that it’s just fine, because your code is “simple”. To take my ridiculous metaphor from the beginning, you end up testing the oil warning lamp by running a car for years just to see if it will eventually turn on when it has to.

Or – worse – you actually won’t test it at all. When you design your car in such a way that it’s impossible to test the oil lamp in isolation, you probably won’t test the oil lamp. When the effort or testing outweighs the effort to fix the malfunction if it happens, you simply skip such test.

Funnily enough, the concept of tight vs. loose coupling and testing in isolation is quite familiar to most of developers. Especially if the example is simple enough. Imagine a self-service cash register in a supermarket that weights every item after you scan it. Take a look at this piece of code:

tableextension 50101 "Sales Line Ext" extends "Sales Line"
{
    fields
    {
        modify("Item Reference No.")
        {
            trigger OnAfterValidate()
            var
                Scale: Codeunit "Mettler Toledo";
            begin
                Rec.Validate("Gross Weight", Scale.GetWeight());
            end;
        }
    }
}

You wouldn’t test this by actually plugging the actual scale into your computer. You would decouple it somehow.

Well, nobody in their right mind would ever write this in the first place. Most developers already know better. I can hear your brains working already, you are imagining hundreds of ways of decoupling this. But this is such an obvious example. It’s funny how simple the concept of tight coupling really is when the example is obvious or ridiculous enough.

But when examples becomes less clear, less obvious, the waters become murkier.

Imagine you are implementing BC for a bureau de change. Every transaction they perform consists of three steps. First, you must check if the user is allowed to perform a specific conversion (some currencies are strictly regulated, and some are more commonly counterfeited, so they want some control here). Then, you perform the conversion. Finally, you log every transaction in a table.

Permission system is simple, the customer is happy with just defining which user can convert which currency to what currency. Logging is also simple: who, when, which currency to which other currency, how much was received, how much was given. Also, the customer is quite happy with BC standard currency conversion functionality.

The logic is so simple, and you end up with something like this:

procedure Convert(FromAmount: Decimal; FromCurrencyCode: Code[10]; ToCurrencyCode: Code[10]) Result: Decimal
var
    Permission: Record "Demo Currency Exch. Permission";
    ExchRate: Record "Currency Exchange Rate";
    ExchangeLog: Record "Demo Currency Exchange Log";
begin
    // Check permissions
    Permission.SetRange("User ID", UserID);
    Permission.SetFilter("From Currency Code", '%1|%2', '', FromCurrencyCode);
    Permission.SetFilter("To Currency Code", '%1|%2', '', ToCurrencyCode);
    if not Permission.FindFirst() then
        Error('Currency exchange is not allowed for %1 from %2 to %3.', UserId, FromCurrencyCode, ToCurrencyCode);

    // Perform conversion
    Result := ExchRate.ExchangeAmtFCYToFCY(WorkDate(), FromCurrencyCode, ToCurrencyCode, FromAmount);

    // Log operation
    ExchangeLog."Date and Time" := CurrentDateTime;
    ExchangeLog."User ID" := UserID;
    ExchangeLog."From Currency Code" := FromCurrencyCode;
    ExchangeLog."To Currency Code" := ToCurrencyCode;
    ExchangeLog."From Amount" := FromAmount;
    ExchangeLog."To Amount" := Result;
    ExchangeLog.Insert();
end;

(Please ignore the hardcoded text constant, it’s intentional to keep the example shorter and more direct.)

This gets the job done. It was simple to write. It’s simple to read. Simple to maintain, too.

But is it simple to test? Let’s see:

[Test]
procedure Test_01_ConvertCurrency_Success()
var
    CurrencyFrom, CurrencyTo : Record Currency;
    Permission: Record "Demo Currency Exch. Permission";
    ExchangeLog: Record "Demo Currency Exchange Log";
    Amount: Decimal;
begin
    // [GIVEN] Two currencies
    LibraryERM.CreateCurrency(CurrencyFrom);
    LibraryERM.CreateCurrency(CurrencyTo);

    // [GIVEN] Exchange rate between the two currencies
    LibraryERM.CreateExchangeRate(CurrencyFrom.Code, WorkDate(), 10, 10);
    LibraryERM.CreateExchangeRate(CurrencyTo.Code, WorkDate(), 0.1, 0.1);

    // [GIVEN] Permission to exchange between two currencies
    Permission."From Currency Code" := CurrencyFrom.Code;
    Permission."To Currency Code" := CurrencyTo.Code;
    Permission."User ID" := UserId();
    Permission.Insert();

    // [WHEN] Convert currency
    Amount := ConvertCurrency.Convert(1, CurrencyFrom.Code, CurrencyTo.Code);

    // [THEN] Amount is converted
    Assert.AreEqual(0.01, Amount, 'Amount is not converted correctly');

    // [THEN] Log entry is written
    ExchangeLog.FindLast();
    Assert.AreEqual(CurrencyFrom.Code, ExchangeLog."From Currency Code", 'From currency code is not correct');
    Assert.AreEqual(CurrencyTo.Code, ExchangeLog."To Currency Code", 'To currency code is not correct');
    Assert.AreEqual(1, ExchangeLog."From Amount", 'From amount is not correct');
    Assert.AreEqual(0.01, ExchangeLog."To Amount", 'To amount is not correct');
    Assert.AreEqual(UserId(), ExchangeLog."User ID", 'User ID is not correct');
end;

There are plenty of problems here. Let’s start with the most obvious ones.

First, the test is unreliable. What if my last entry in the log before my test is actually an exact match for what I tested? My test would succeed even if no log was written during test, and I wouldn’t know. A critical piece of logic could be not working, and I wouldn’t know.

Ha, that’s easy to catch, just add this:

// [GIVEN] No log entries
ExchangeLog.DeleteAll();

Well, I have seen these, too. I have plenty to say about that. For now, I’ll just ask you: How is that a given of your process? Will your users delete the log before running your code?

Yeah, there are more complicated ones, like this:

// [GIVEN] Last entry no. before the conversion
if ExchangeLog.FindLast() then
    LastEntryNo := ExchangeLog."Entry No.";

This will work, but it will also make your test more complicated.

But these are least of your testability problems. This is just to pluck the low-hanging fruit first. Let’s climb up this testability tree a bit.

The next problem our test has is how complex it is to set up. There is so much you have to write to the database, so many givens. Yeah, only five, really, you say. But did you see, did you know up front, that you will need those five to be able to test your code? More often than not, the process of writing tests is a cat-and-mouse game of figuring out which givens you need to be able to successfully run your “when”, before you can even start to “then” your assertions.

The next problem is far less obvious to spot, but here we go:

    // [THEN] Amount is converted
    Assert.AreEqual(0.01, Amount, 'Amount is not converted correctly');

What does this amount really mean? I have no clue and I wrote the test. Apparently to convert my given currency from to my given currency to with given exchange rates, this is the amount that comes out of the process, so yeah, that’s what it is. But trust me on my word, I didn’t have a faintest idea this would be the amount before I wrote this test, I expected it to be 1, but hey, I am bad at math, so what do I know.

This happens so often. People write their assertions not based on what they know the output must be, but based on what the process says the output is. And if you have no way of knowing that the output is wrong (because maybe there is a bug you didn’t detect) your test is useless.

But the biggest problem with this particular test case is that – even though it works correctly – it actually is useless. The moment you write that test, I can see you writing four more tests for combinations of no currency from, no currency to, no exchange rate from, no exchange rate to. Because yes, we need to know that, too. You know that the process should fail when those particular givens aren’t present, so you test that.

And this is the trap that’s so easy to fall into. The problem is not that you are writing a test for every failure scenario that you know the process could fail – you should absolutely test for those scenarios, especially when you are testing core functionality of your implementation. The problem is that you aren’t testing your code here, you are testing Microsoft’s code.

Yes, that’s right. Whose givens are those currencies? Whose givens are those exchange rates? Who needs them? Well, this variable does:

        ExchRate: Record "Currency Exchange Rate";

But that’s the point, this component is my dependency, I don’t need to test my dependencies. I want to test my process in isolation of its dependencies.

There is no substantial difference between Currency Exchange Rate table in this example and the Mettler Toledo codeunit in my scale example before. They are exactly the same. They are both my dependencies. And as much as I want to be able to test my sales process without plugging in the scale hardware device into my computer for testing purposes, I also don’t want to “plug in” the actual currency exchange rate “device” into my environment while testing my conversion process.

Currency Exchange Rate table is my dependency.

One of the biggest testability problems we have in this community is that our code has always been so tightly coupled that we are don’t even see that BC base app and system app are our dependencies. When you fail to see that, your tests become long lists of givens you put in place not because your process needs them, but because BC – your dependency – needs them.

It’s very easy to spot dependencies when you start asking questions like this: can I imagine that a similar component would get the same job done equally well?

My customer here could tell me that they now want to use Azure Entra ID to control permissions. They could tell me they want to use their bank API to convert amounts. They could tell me they want to log into telemetry. These kinds of changes would require me to change all my code. But they would require me to change all my tests, too.

These types of tests are called brittle tests: they are tightly coupled to implementation details of the code they are testing. A slight change in code – even such change that doesn’t break the functionality or introduce any new issues or bugs – could break my tests so I have to change the test code, too.

If I substitute Currency Exchange Rate table with an external API, for example, all my tests are just rubbish. None of the givens make any more sense. Any assertions against any amounts are just painfully obvious not to be necessary at all. I wouldn’t actually want to even send any requests to that API during tests, let alone validate any results I would get back. So why would I want to do that with Currency Exchange Rate table – what difference is there between them? There is none.

What I would ideally like to be able to is test my code in such a way that if any of the components are ever substituted I don’t need to change a single line of code in my test. I want robust tests, resilient test. In this particular case, I want to write tests in such a way that not only they validate my current business logic, but also if, in the future, I substitute Currency Exchange Rate table with an external API, I don’t have to change a single line of code in my tests. That’s what I want. And yes, that’s totally possible.

To understand how, let’s focus on what it is that I want to test. This is my process:

That’s what I want to test, that’s what my code needs to achieve.

Right now, my code achieves that with a little help of its Currency Exchange Rate dependency, like this:

However, as I already said, I could expect (or imagine, at least) that I would want to be able to substitute Currency Exchange Rate table with something else, just like this:

If you can imagine that something like this would be possible, then you must start thinking of decoupling. A traditional approach might look like this:

    var
        Setup: Record "Demo Setup";
        CurrencyExchange: Record "Currency Exchange Rate";
        BankAPI: Codeunit "Demo Bank API";
    begin
        Setup.Get();
        case Setup."Conversion Type" of
            Setup."Conversion Type"::BC:
                Result := CurrencyExchange.ExchangeAmtFCYToFCY(WorkDate(), FromCurrencyCode, ToCurrencyCode, FromAmount);
            Setup."Conversion Type"::API:
                Result := BankAPI.Convert(FromAmount, FromCurrencyCode, ToCurrencyCode);
        end;
    end;

Spoiler alert: this isn’t decoupling. This is just more coupling. Instead of one tightly coupled dependency, you now have two tightly coupled dependencies. And you still can’t test your business logic in isolation of these dependencies. Even worse, you now must choose which one of these two dependencies to use for test cases. In this case you would probably still choose to test this with Currency Exchange Rate dependency, keeping your tests as brittle as they were before.

It may sound a little bit too narrow to you, but the only way to truly decouple components on code level is through abstraction. And in AL, the only clean way to achieve abstraction is through interfaces. So I would start by defining an interface, like this:

interface ICurrencyConverter
{
    procedure Convert(AtDate: Date; FromCurrencyCode: Code[10]; ToCurrencyCode: Code[10]; Amount: Decimal): Decimal;
}

Then, I can implement that interface in a codeunit of its own:

codeunit 50113 "BC Currency Converter" implements ICurrencyConverter
{
    procedure Convert(AtDate: Date; FromCurrencyCode: Code[10]; ToCurrencyCode: Code[10]; Amount: Decimal): Decimal
    var
        ExchRate: Record "Currency Exchange Rate";
    begin
        exit(ExchRate.ExchangeAmtFCYToFCY(AtDate, FromCurrencyCode, ToCurrencyCode, Amount));
    end;
}

Then, I can change how my my currency conversion dependency is coupled:

    procedure Convert(FromAmount: Decimal; FromCurrencyCode: Code[10]; ToCurrencyCode: Code[10]) Result: Decimal
    var
        Permission: Record "Demo Currency Exch. Permission";
        Converter: Interface ICurrencyConverter;
        ExchangeLog: Record "Demo Currency Exchange Log";
    begin
        // Check permissions
        Permission.SetRange("User ID", UserID);
        Permission.SetFilter("From Currency Code", '%1|%2', '', FromCurrencyCode);
        Permission.SetFilter("To Currency Code", '%1|%2', '', ToCurrencyCode);
        if not Permission.FindFirst() then
            Error('Currency exchange is not allowed for %1 from %2 to %3.', UserId, FromCurrencyCode, ToCurrencyCode);

        // Perform conversion
        Result := Converter.Convert(WorkDate(), FromCurrencyCode, ToCurrencyCode, FromAmount);

        // Log operation
        ExchangeLog."Date and Time" := CurrentDateTime;
        ExchangeLog."User ID" := UserID;
        ExchangeLog."From Currency Code" := FromCurrencyCode;
        ExchangeLog."To Currency Code" := ToCurrencyCode;
        ExchangeLog."From Amount" := FromAmount;
        ExchangeLog."To Amount" := Result;
        ExchangeLog.Insert();
    end;

Obviously, this can’t just work. Interface variables are not automatically initialized. You cannot just call Converter.Convert here because we need to assign, at runtime, the actual implementation we want to use.

Thankfully, I don’t need to invent any wheels here. There is a concept called dependency injection:

    procedure Convert(FromAmount: Decimal; FromCurrencyCode: Code[10]; ToCurrencyCode: Code[10]; Converter: Interface ICurrencyConverter) Result: Decimal
    var
        Permission: Record "Demo Currency Exch. Permission";
        ExchangeLog: Record "Demo Currency Exchange Log";
    begin
        // Check permissions
        Permission.SetRange("User ID", UserID);
        Permission.SetFilter("From Currency Code", '%1|%2', '', FromCurrencyCode);
        Permission.SetFilter("To Currency Code", '%1|%2', '', ToCurrencyCode);
        if not Permission.FindFirst() then
            Error('Currency exchange is not allowed for %1 from %2 to %3.', UserId, FromCurrencyCode, ToCurrencyCode);

        // Perform conversion
        Result := Converter.Convert(WorkDate(), FromCurrencyCode, ToCurrencyCode, FromAmount);

        // Log operation
        ExchangeLog."Date and Time" := CurrentDateTime;
        ExchangeLog."User ID" := UserID;
        ExchangeLog."From Currency Code" := FromCurrencyCode;
        ExchangeLog."To Currency Code" := ToCurrencyCode;
        ExchangeLog."From Amount" := FromAmount;
        ExchangeLog."To Amount" := Result;
        ExchangeLog.Insert();
    end;

This is the simplest way of applying the dependency inversion principle. Now my code can perform any currency conversion, without being concerned at all about how exactly it’s doing that. That’s not my code’s concern. Currency converter dependency is concerned with implementation details of currency conversion, and my code is concerned with checking permission, calling conversion, then logging the results.

My code is now structured like this:

Yes, yes, I hear you. The change I did is suddenly a breaking change for everything. Any place in code that calls the Convert function, including any tests that call it, now don’t compile anymore. I’ve just broken them.

But it’s surprisingly easy to fix. Just declare another function with the same name, but a different signature. In other words, an overload:

    procedure Convert(FromAmount: Decimal; FromCurrencyCode: Code[10]; ToCurrencyCode: Code[10]) Result: Decimal
    var
        BCConverter: Codeunit "BC Currency Converter";
    begin
        Result := Convert(FromAmount, FromCurrencyCode, ToCurrencyCode, BCConverter);
    end;

Okay, so now nothing is broken. Everything works fine again. Especially when there is a single implementation of a dependency you abstracted through an interface, this simple trick will allow me to decouple my code from actual implementation without introducing any breaking changes.

For my existing code, this doesn’t mean much. But for my tests, this means everything. Since I abstracted my conversion logic through an interface, and since I decoupled my code from actual implementations so it doesn’t care any longer about how exactly conversion is performed, I can now do this:

So I create a dummy converter:

codeunit 50147 "Dummy Currency Converter" implements ICurrencyConverter
{
    procedure Convert(AtDate: Date; FromCurrencyCode: Code[10]; ToCurrencyCode: Code[10]; Amount: Decimal): Decimal;
    begin
        // Does nothing
    end;
}

And my test is suddenly a lot simpler:

[Test]
procedure Test_01_ConvertCurrency_Success_01()
var
    Permission: Record "Demo Currency Exch. Permission";
    ExchangeLog: Record "Demo Currency Exchange Log";
    Converter: Codeunit "Dummy Currency Converter";
    Amount: Decimal;
begin
    // [GIVEN] Permission to exchange between two currencies
    Permission."From Currency Code" := 'DUMMY_FROM';
    Permission."To Currency Code" := 'DUMMY_TO';
    Permission."User ID" := UserId();
    Permission.Insert();

    // [WHEN] Convert currency
    Amount := ConvertCurrency.Convert(1, 'DUMMY_FROM', 'DUMMY_TO', Converter);

    // [THEN] Log entry is written
    ExchangeLog.FindLast();
    Assert.AreEqual('DUMMY_FROM', ExchangeLog."From Currency Code", 'From currency code is not correct');
    Assert.AreEqual('DUMMY_TO', ExchangeLog."To Currency Code", 'To currency code is not correct');
    Assert.AreEqual(1, ExchangeLog."From Amount", 'From amount is not correct');
    Assert.AreEqual(Amount, ExchangeLog."To Amount", 'To amount is not correct');
    Assert.AreEqual(UserId(), ExchangeLog."User ID", 'User ID is not correct');
end;

I don’t need any currencies or any exchange rates in my database. The only true given I have is the permission to convert. This is what this test case is about: when there is permission, then convert and log. And this is exactly what this test validates. This test code is only concerned with that logic. There is no noise from unnecessary givens, especially those that my code doesn’t care about, that are only there because my dependencies need them.

This is what testing in isolation means. I am able to test my code in isolation of its dependency. While my code depends on another component to perform part of its job, it doesn’t depend on a specific implementation. It doesn’t care about how, it only cares about what. If I could substitute the Currency Exchange Rate table with bank API, and my code is still valid, then I can substitute it with a mock implementation, and it’s still equally valid.

But since I now have two functions, don’t I need to test the other one, that overload, too? Actually, no. I don’t want to test that one. I don’t want to test third-party dependencies myself. Either I trust them and I use them, or I don’t trust them and delegate their work to something else. In my case, that overload calls Business Central base app standard currency conversion process. This function I call (ExchangeAmtFCYToFCY) – it’s not my job to test that. That’s Microsoft’s job.

Business Central is your dependency. Base app is your dependency. System app is your dependency. Any third-party code, code that wasn’t written by you (your team included), is your dependency and you should decouple your code from those dependencies so you could test your code in isolation of those dependencies.

But this is not where the story ends. While third-party code is always your dependency, third-party code is not your only dependency. Your code can be your dependency too.

My code, for example, has two more dependencies. They are just not too obvious. Permission table is a dependency. Log is a dependency. As I mentioned before, my customer could ask me to substitute permission table with Entra ID, or log table with telemetry.

Imagine I changed my code like this:

    procedure Convert(FromAmount: Decimal; FromCurrencyCode: Code[10]; ToCurrencyCode: Code[10]; Converter: Interface ICurrencyConverter) Result: Decimal
    var
        Permission: Record "Demo Currency Exch. Permission";
        ExchangeLog: Record "Demo Currency Exchange Log";
        Dimensions: Dictionary of [Text, Text];
    begin
        // Check permissions
        Permission.SetRange("User ID", UserID);
        Permission.SetFilter("From Currency Code", '%1|%2', '', FromCurrencyCode);
        Permission.SetFilter("To Currency Code", '%1|%2', '', ToCurrencyCode);
        if not Permission.FindFirst() then
            Error('Currency exchange is not allowed for %1 from %2 to %3.', UserId, FromCurrencyCode, ToCurrencyCode);

        // Perform conversion
        Result := Converter.Convert(WorkDate(), FromCurrencyCode, ToCurrencyCode, FromAmount);

        // Log operation
        Session.LogMessage(
            'CURRENCY_EXCHANGE', 
            StrSubstNo('User %1 exchanged %2 %3 to %4 %5.', UserId, FromAmount, FromCurrencyCode, Result, ToCurrencyCode), 
            Verbosity::Normal, 
            DataClassification::SystemMetadata, 
            TelemetryScope::All,
            Dimensions);
    end;

Obviously, this breaks my test. Nothing is written to the log table, so there is nothing for me to assert there:

[Test]
procedure Test_01_ConvertCurrency_Success_01()
var
    Permission: Record "Demo Currency Exch. Permission";
    ExchangeLog: Record "Demo Currency Exchange Log";
    Converter: Codeunit "Dummy Currency Converter";
    Amount: Decimal;
begin
    // [GIVEN] Permission to exchange between two currencies
    Permission."From Currency Code" := 'DUMMY_FROM';
    Permission."To Currency Code" := 'DUMMY_TO';
    Permission."User ID" := UserId();
    Permission.Insert();

    // [WHEN] Convert currency
    Amount := ConvertCurrency.Convert(1, 'DUMMY_FROM', 'DUMMY_TO', Converter);

    // [THEN] Log entry is written
    // ...
    // How do I test if it was written to telemetry? 🤔 Hm... 🤷‍♂️
end;

You could say that I don’t want to test that. Why (and how?) would I test that message written to telemetry was correct? You could say that I shouldn’t care. And I could agree.

True, I don’t care about what’s written to telemetry. My process doesn’t care. It doesn’t care how the conversion operation was logged, but it does care that it was logged.

I mean, imagine that I drop this checking of telemetry. My test would look like this:

[Test]
procedure Test_01_ConvertCurrency_Success_01()
var
    Permission: Record "Demo Currency Exch. Permission";
    ExchangeLog: Record "Demo Currency Exchange Log";
    Converter: Codeunit "Dummy Currency Converter";
    Amount: Decimal;
begin
    // [GIVEN] Permission to exchange between two currencies
    Permission."From Currency Code" := 'DUMMY_FROM';
    Permission."To Currency Code" := 'DUMMY_TO';
    Permission."User ID" := UserId();
    Permission.Insert();

    // [WHEN] Convert currency
    Amount := ConvertCurrency.Convert(1, 'DUMMY_FROM', 'DUMMY_TO', Converter);
end;

But what is my “then”, then? You could argue that the fact that there was no error, that I didn’t have to asserterror against my Convert call, is my “then”. And you would be right. But if I just leave my test as-is, then it’s not testing everything.

Remember, my process was this: check permission -> convert -> log. That’s what my customer requires. Logging is a critical part here, my customer requires this log, because maybe because of regulatory requirements. I don’t care why, but my customer needs the log.

If I leave my test without asserting anything against the log, then somebody could change the code like this:

procedure Convert(FromAmount: Decimal; FromCurrencyCode: Code[10]; ToCurrencyCode: Code[10]; Converter: Interface ICurrencyConverter) Result: Decimal
var
    Permission: Record "Demo Currency Exch. Permission";
    ExchangeLog: Record "Demo Currency Exchange Log";
    Dimensions: Dictionary of [Text, Text];
begin
    // Check permissions
    Permission.SetRange("User ID", UserID);
    Permission.SetFilter("From Currency Code", '%1|%2', '', FromCurrencyCode);
    Permission.SetFilter("To Currency Code", '%1|%2', '', ToCurrencyCode);
    if not Permission.FindFirst() then
        Error('Currency exchange is not allowed for %1 from %2 to %3.', UserId, FromCurrencyCode, ToCurrencyCode);

    // Perform conversion
    Result := Converter.Convert(WorkDate(), FromCurrencyCode, ToCurrencyCode, FromAmount);
end;

This code no longer satisfies the requirement, but my test doesn’t tell me that. Yes, maybe I want to test in isolation of my dependencies, but this was a little bit too much of isolation.

You see, logging is also my dependency. Any code that only uses logging should treat logging as its dependency, it should not be tightly coupled to it, but loosely coupled. I should allow my code to be tested in isolation of logging, but at the same time I want to know if logging was invoked, or if it was invoked with correct context.

I have another component: permission system. It’s a component on its own, but my conversion process uses it as a dependency.

So let’s decouple those two components as well.

First step, abstraction:

interface IPermissionChecker
{
    procedure CanConvert(FromCurrencyCode: Code[20]; ToCurrencyCode: Code[20]; User: Text[50]): Boolean;
}

interface ILogger
{
    procedure Log(FromCurrencyCode: Code[10]; ToCurrencyCode: Code[10]; FromAmount: Decimal; ToAmount: Decimal; User: Text[50]);
}

And then, implementation:

codeunit 50111 "Database Permission Checker" implements IPermissionChecker
{
    procedure CanConvert(FromCurrencyCode: Code[20]; ToCurrencyCode: Code[20]; User: Text[50]): Boolean;
    var
        Permission: Record "Demo Currency Exch. Permission";
    begin
        Permission.SetRange("User ID", User);
        Permission.SetFilter("From Currency Code", '%1|%2', '', FromCurrencyCode);
        Permission.SetFilter("To Currency Code", '%1|%2', '', ToCurrencyCode);
        exit(not Permission.IsEmpty());
    end;
}

codeunit 50112 "Database Logger" implements ILogger
{
    procedure Log(FromCurrencyCode: Code[10]; ToCurrencyCode: Code[10]; FromAmount: Decimal; ToAmount: Decimal; User: Text[50]);
    var
        ExchangeLog: Record "Demo Currency Exchange Log";
    begin
        ExchangeLog."Date and Time" := CurrentDateTime;
        ExchangeLog."User ID" := User;
        ExchangeLog."From Currency Code" := FromCurrencyCode;
        ExchangeLog."To Currency Code" := ToCurrencyCode;
        ExchangeLog."From Amount" := FromAmount;
        ExchangeLog."To Amount" := ToAmount;
        ExchangeLog.Insert();
    end;
}

Now, an important thing. I want to test these two directly. This is my code, I wrote it, I need to verify if it works as designed. So I will absolutely, totally write these tests:

    [Test]
    procedure Test_01_DatabasePermissionChecker_Success()
    var
        Permission: Record "Demo Currency Exch. Permission";
        PermissionChecker: Codeunit "Database Permission Checker";
        Result: Boolean;
    begin
        // [GIVEN] Permission to convert
        Permission."From Currency Code" := 'DUMMY_FROM';
        Permission."To Currency Code" := 'DUMMY_TO';
        Permission."User ID" := 'DUMMY';
        Permission.Insert(false);

        // [WHEN] Checking permission
        Result := PermissionChecker.CanConvert('DUMMY_FROM', 'DUMMY_TO', 'DUMMY');

        // [THEN] Conversion is allowed
        Assert.IsTrue(Result, 'Conversion is not allowed');
    end;

    [Test]
    procedure Test_02_DatabasePermissionChecker_Failure()
    var
        PermissionChecker: Codeunit "Database Permission Checker";
        Result: Boolean;
    begin
        // [GIVEN] No permission to convert

        // [WHEN] Checking permission
        Result := PermissionChecker.CanConvert('DUMMY_F01', 'DUMMY_F02', 'DUMMY_U02');

        // [THEN] Conversion is allowed
        Assert.IsFalse(Result, 'Conversion is allowed');
    end;

    [Test]
    procedure Test_03_DatabaseLogger()
    var
        Logger: Codeunit "Database Logger";
        ExchangeLog: Record "Demo Currency Exchange Log";
    begin
        // [WHEN] Logging a conversion entry
        Logger.Log('DUMMY_FROM', 'DUMMY_TO', 1, 2, 'DUMMY');

        // [THEN] Expected data is written
        ExchangeLog.FindLast();
        Assert.AreEqual('DUMMY_FROM', ExchangeLog."From Currency Code", 'From currency code is not correct');
        Assert.AreEqual('DUMMY_TO', ExchangeLog."To Currency Code", 'To currency code is not correct');
        Assert.AreEqual(1, ExchangeLog."From Amount", 'From amount is not correct');
        Assert.AreEqual(2, ExchangeLog."To Amount", 'To amount is not correct');
        Assert.AreEqual('DUMMY', ExchangeLog."User ID", 'User ID is not correct');
    end;

But that’s it. Once I have tested them, I know that they work, and that they are doing their job as designed. I don’t need to involve these two components in any of my future tests. I have tested my components directly. Any other code that I may have, code that uses these components as dependencies, I want to test in isolation of these dependencies.

This is the picture I have:

I could now create a few test doubles like this:

codeunit 50144 "Mock Permission Checker" implements IPermissionChecker
{
    var
        _allowed: Boolean;

    procedure CanConvert(FromCurrencyCode: Code[20]; ToCurrencyCode: Code[20]; User: Text[50]): Boolean;
    begin
        exit(_allowed);
    end;

    procedure SetAllowed(Allowed: Boolean);
    begin
        _allowed := Allowed;
    end;
}

codeunit 50145 "Spy Logger" implements ILogger
{
    var
        _invoked: Boolean;

    procedure Log(FromCurrencyCode: Code[10]; ToCurrencyCode: Code[10]; FromAmount: Decimal; ToAmount: Decimal; User: Text[50]);
    begin
        _invoked := true;
    end;

    procedure IsInvoked(): Boolean
    begin
        exit(_invoked);
    end;
}

My business logic can now be so much simpler:

    procedure Convert(FromAmount: Decimal; FromCurrencyCode: Code[10]; ToCurrencyCode: Code[10];
        PermissionChecker: Interface IPermissionChecker;
        CurrencyConverter: Interface ICurrencyConverter;
        Logger: Interface ILogger
    ) Result: Decimal
    begin
        if not PermissionChecker.CanConvert(FromCurrencyCode, ToCurrencyCode, UserId()) then
            Error('Currency exchange is not allowed for %1 from %2 to %3.', UserId, FromCurrencyCode, ToCurrencyCode);
        Result := CurrencyConverter.Convert(WorkDate(), FromCurrencyCode, ToCurrencyCode, FromAmount);
        Logger.Log(FromCurrencyCode, ToCurrencyCode, FromAmount, Result, UserId());
    end;

… but so can my tests:

[Test]
procedure Test_04_HasPermission_Success()
var
    Permission: Codeunit "Mock Permission Checker";
    DummyCurrencyConverter: Codeunit "Dummy Currency Converter";
    SpyLogger: Codeunit "Spy Logger";
begin
    // [GIVEN] Permission to convert
    Permission.SetAllowed(true);

    // [WHEN] Performing conversion
    ConvertCurrency.Convert(1, 'DUMMY_FROM', 'DUMMY_TO', Permission, DummyCurrencyConverter, SpyLogger);

    // [THEN] Logger is invoked
    Assert.IsTrue(SpyLogger.IsInvoked(), 'Logger is not invoked');
end;

[Test]
procedure Test_05_NoPermission_Failure()
var
    Permission: Codeunit "Mock Permission Checker";
    DummyCurrencyConverter: Codeunit "Dummy Currency Converter";
    SpyLogger: Codeunit "Spy Logger";
begin
    // [GIVEN] No permission to convert
    Permission.SetAllowed(false);

    // [WHEN] Attempting to performing conversion
    asserterror ConvertCurrency.Convert(1, 'DUMMY_FROM', 'DUMMY_TO', Permission, DummyCurrencyConverter, SpyLogger);

    // [THEN] Logger is not invoked
    Assert.IsFalse(SpyLogger.IsInvoked(), 'Logger is not invoked');
end;

And so I have tested my logic in complete isolation of its dependencies.

To wrap it up, I took my original, tightly coupled code, and delegated its work into three different components: permission component, conversion component, and logging component. Then I composed the my process by involving these three components as my customer requires me to: if there is no permission, then fail with error; otherwise perform the conversion and log the fact that it was performed.

Two of these extra components required testing directly (permission and logging) because I wrote those components. That was my code, so I tested it directly. One of those component (currency conversion) was not my code. I wrapped it into a component that requires no testing.

The end results is just a mountain of benefits:

  • My code is better structured
  • My code is completely decoupled from its dependencies
  • There are no breaking changes; all callers can call my code exactly like before, nothing was affected
  • My tests are robust instead of brittle: test code only needs to change if the function it tests changes; it doesn’t need to change if dependencies change
  • My tests are far simpler: no need for unnecessary “givens”
  • My tests are blazing fast: no database access is necessary (except for testing my code that writes to database, but I could even improve that, as I have shown before)

The only “downside”? Well, this dependency injection may seem unnatural to you. Well, that simply because we aren’t used to writing our code that way. For somebody who has written a ton of C# and TypeScript, this actually is the natural way of writing code. Plus, there are other patterns, other approaches to decoupling, dependency injection is just one of them

That’s it. that’s what testing in isolation means, and that’s why it’s good. Without testing in isolation there is no unit testing, and if you want to be able to unit-test your code, you’ll have to learn to write code that looks more or less like what I showed you in this post.

A note before I go: all of this could have been done with events, without interfaces, and without dependency injection. You could feel that events allow you to decouple your code from its dependencies. But unfortunately, that’s not the case. While events do allow you to isolate components during testing, events introduce risks that interfaces don’t, and I’ll dig deep, very deep into this in one of my future posts.

And last note, really the last, the fact that we can structure our code so we can properly unit-test it, doesn’t mean that we don’t need to write integration tests. We still do, and there is still an important place for integration tests. And this is also something I’ll cover in one of my future posts.

Let me know what you think. Do you think this is something that makes sense to your daily work, or is this just too far out there, nice in theory, but never going to work in practice? Let me know 😊

Vjeko

Vjeko has been writing code for living since 1995, and he has shared his knowledge and experience in presentations, articles, blogs, and elsewhere since 2002. Hopelessly curious, passionate about technology, avid language learner no matter human or computer.

This Post Has 14 Comments

  1. David Roys

    Ok, haven’t finished reading another great post. Shouldn’t Amount := ConvertCurrency.Convert(1, ‘DUMMY_FROM’, ‘DUMMY_TO’); actually be calling the overloaded procedure that takes the mock converter?

    1. Vjeko

      Yes, good catch, actually it should. The demo I usually present has code slightly differently structured, and I was copying and pasting intermediate stages. Will fix it ASAP.

  2. guidorobben

    great post. If gives me a lot of stuff to think about. These solutions are not something I use daily, so seeing this post helps me a lot.

  3. John Todd Scott

    Is there repo for this code?

    1. Vjeko

      I have some versions of this on my GitHub, but not this exact one.

    2. Vjeko

      So, here you go:
      https://github.com/vjekob/al-test-doubles/

      As I said, this is fairly old. This code has evolved as I was evolving my views on testing based on experience. I must admit I was probably rushing this too much trying to complete this post. It took me nearly a month to write it and I was focusing on how to explain concepts, was putting stuff in and out, and eventually decided to settle (again) with this currency example that I had presented at all events this year (two Directions, three Days of Knowledge, and then some small local ones) – and at the same time I tried improving it a bit, without running it constantly. Should really be more careful, giving it a couple more days would have been a better option.

      But thanks for pointing these problems out, John, I really appreciate it!

  4. Todd Scott

    So doesn’t this code violate your statement from the previous post ?
    codeunit 50112 “Database Logger” implements ILogger
    {
    procedure Log(FromCurrencyCode: Code[10]; ToCurrencyCode: Code[10]; FromAmount: Decimal; ToAmount: Decimal; User: Text[50])
    var
    ExchangeLog: Record “Demo Currency Exchange Log”;
    begin
    ExchangeLog.”Date and Time” := CurrentDateTime;
    ExchangeLog.”User ID” := User;
    ExchangeLog.”From Currency Code” := FromCurrencyCode;
    ExchangeLog.”To Currency Code” := ToCurrencyCode;
    ExchangeLog.”From Amount” := FromAmount;
    ExchangeLog.”To Amount” := ToAmount;
    ExchangeLog.Insert(true); //<– Should this be here?
    end;
    }

    “…Well, that’s the point of it all. I don’t want data access code intermingled and mish-mashed with my business logic. This is probably the most important point I am going to make in this post, but don’t, just don’t, ever, put data access code into the same function where your business logic is. No matter how simple business logic is (and in this example above, it’s ridiculously simple), don’t add that modify, or insert, or delete, next to the place where you assign values. That code does not belong together. Call me crazy if you want…”

    1. Vjeko

      Yeah, this is what happens when you copy and paste old examples 😂 I still stand by these words, and I should really fix this demo. I was rushing to complete this post and decided to use a (very) old example just to illustrate the testing in isolation stuff. I’ll fix this.

  5. Todd Scott

    In Test_05_NoPermission_Failure, shouldn’t the Assert.IsTrue be an Assert .IsFalse?

    [Test] procedure Test_05_NoPermission_Failure() var DummyCurrencyConverter: Codeunit "Dummy Currency Converter"; ConvertCurrency: Codeunit "Vejko Currency Converter - New"; Permission: Codeunit "Mock Permission Checker"; SpyLogger: Codeunit "Spy Logger"; begin // [GIVEN] No permission to convert Permission.SetAllowed(false); // [WHEN] Attempting to performing conversion asserterror ConvertCurrency.Convert(1, 'DUMMY_FROM', 'DUMMY_TO', Permission, DummyCurrencyConverter, SpyLogger); // [THEN] Logger is not invoked Assert.IsTrueSpyLogger.IsInvoked(), 'Logger is not invoked'); // shouldn't it be this? Assert.IsFalse(SpyLogger.IsInvoked(), 'Logger is invoked'); end;

    1. Vjeko

      Right. Fixing it right away.

  6. Michael

    This is a great blogpost, though it was hard work for me to read it. Maybe in future you can split the themes in multiple parts.

    1. Vjeko

      Yeah, maybe I should. My problem with blogging is that I always try to be so comprehensive. I approach it not as a “blog” (like, really, a web log, the way it was imagined) but as a knowledge base. Can’t promise this will change.

  7. David Hofschneider

    Hi Vjeko, i stumbled over your repo https://github.com/vjekob/al-power-of-interfaces and followed your instructions till the end.
    Finally i ended up with something like that for example (see below)
    I think, i didn`t forget/oversee something to refactor, but is this not “violating” against your statement “never mix business logic with database access”?. in CalculateTeamBonus we have database access (CustLedgEntry and CalcSums) and then the business logic for the calculation itself.
    If i try to test this, i have to write again, a punch of Givens because of the CustLedgEntries.
    Otherwhise setting the correct filters is may a business logic for itself.
    How would you tackle this issues here?
    Best regards
    David

    codeunit 60108 BonusCalculatorComission implements IBonusCalculator
    {

    procedure CalculateBonus(Employee: Record Employee; Setup: Record SalarySetup; Salary: Decimal; AtDate: Date): Decimal var Bonus: Decimal; SalaryCalculate: Codeunit SalaryCalculate; StartingDate: Date; EndingDate: Date; begin SalaryCalculate.GetMonthForDate(AtDate, StartingDate, EndingDate); Bonus := CalculateTeamBonus(Employee, StartingDate, EndingDate); exit(Bonus); end; local procedure CalculateTeamBonus(var Employee: Record Employee; var StartingDate: Date; var EndingDate: Date): Decimal var CustLedgEntry: Record "Cust. Ledger Entry"; Bonus: Decimal; begin CustLedgEntry.SetRange("Posting Date", StartingDate, EndingDate); CustLedgEntry.SetRange("Salesperson Code", Employee."Salespers./Purch. Code"); CustLedgEntry.SetFilter("Document Type", '%1|%2', "Gen. Journal Document Type"::Invoice, "Gen. Journal Document Type"::"Credit Memo"); CustLedgEntry.CalcSums("Profit (LCY)"); Bonus := (Employee.CommissionBonusPct / 100) * CustLedgEntry."Profit (LCY)"; exit(Bonus) end;

    }

    1. Vjeko

      Hi David,

      Sorry, I can’t support that repo here. That repo – as it says – is for attendees of the workshop that I sell and make a living on. There is a limit between free stuff I share on my blog, and stuff that feeds my family. There are more exercises to that workshop, and more code, and I can’t share it all here or there for public, and I hope you understand that.

Leave a Reply