Prioritizing Integration Tests for Enhanced System Reliability
Written on
Chapter 1: The Essence of Testing
In my journey as a professional coder, I've embraced unit tests from the start. There's a unique satisfaction in crafting them, as they compel you to consider the perspective of other developers who will utilize your code—sometimes even your future self. Each unit test I create serves a dual purpose: verifying accuracy and evaluating the usability of the class for developers. Creating code that is not only functional but also enjoyable to work with has become increasingly vital.
However, unit tests alone do not guarantee the overall correctness of a system. They are quick and inexpensive to write, execute, and validate, but this efficiency comes with a significant limitation—they often fail to uncover bugs that may appear in a production environment.
This is because witnessing functionality in isolation under tightly controlled conditions is vastly different from observing it within a broader context, involving numerous interacting components and varied use cases. Reckless mocking of dependencies can lead to serious issues, particularly when it comes to interactions with databases.
Take a moment to examine the following unit test. It aims to confirm that saving a user via the "UserManager" functions correctly:
[Fact]
public void SaveNewUser()
{
// Arrange
var repository = Substitute.For<IUserRepository>();
var user = new User();
var sut = new UserManager(repository);
// Act
sut.SaveUser(user);
// Assert
repository.Received(1).SaveUser(Arg.Any());
}
This test is essentially a transparent, white-box assessment. It verifies that the UserManager invokes the "SaveUser" method on its collaborator, the "IUserRepository." Yet, such a unit test does very little; it lacks any real verification and fails to instill confidence in the system.
These tests may provide a false sense of security, allowing developers to meet arbitrary code coverage goals and pass quality checks on the build server, but they often mask deeper issues.
Consider a scenario where your "users" table is defined like this:
CREATE TABLE "Users" (
"Id" uuid NOT NULL,
"Name" text NOT NULL,
—and many other columns
CONSTRAINT "PK_Users" PRIMARY KEY ("Id")
);
If a user is created without a valid "Id" or "Name," it should trigger an exception rather than misleading you into thinking everything functions correctly.
This example, though simplistic, underscores the dangers of relying solely on unit tests for validation.
After years in software development, I’ve learned to carefully evaluate whether a test should remain a unit test or be elevated to an integration test when I find myself mocking dependencies.
Now, let’s compare this with a more robust integration test:
public class UserManagerShould : IClassFixture<DatabaseFixture>
{
[Fact]
public async Task SaveNewUser()
{
// Arrange
UserDbContext context = new TestDbContextFactory(fixture.ConnectionString)
.CreateDbContext(null!);
await context.Database.EnsureCreatedAsync();
await context.Users.ExecuteDeleteAsync();
var repository = new EfUserRepository(context);
var user = new User();
var sut = new UserManager(repository);
// Act
sut.SaveUser(user);
// Assert
List<User> result = context.Users
.AsNoTracking()
.ToList();
result.Should()
.HaveCount(1);}
}
This integration test accurately reflects the behavior of the system in a production-like setting. It uses a fixture and spins up a PostgreSQL Docker container specifically for the test. When the "SaveUser(user)" method is called, it performs a real insert into the database. If the user lacks a name, an error is thrown, indicating the violation of a not-null constraint.
Tests that fail reveal necessary actions. In this case, they highlight the need for additional checks before saving a user. An even better strategy would be to prevent invalid states from being created in the first place, but that’s a discussion for another time.
In conclusion, testing should instill confidence in the overall functionality of your system, not just its individual components under ideal conditions.
Integrating important class interactions can uncover potential issues that arise from how components communicate and share data. Investing time in utilizing a real database pays off significantly in the long run.
Catching integration issues during build time is far more cost-effective than addressing easily preventable problems during runtime.
Stay connected!
Get updates on similar articles by subscribing to my newsletter and check out my YouTube Channel (@Nicklas Millard). Don't forget to connect with me on LinkedIn.