Published on

Unit Testing vs Integration Testing

Table of Contents

During the start of my career, I was pretty confident about my code that it works! Probably due to the fact, that the project sizes were small (4-6 months duration) and I could fit the whole project in my head. So I always knew where to look when something goes wrong and what changes will break what part of my code.

But then I was put on a project which was larger (2+ years duration) as compared to the ones I had worked on in the past. That is where I realized the capacity of my brain and that something else is needed to verify my code changes. Because whenever I had to make a change in an existing part of my code, I was never confident enough that I had not broken anything else after the change.

So to have that confidence back, I needed some kind of safety net that will protect me if I made some bad change in the code. This is where automated testing comes in.

The basic idea in automated testing is that for the production code, you will write some more code. This code is your test code or simply tests, and when these tests are run, they verify whether the production code works correctly or not.

Now there are many types of automated testing and each serves its own purpose. But as a beginner, these types confused me or it seems they had multiple meanings. In this post, I’ll discuss two of these types, unit testing, and integration testing. Both have different flavors which cause confusion as people take them for a different forms of automated tests altogether. But we’ll go through the different flavors of each and by the end of this post, you will have clarity about what is unit testing and integration testing, and what is the difference between them.

What is Unit testing?

Software is made up of small components and these components could be functions, classes, modules, etc. You can think of these components as units and testing these units individually is unit testing.

As a developer, we are responsible for writing these tests. These tests could be written before (Test Driven Development) or after the unit is created and they are written using the regular tools/language which we use for our production code though we would need a unit testing framework dependency.

Unit tests should execute significantly faster than any other form of testing. Ideally, the whole suite of unit tests should execute under 10 seconds. This is because this is the most effective form of feedback that we get about the production code. So we should have a habit of running them frequently(after every logical change or before a commit) and this would only happen if these tests run quickly.

Unit Test Example

Suppose we have a service, let’s say PasswordStrengthChecker.

PasswordStrengthChecker.cs
class PasswordStrengthChecker
{
    // Should return true when the password has
    // length >= 8
    // at least 1 special character
    public bool IsStrong(string password) { ... }
}

This is how I would write unit tests for this service.

PasswordStrengthCheckerTests.cs
public class PasswordStrengthCheckerTests
{
    private readonly PasswordStrengthChecker sut;

    public PasswordStrengthCheckerTests()
    {
        sut = new PasswordStrengthChecker();
    }

    [Fact]
    public void ShouldReturnFalseWhenPasswordLengthIsLessThan8()
    {
        var password = "123456";
        Assert.False(sut.IsStrong(password));
    }

    [Fact]
    public void ShouldReturnFalseWhenOnlyContainsWhitespace()
    {
        var password = "          ";
        Assert.False(sut.IsStrong(password));
    }

    [Fact]
    public void ShouldReturnTrueWhenContainsSpecialCharacterAndLengthIsEqualOrGreaterThan8()
    {
        var password = "12345678@";
        Assert.True(sut.IsStrong(password));
    }
}

SUT means System Under Test.

I have used the xUnit.net framework for unit testing. Methods marked with the [Fact] attribute are considered tests and are executed by the xUnit.

In any kind of automated test, try to test one simple thing in a given test. This is what I was trying to do here as well. I am executing one simple case per method rather than verifying everything in one method. This is because when a test fails, you don’t want to start a debugging session to see why it failed, rather it should be clear from the name itself. And that would only happen if the tests are simple and verify only one thing.

Now 2 patterns/types of unit testing have emerged over the years based on how you handle the collaborators in the test.

Collaborator is nothing but a dependency that SUT needs. For example new ClassA(new ClassB()), here ClassB is a dependency of ClassA.

Solitary Unit Tests

According to the definition of unit testing, the unit should be tested in isolation. But what if the unit has some dependencies and a failure in those may fail the unit as well. Then how can we test the unit in isolation?

