Integration testing or how to sleep well at nights
Unit testing is good at checking the correctness of your code in isolation, but it’s not a panacea. Only integration tests can give us confidence that the application we develop actually works as a whole. They are also a good substitute for mocks in the cases where you can’t test important business logic without involving external dependencies.
-
Integration testing or how to sleep well at nights
Integration testing
Integration tests operate on a higher level of abstraction than unit tests. The main difference between integration and unit testing is that integration tests actually affect external dependencies.
The dependencies integration tests work with can be broken into two types: the ones that are under your control, and the ones that you don’t have control over.
Database and file system fall into the first category: you can programmatically change their internal state which makes them perfectly suitable for integration testing.
The second type represents such dependencies as email SMTP server or enterprise service bus (ESB). In most cases, you can’t just wipe out the side effects introduced by invoking an email gateway, so you still need to somehow fake these dependencies even with integration tests. However, you don’t need mocks to do that. We’ll discuss this topic in a minute.
It’s almost always a good idea to employ both unit and integration testing. The reason is that, with unit tests, you can’t be sure that different parts of your system actually work with each other correctly. Also, it’s hard to unit test business logic that don’t belong to domain objects without introducing mocks.
A single integration test cross cuts several layers of your code base at once resulting in a better return of investments per line of the test code. At the same time, integration testing is not a substitution for unit testing because they don’t provide as high granularity as unit tests do. You can’t just cover all possible edge cases with them because it would lead to significant code duplication.
A reasonable approach here is the following:
-
Employ unit testing to verify all possible cases in your domain model.
-
With integration tests, check only a single happy path per application service method. Also, if there are any edge cases which cannot be covered with unit tests, check them as well.
Integration testing example
Alright, let’s look at a concrete example of how we can apply integration testing. Below is a slightly modified version of the method from the previous post:
public HttpResponseMessage CreateCustomer(string name, string email, string city)
{
Customer existingCustomer = _repository.GetByEmail(email);
if (existingCustomer != null)
return Error("Customer with such email address already exists");
Customer customer = new Customer(name, city);
_repository.Save(customer);
if (city == "New York")
{
_emailGateway.SendSpecialGreetings(customer);
}
else
{
_emailGateway.SendRegularGreetings(customer);
}
return Ok();
}
How can integration tests help us in this situation?
First of all, they can verify that the customer was in fact saved in the database. Secondly, there’s an important business rule here: customers' emails must be unique. Integration testing can help us with that as well. Furthermore, we send different types of greeting emails depending on the city the customer’s in. That is also worth checking.
Let’s start off with testing a happy path:
[Fact]
public void Create_customer_action_creates_customer()
{
var emailGateway = new FakeEmailGateway();
var controller = new CustomerController(new CustomerRepository(), emailGateway);
controller.CreateCustomer("John Doe", "[email protected]", "Some city");
using (var db = new DB())
{
Customer customerFromDb = db.GetCustomer("[email protected]");
customerFromDb.ShouldExist()
.WithName("John Doe")
.WithEmail("[email protected]")
.WithCity("Some city")
.WithState(CustomerState.Pending);
emailGateway
.ShouldSendNumberOfEmails(1)
.WithEmail("[email protected]", "Hello regular customer!");
}
}
Note that we pass a real customer repository instance to the controller and a fake email gateway. Here, the repository is a dependency we have control over, whereas email gateway is the dependency we need to fake.
Also note the DB class and the heavy use of extension methods, such as ShouldExist, WithName and so on. The DB class is a utility class that helps gather all test-specific database interactions in a single place, and the extension methods allow us to check the customer’s properties in a narrow and readable way.
The test also verifies that an appropriate email was sent to the newly created customer. In this particular case, the email should be sent with "Hello regular customer" subject. We’ll look at the fake email gateway closer shortly.
Here’s another test:
[Fact]
public void Cannot_create_customer_with_duplicated_email()
{
CreateCustomer("[email protected]");
var controller = new CustomerController(
new CustomerRepository(),
new FakeEmailGateway());
HttpResponseMessage response = controller.CreateCustomer("John", "[email protected]", "LA");
response.ShouldBeError("Customer with such email address already exists");
}
Here we verify that no two customers can have the same email.
The two tests shown above allow us to make sure that all three layers (controllers, the domain model, and the database) work together correctly. They immediately let us know if there’s anything wrong with the database structure, object-relational mappings, or SQL queries, and thus give us a true confidence our application works as a whole.
Of course, it’s not 100% guarantee because there still could be issues with ASP.NET Web API routing or SMTP server settings. But I would say that integration testing, in conjunction with unit testing, provide us about 80% assurance possible.
Alright, and finally here’s the third integration test which verifies that New Yorkers receive a special greetings letter:
[Fact]
public void Customers_from_New_York_get_a_special_greetings_letter()
{
var emailGateway = new FakeEmailGateway();
var controller = new CustomerController(new CustomerRepository(), emailGateway);
controller.CreateCustomer("John", "[email protected]", "New York");
emailGateway
.ShouldSendNumberOfEmails(1)
.WithEmail("[email protected]", "Hello special customer!");
}
Implementing stubs for external dependencies
Now, let’s take a closer look at the fake email gateway we used in the tests above:
public class FakeEmailGateway : IEmailGateway
{
private readonly List<SentEmail> _sentEmails;
public IReadOnlyList<SentEmail> SentEmails
{
get { return _sentEmails.ToList(); }
}
public FakeEmailGateway()
{
_sentEmails = new List<SentEmail>();
}
public void SendRegularGreetings(Customer customer)
{
_sentEmails.Add(new SentEmail(customer.Email, "Hello regular customer!"));
}
public void SendSpecialGreetings(Customer customer)
{
_sentEmails.Add(new SentEmail(customer.Email, "Hello special customer!"));
}
public class SentEmail
{
public string To { get; private set; }
public string Subject { get; private set; }
public SentEmail(string to, string subject)
{
To = to;
Subject = subject;
}
}
}
As you can see, it just records recipients and subjects for all emails sent during its lifespan. We then use this information in our integration tests.
This implementation has a significant flaw which makes it no better solution than the mocks I argued against in the first article: it’s too brittle as it mimics every single method in the IEmailGateway interface. This stub would either stop compiling or report false positive every time you make a change in the interface.
Also, it makes a heavy use of the code duplication. Note that the letters' subjects are just typed in directly resulting in virtually no advantage over mocks.
A much better approach would be to extract a separate class - an email provider - with a single method to which the email gateway would resort in order to send an email. This provider then can be substituted by a stub:
Unlike the previous version, this stub is stable as it doesn’t depend on the actual emails sent by the email gateway. It also doesn’t introduce any code duplication - it just records all incoming emails as is:
public class FakeEmailProvider : IEmailProvider
{
public void Send(string to, string subject, string body)
{
_sentEmails.Add(new SentEmail(to, subject));
}
/* Other members */
}
Summary
Integration testing is a good alternative to unit testing with mocks because of two reasons:
-
Unlike unit tests with mocks, integration tests do verify that your code works with external dependencies correctly.
-
For the dependencies you don’t have control over, integration testing makes use of stubs instead of mocks. In most cases, stubs are less brittle and thus more maintainable choice. This point correlates to an old discussion about behavior vs state verification (also known as the classicist and the mockist schools). We’ll return to this topic in the following articles.
In the next post, we’ll talk about the most important TDD rule.
If you enjoyed this article, be sure to check out my Pragmatic Unit Testing Pluralsight course too.
Other articles in the series
-
Integration testing or how to sleep well at nights
Subscribe
Comments
comments powered by Disqus