Integration Testing composed functions by Mark Seemann
When you build a system from small functions that you subsequently compose, how do you know that the composition works? Integration testing is one option.
Despite its reputation as a niche language for scientific or finance computing, F# is a wonderful language for 'mainstream' software development. You can compose applications from small, inherently testable functions.
Once you have your functions as building blocks, you compose them. This is best done in an application's Composition Root - no different from Dependency Injection in Object-Oriented Programming.
let imp = Validate.reservation >> bind (Capacity.check 10 (SqlGateway.getReservedSeats connectionString)) >> map (SqlGateway.saveReservation connectionString)
How can you be sure that this composition is correct?
The answer to that question isn't different from its Object-Oriented counterpart. When you implement small building blocks, you can test them. Call these small building blocks units, and it should be clear that such tests are unit tests. It doesn't matter if units are (small) classes or functions.
Unit testing ought to give you confidence that each unit behaves correctly, but unit tests don't tell you how they integrate. Are there issue that only surface when units interact? Are units correctly composed?
In my experience, you can develop entire systems based exclusively on unit tests, and the final application can be stable and sturdy without the need for further testing. This depends on circumstances, though. In other cases, you need further testing to gain confidence that the application is correctly composed from its building blocks.
You can use a small set of integration tests for that.
In my Outside-In Test-Driven Development Pluralsight course, I demonstrate how to apply the GOOS approach to an HTTP API built with ASP.NET Web API. One of the techniques I describe is how to integration test the API against its HTTP boundary.
In that course, you learn how to test an API implemented in C#, but since the tests are made against the HTTP boundary, the implementation language doesn't matter. Even so, you can also write the tests themselves in F#. Here's an example that exercises the Controller that uses the above
[<Fact; UseDatabase>] let ``Post returns success`` () = use client = createClient() let json = ReservationJson.Root("2014-10-21", "Mark Seemann", "email@example.com", 4) let response = client.PostAsJsonAsync("reservations", json).Result test <@ response.IsSuccessStatusCode @>
This test creates a new HttpClient object called
client. It then creates a JSON document with some reservation data, and POSTs it to the reservations resource. Finally, it verifies that the response indicated a success.
The ReservationJson type was created from a sample JSON document using the JSON Type Provider. The createClient function is a bit more involved, but follows the same recipe I describe in my course:
let createClient () = let baseAddress = Uri "http://localhost:8765" let config = new HttpSelfHostConfiguration(baseAddress) configure connStr config config.IncludeErrorDetailPolicy <- IncludeErrorDetailPolicy.Always let server = new HttpSelfHostServer(config) let client = new HttpClient(server) client.BaseAddress <- baseAddress client
configure function is a function defined by the application implementation. Among many other things, it creates the above
imp composition. When the test passes, you can trust that
imp is correctly composed.
You may already have noticed that the ``Post returns success`` test is course-grained and vague. It doesn't attempt to make strong assertions about the posterior state of the system; if the response indicates success, the test passes.
The reason for this is that all important behaviour is already covered by unit tests.
- Is the response specifically a 201 (Created) response? Covered by unit tests.
- Does the response have a Location header indicating the address of a newly created resource? Covered by unit test.
- What happens if the input is malformed? Covered by unit tests.
- What happens if the system can't accept the request due to business rules? Covered by unit tests.
- ...and so on.
Specifically, it has been my experience that the most common integration issues are related to various configuration errors:
- Missing configuration values
- Wrong configuration values
- Network errors
- Security errors
- ... etc.
In other words: you should have a legion of unit tests covering specific behaviour, and a few integration tests covering common integration issues. You may already have recognised this principle as the Test Pyramid.
In this article, you saw an example of an integration test against an HTTP API, written in F#. The principle is universal, though. You can compose applications from units. These units can be functions written in F#, or Haskell, or Scala, or they can be classes written in C#, Java, Python, and so on. Composition can be done on functions, or on objects using Dependency Injection.
In all of these cases, you can cover the units with unit tests. Issues with composition can be screened by a few smoke tests at the integration level.