SOLID Testing

SOLID Testing

Anytime anyone mentions testing you will hear hundreds of different explanations of what testing is and isn't from every developer you meet. The simple idea of having tests is to have more confidence in your code's behavior. Tests ensure that the desired outcome of your code is repeatable and exactly as you intended it to be. Tests do not necessarily tell you that you do or don't have a problem, just that the unit of behavior that is under test is behaving to known specifications. The value of a test is not just the behavior of our code, but also whether your current design needs some adjustments.

Most of us have heard of and/or seen the testing pyramid but I will briefly go over it to setup the rest of our conversation. The standard pyramid has three levels: acceptance, integration, and unit. Acceptance testing is typically system level testing, these tests are probably referred to as regression and/or UI testing in most enterprise applications. Acceptance tests load the entire system and run individual tests against an installed application. The next layer is integration tests, these tests are different depending on what your application is, but to give a point of reference, individual page testing if you are in a web app. Integration tests load specific portions of your application without loading the entire system. For the most part they typically are testing some class level integration while keeping a defined boundary (e.g. faking the database). Unit level is your low level tests that typically correspond to a class or a very small section of your code. Each layer respectfully should have more tests as you move from top to bottom. Our focus for now will mostly be at the unit level, as I genuinely believe if you practice good SOLID principles, this will take you a long way before you have to focus on the other layers.

When writing any test, it's always good to setup your test in a predictable way. I typically follow AAA (Arrange, Act, Assert) pattern as it helps keep my head straight. Some people even comment those sections in code so it's clear, and I have done this myself at times. The idea is pretty straight forward: by clearly defining those areas of your test, you can keep your current test concise and direct. The class or portion of the code that you are wanting to test is your system under test (SUT). I typically will name the testing class sut so it's super clear the thing I intend to act and assert on. In your arrange section, you typically are setting up any variables that need to be done before you act on your code. The act portion should be a one liner, it's the action you are performing that you want to test. The assertion, should also be a one liner as well, and it's the result you are expecting from your test.

[Test]
public void GivenCount_WhenCountIsOne_ThenSingularContextIsReturned()
{
            // Arrange
            const int count = 1;
            
            // Act
            var actual = _sut.Create(count);

            // Assert
            Assert.That(actual, Is.EqualTo("Clicked " + count + " time"));
 }        

Above is an example from our previous articles but I added the AAA so you can clearly see the distinction of the sections of the test. We will go over the arrange portion in the next few sections as it typically deals with dependencies and can help you design your classes in a more SOLID way.

We will start by talking about your initial setup for your test. There are many ways to handle dependencies for your current class. As we discussed before, typically you are going to inject yourself with some type of interface so that it allows you to test your classes in isolation. I will pretty much approach this using a type of 'fake' known as a mock. I prefer mocks for many reasons. Probably the biggest reason is it requires the least overhead and allows me to truly not care about any implementation and focus only on the behavior of the test. We will go over the few tests we have written for our example application and explain in detail what is going on. We will dive deeper, in future articles, about SOLID and what each acronym means in each situation and how it relates to tests.

The first class we will go over is the CounterFactory. This class is implementing the factory pattern which is a pattern used to prevent the keyword "new" from being used. In this situation we are creating a string object from an integer. We will discuss the factory pattern in much more detail in the near future.

public class CounterFactory : IFactory<string, int>
{
    public string Create(int count)
    {
        var text = string.Empty;

        if (count == 1)
            text = $"Clicked {count} time";
        else if(count > 1)
            text = $"Clicked {count} times";

        return text;
    }
}        

We can see that we have three scenarios that we need to test: less than 1, 1, and greater than 1. These tests are very straight forward: in the arrange we set the count to different values, we act by creating the object, and assert that the string is what we expected. When naming your tests, you are trying to not explain implementation but more the behavior of the test. You will see in the case of 1, our Given/When/Then, we state singular context and in the case of greater than 1 plural context. This is intentional as we are trying to convey behavior not exact implementation details. If you had to make a change to the verbiage, then you would not want to also change your test name because the behavior is the same, but the implementation is different.

public class CounterFactoryTests
{
    private CounterFactory sut;

    [SetUp]
    public void Setup()
    {
        sut = new CounterFactory();
    }

    [Test]
    public void GivenCount_WhenCountIsZero_ThenEmptyStringIsReturned()
    {
        var actual = sut.Create(0);

        Assert.That(actual, Is.Empty);
    }

    [Test]
    public void GivenCount_WhenCountIsOne_ThenSingularContextIsReturned()
    {
        const int count = 1;

        var actual = sut.Create(count);

        Assert.That(actual, Is.EqualTo("Clicked " + count + " time"));
    }