What we can do in this case is we can use test doubles or specifically mocked versions of the collaborators. These mocked versions will behave as we instruct them to behave and then we can test the unit in isolation.

In the above code example, we did not have a collaborator. Now let’s take another example with a collaborator.

AuthenticationService.cs
public class AuthenticationService
{
    private readonly IPasswordStrengthChecker passwordStrengthChecker;
    private readonly IPasswordHasher passwordHasher;
    private readonly IUserRepository userRepository;

    public AuthenticationService(IPasswordStrengthChecker passwordStrengthChecker, IPasswordHasher passwordHasher, IUserRepository userRepository)
    {
        this.passwordStrengthChecker = passwordStrengthChecker;
        this.passwordHasher = passwordHasher;
        this.userRepository = userRepository;
    }

    public async Task Register(string email, string password)
    {
        if (!passwordStrengthChecker.IsStrong(password))
        {
            throw new ArgumentException("Password criteria did not matched", nameof(password));
        }

        var passwordHash = passwordHasher.CreateHash(password);

        var user = new User
        {
            Email = email,
            PasswordHash = passwordHash
        };
        await userRepository.Add(user);
    }
}

Here I have a simple AuthenticationService that has 3 collaborators.

  • IPasswordStrengthChecker
  • IPasswordHasher
  • IUserRepository

Now let’s write unit tests for AuthenticationService’s Register() method.

AuthenticationServiceTests.cs
public class AuthenticationServiceTests
{
    [Fact]
    public async Task ShouldRegisterUser()
    {
        // Arrange
        var passwordStrengthCheckerMock = new Mock<IPasswordStrengthChecker>();
        passwordStrengthCheckerMock.Setup(m => m.IsStrong(It.IsAny<string>())).Returns(true);

        var passwordHasherMock = new Mock<IPasswordHasher>();
        passwordHasherMock.Setup(m => m.CreateHash(It.IsAny<string>())).Returns("hash");

        var userRepositoryMock = new Mock<IUserRepository>();

        var sut = new AuthenticationService(passwordStrengthCheckerMock.Object, passwordHasherMock.Object, userRepositoryMock.Object);

        // Act
        await sut.Register("myemail@mail.com", "12345678$");

        // Assert
        userRepositoryMock.Verify(m => m.Add(It.Is<User>(u => u.Email == "myemail@mail.com" && u.PasswordHash == "hash")), Times.Once);
    }
}

Here I have mocked every collaborator so that they behave as intended when executed by the SUT. For mocking, I have used the Moq library and I am verifying the test by checking if IUserRepository’s Add() method is called with the correct argument.

By mocking, we have ensured that if this test fails, it will be due to the AuthenticationService only.

But I don’t prefer solitary unit testing because creating and managing these mocks over time becomes exhausting and since we are using these mocks a doubt remains whether the SUT will work with the real implementation of collaborators.

Sociable Unit Tests

In sociable unit tests, we test the unit with all of its collaborators’ real implementation. If a collaborator is slow or expensive to use in a test environment only then we use a test double. For example, if a collaborator is a database, a paid service, etc.

Sociable unit tests are a source of confusion for some people because since we are connecting (integrating) multiple units together and testing them, some people take them as integration tests. The purpose of integration tests is to verify whether independently developed units work together or not. The term independently developed is the key here, for example, two independently developed units could be an application and its database. But here we are talking about different units of our own code.

Also, the term, unit testing, still makes sense here because it's still a unit that we are testing because, in software development, if you have a good design then your code will be modular and loosely coupled which means you will be combining many units most of the times to do a certain task. Look at the AuthenticationService from the above example, it’s a unit whose job is to perform authentication-related tasks and is using different units to do its job.

Now look at the same test ShouldRegisterUser() but in sociable form.

