How to Write Effective Unit Tests

Elijah Koulaxis

November 7, 2023

unit-testing

Unit testing is an important part of making software because it makes sure that individual pieces of code work the way they should. But making good unit tests is more than just checking the syntax. It's important to follow some best practices when writing tests so that they are strong, easy to keep, and useful in the long run.

I will give you some writing tips for unit tests that have worked really well for me, along with a very simple example that shows how to use these tips.

Testing Behavior, Not Implementation

Instead of checking the specifics of how a piece of code is implemented, unit tests should focus on making sure that it works the way it's supposed to. If you write tests that check if a function returns the intended result, you can change the code underneath the tests without breaking them. Keeping these two things separate makes your code more flexible and easier to manage. You can be sure that your tests will still pass even if you change the way the code does something by not including tests that check how it does it.

Clear and Descriptive Test Cases

To understand what's being checked, you need test cases with clear names. Give functions names that are clear and use a given-when-then pattern. Like, "Given a certain context, when a specific action is taken, then there should be a particular outcome." With descriptive test names, it's always easy to figure out what each test case is supposed to do, even if a test fails. This helps you fix bugs and keep your test set up to date.

Single Assertion per Test

There should only be one clear assertion in each test. A test should only check one part of how the code works. When a test fails, this makes it easy to figure out what went wrong. When a test checks more than one assertion and one of them fails, it can be hard to figure out which part of the code is broken. With single statements per test, you can make your tests more specific and get to the root of problems more easily.

This is very important when integrating your tests into a CI workflow. With only one assertion per test, we can give the test function a very specific name that lets us quickly find the failing test by looking at the logs.

Isolation of Tests

It is best for unit tests to be kept separate from each other. They should not depend on outside factors or the order in which they are run. There should be a separate environment for each test, which should run its assertions and then clean up. Isolated tests make sure that if one fails, it doesn't change the results of the other tests. To keep your tests consistent and reliable, you need to isolate them.

Mocks

In addition to the above section (Isolation of Tests), it is also recommended to separate the code you are testing from outside dependencies like databases or APIs when you are doing unit testing. This is where mocks and test doubles come in. Mocks are objects that simulate the behavior of external components, while test doubles are used to replace real implementations for testing.

Test Setup Simplification

It should be as easy as possible to set up the test. Don't use the same code in more than one test. If you set up the same things over and over again in different tests, you might want to make helper functions or tools to make test data or common conditions. Use TearDown and SetUp functions that run before each test.

Example

public class Calculator
{
    private ILogger logger;

    public Calculator(ILogger logger)
    {
        this.logger = logger;
    }

    public int Add(int a, int b)
    {
        logger.Log($"Adding {a} and {b}");
        return a + b;
    }
}

public interface ILogger
{
    void Log(string message);
}

[TestFixture]
public class CalculatorTests
{
    private Calculator calculator;
    private Mock<ILogger> loggerMock;

    [SetUp]
    public void Setup()
    {
        loggerMock = new Mock<ILogger>();
        calculator = new Calculator(loggerMock.Object);
    }

    [Test]
    public void GivenTwoNumbers_WhenAddMethodIsCalled_ThenReturnSum()
    {
        // Arrange (Given)
        int a = 5;
        int b = 7;

        // Act (When)
        int result = calculator.Add(a, b);

        // Assert (Then)
        Assert.AreEqual(12, result);
    }

    [Test]
    public void GivenTwoNumbers_WhenAddMethodIsCalled_ThenLogMessage()
    {
        // Arrange (Given)
        int a = 3;
        int b = 4;

        // Act (When)
        int result = calculator.Add(a, b);

        // Assert (Then)
        loggerMock.Verify(logger => logger.Log($"Adding {a} and {b}"), Times.Once);
    }
}
  1. Testing Behavior, Not Implementation: The tests focus on the behavior of the Calculator class by verifying that it returns the correct sum. We don't test how the addition is implemented within the class.

  2. Clear and Descriptive Test Cases: The test methods are named descriptively, following the "Given-When-Then" pattern.

  3. Single Assertion per Test: Each test contains a single clear assertion.

  4. Isolation of Tests: The [SetUp] method creates a new instance of the Calculator class for each test, and the mock for the logger is also isolated for each test.

  5. Mocks: We use a mock for the ILogger interface to simulate the behavior of external dependencies (logging in this case) and keep the tests isolated.

  6. Test Setup Simplification: The setup for each test is simplified by creating a new Calculator instance and logger mock. We don't duplicate setup code across multiple tests, and each test starts with a clean environment.

In conclusion, it is important to write good unit tests to keep code sane and avoid "code surprises." Remember, check your code like you would a milk carton's expiration date. You'll be glad you did it!

Tags:
Back to Home