    [Test]
    public void GivenCount_WhenCountIsGreaterThanOne_ThenPluralContextIsReturned()
    {
        const int count = 2;

        var actual = sut.Create(count);

        Assert.That(actual, Is.EqualTo("Clicked " + count + " times"));
    }
}        

Next class we can look at is the MainPageViewModel. This class is a standard class where it has services injected into and mocks that behavior so we can test just the business logic in the view model. The textFactory is the class mentioned above and the reader service is an adapter for whatever reader service you may or may not be using. We will dive deeper into the adapter pattern later but for now, the reader service is an abstraction level for any reader service. In this case the concrete class is implementing MAUI reader service, which is a static class that would require us to do a lot of work in order to test it. We hide that behind an interface and leave those details out of the MainPageViewModel.

public class MainPageViewModel : ObservableObject
{
    private int count;

    private string text;
    public string Text
    {
        get => text;
        set => SetProperty(ref text, value);
    }

    private readonly IFactory<string, int> textFactory;
    private readonly IReaderService<string> readerService;

    public MainPageViewModel(
        IFactory<string, int> textFactory, 
        IReaderService<string> readerService)
    {
        this.textFactory = textFactory;
        this.readerService = readerService;

        count = 0;
        Text = "Click me";
    }

    public ICommand ClickCommand => new RelayCommand(ClickCounter);

    private void ClickCounter()
    {
        count++;

        var value = textFactory.Create(count);

        Text = value;

        readerService.Read(value);
    }
}        

There are really only a few tests in this class as well, but we will go over them. Since we set the text to "click me" in the constructor, in the setup I assert that is indeed the value after every setup. The reason for this is if the state of constructor isn't what I expect, then it's reasonable that the rest of my assumptions could be wrong as well. It's also logic that I have to account for, otherwise I would have missed test coverage. Some people will write individual tests for those situations; I prefer the setup but that is entirely a personal preference. The other two test are to confirm that the text is updated and that the reader was used. Since we can control the text from the factory, we can fully control the text values and write tests for each situation independently.

public class MainPageViewModelTests
{
    private IFactory<string, int> textFactory;
    private IReaderService<string> readerService;

    private MainPageViewModel sut;

    [SetUp]
    public void SetUp()
    {
        textFactory = Substitute.For<IFactory<string, int>>();
        readerService = Substitute.For<IReaderService<string>>();

        sut = new MainPageViewModel(textFactory, readerService);

        Assert.That(sut.Text, Is.EqualTo("Click me"));
    }

    [Test]
    public void GivenCount_WhenButtonIsClicked_ThenTextIsUpdated()
    {
        var text = "A";
        textFactory.Create(1).Returns(text);

        sut.ClickCommand.Execute(null);

        Assert.That(sut.Text, Is.EqualTo(text));
    }

    [Test]
    public void GivenCount_WhenButtonIsClicked_ThenTextIsRead()
    {
        var text = "B";
        textFactory.Create(1).Returns(text);

        sut.ClickCommand.Execute(null);
        
        readerService.Received().Read(text);
    }
}        

The thing to note here is the use of mocks for both the factory and reader. By creating the factory, we can completely control what the text value is and are never dependent on the exact text creation. This allows us to only care about how the text is used, not the exact implementation. The test is written to state that, after a click, the text has been updated, and the reader should be used in that situation as well. The reader is a unique situation as there is not return value for it, so in those cases, the Received call is appropriate because making sure that the reader is used is a business directive. You typically want to avoid received calls as they can force in you into implementation details being in your test. Just like anything, it isn't a hard and fast rule but one you should be doing intentional.

We will keep writing more and more tests as we go through all the sections of the SOLID paradigm, so this won't be the last time you see tests or the last time I explain them. Tests are a crucial part of any developer's arsenal and if we build proper abstraction levels using SOLID and specific patterns, we can make it highly likely that we do not need to change our tests for any given class due to implementation changes. You saw the factory pattern and adapter pattern in play here and saw their abstraction layers. We will continue to build on those patterns as we progress in our journey.

The next article will center around refactoring and how using both SOLID and design patterns you can achieve the nice saying, "Red, green, refactor!" Refactoring without impunity is extremely important and if your test is setup to test behavior and not implementation, we should be golden.

To view or add a comment, sign in

More articles by Jeff Shafferman

  • Stardew Valley: LEAN in a nutshell

    Stardew valley is a wildly popular video game that has earned over $500 million in sales. It's success is without…

    2 Comments
  • Interfaces

    Previously we talked about how to structure an application so that the flow of information was a one way street and how…

  • SOLID Layering

    In the previous article, I introduced the idea that if your organization wants to be AGILE then your code needs to have…

  • AGILE and SOLID

    In recent years I have seen a number of developers and companies frustrated with AGILE because all it is is a process…

    1 Comment

Explore content categories