AuthenticationServiceTests.cs
public class AuthenticationServiceTests
{
    [Fact]
    public async Task ShouldRegisterUser()
    {
        // Arrange
        var userRepository = new FakeUserRepository();
        var sut = new AuthenticationService(new PasswordStrengthChecker(), new Argon2idPasswordHasher(), userRepository);

        // Act
        await sut.Register("myemail@mail.com", "12345678$");

        // Assert
        Assert.NotNull(userRepository.Get("myemail@mail.com"));
    }
}

As you can see, I used the real implementations for the collaborators except for the IUserRepository. For IUserRepository I created a fake implementation which is an in-memory implementation, that simply stores the data in a list. The reason for using a fake is that using the real implementation might slow down the unit tests.

Now the argument against sociable unit testing is the non-isolation of the unit. A failure in collaborator may fail the unit under test. But according to me, this isolation is not worth it because eventually, we’ll be connecting all the units in later stages of automated tests or during runtime, so testing them together here gives us early feedback. Moreover, the collaborators too would have unit tests so there would be less chance they would fail, even if a collaborator fails then we have discovered a new test case to add for the collaborator.

What is Integration testing?

When we develop an application it usually consists of many independent units such as a database, some 3rd party library or service, maybe a message queue, logging infrastructure, and so on. We know these units work independently but would these units work if we combined them as a whole? More specifically, do these units integrate with our application correctly and work as expected?

This is where integration testing comes into the picture. With integration testing, we verify whether the integration of various independent units works with our application or not.

Now integration testing is a confusing term because for different people it means different things. So when they talk about integration testing they usually mean one of the following things.

  1. Sociable Unit Tests - Testing the integration of our code rather than with the external units. Read more about it, above, in the Sociable Unit Tests section.
  2. Narrow Integration Tests - Testing methods of external units which we are consuming in our application.
  3. Broad Integration Tests - Test a running instance of our application by deploying it to a test environment. Generally regarded as System/End to End tests.
categorization of integration testing

Narrow Integration Tests

In Narrow integration testing, we’ll write tests for the methods/APIs of the external unit which we are consuming in our code. For example, if I have a repository that only has 2 methods, let’s say, Add() and GetById(), then I’ll write tests for these 2 methods. These tests should be executed against the real thing such as database, storage, etc.

I prefer narrow integration tests due to the scope and specificity of the test in comparison to Broad ones and provide faster feedback.

Integration Test Example

Below is an example of a narrow integration test.

EfUserRepositoryTests.cs
 public class EfUserRepositoryTests : IDisposable
{
    private readonly EfUserRepository sut;

    public EfUserRepositoryTests()
    {
        // Don’t put the connection string here.
        // Read it from a non-version-controlled source.
        var connectionString = "connection-string";
        sut = new EfUserRepository(connectionString);
    }

    [Fact]
    public async Task Add_ShouldAddAUserInTheTable()
    {
        var expectedUser = new User { Email = "myemail@mail.com", PasswordHash = "hash" };

        await sut.Add(expectedUser);

        var actualUser = await sut.Get("myemail@mail.com");
        Assert.NotNull(actualUser);
    }

    [Fact]
    public async Task Get_ShouldReturnUserWithTheGivenEmailAddress()
    {
        await sut.Add(new User { Email = "myemail@mail.com", PasswordHash = "hash" });

        var user = await sut.Get("myemail@mail.com");

        Assert.NotNull(user);
        Assert.Equal("myemail@mail.com", user.Email);
    }

    [Fact]
    public async Task Get_ShouldReturnNullWhenNoUserIsPresentWithTheGivenEmailAddress()
    {
        var user = await sut.Get("myemail@mail.com");

        Assert.Null(user);
    }

    public void Dispose()
    {
        sut.DeleteAll();
    }
}

Broad Integration Tests

All the automated tests that I have discussed were executed by the test runner like xUnit but in broad integration tests, we’ll have a running instance of the application and perform tests on that using some end-to-end testing frameworks such as SpecFlow, Selenium, etc.

These are generally regarded as End to End system tests and not integration tests. It’s better to have only a few of them on business-critical paths as they become hard to maintain over time.