TDD & Microservices with Contract Testing
TDD implies moving in "baby steps". But when we're working with microservices and communicating with other teams, we need to do API design which necessitates some "bigger steps". How to do this?
Cycles of TDD
TDD is an incremental & iterative approach to development, whereby we move in "baby steps". A key characteristic is that tests need to be FAST. At the minute-by-minute cycle of TDD, we're writing unit tests; they're fast-running. At the hour-by-hour cycle of TDD, we're writing tests that cross architectural boundaries (integration tests); somewhat slower feedback. Read The Cycles of TDD (Uncle Bob) for more information.
TDD in Monoliths
When we have a monolithic project, it's the easiest scenario. Our unit tests cover the majority of the monolith. We'd typically have one database, no message brokers, and very few third-party dependencies (e.g., connecting to PayPal, Weather API, etc...). Here inter-service communication is almost non-existent, and integration testing is relatively straightforward. Furthermore, a developer (whether working solo or pair/mob programming) can develop a use case in TDD-way from start to end without depending on other teams.
TDD in Microservices
When we have a microservice project, we face challenges concerning the question: how do we apply TDD? We know in TDD, that we’re generally working in very short cycles. Working second-by-second, minute-by-minute for a whole use case was possible in the case of a monolith but what happens in distributed systems, when the use case is no longer owned by our team, but split across teams?
We certainly can NOT contact other teams minute-by-minute. We can work in “baby steps“ within our microservice, but not across other microservices.
So what’s the solution?
Well, in distributed systems, we need to have also somewhat “bigger steps“, in the form of “contracts” between teams. This means, if a use case is spanning multiple microservices, we need to do some upfront analysis across the microservices, determine how the use case will span the microservices, and what will be the API exposed by each microservice for the part of functionality handled by that microservice. (Here when I say “API”, it may refer to REST API, or Message structure, etc.)
This is where Contract Testing is a really useful tool. Let’s say the provider and the consumer team had an alignment meeting and agreed upon the “contract“ API. In that case, the consumer team formalizes that contract as an executable contract test (e.g., using Pact or Spring Cloud). The consumer team can then write integration tests against the contract - the test runner spins up a Mock Server, rather than connecting to the real provider microservice. Simultaneously, the provider team is implementing their microservice and using the contract test to verify that their API satisfies what was agreed upon.
Thus, combining Contract Testing & TDD:
We can develop & test our microservice in isolation from other microservices
We are applying some “bigger steps,” as there is some upfront design involved when formulating the contract; however, we still adopt the usual TDD “baby steps“ inside our microservice
TDD and Contract Testing in Hexagonal Architecture
We’ll discuss the topics above at our next Tech Excellence meetup TDD and Contract Testing in Hexagonal Architecture (Valentina Cupać).
TDD and Hexagonal Architecture help us build high-quality software products and reduce long-term maintenance costs. With TDD, our tests act as executable specifications of behavior that drive the implementation. With Clean Architecture, we can model our application core to be independent of infrastructural concerns.
In this session, we will review Hexagonal Architecture, aka Ports & Adapters, through a multi-module Java project.
The Application Core module comprises the Use Cases & the Domain. This impacts testing - we write Unit Tests which target Driver Ports within the Core, and we use Fakes for Driven Ports.
The Adapter modules implement infrastructural concerns. For each infrastructural concern, we have a fake implementation and one or more real implementations. We write Integration Tests targeting the adapters.
In Integration Testing, where does Contract Testing fit in (more specifically, Consumer Driven Contract Testing)?
When testing HTTP Driver Adapters (e.g., we wrote a microservice that exposes a REST API), then we can use contract testing to test our REST API contract, whilst mocking out the Application Core.
When testing HTTP Driven Adapters (e.g., we are integrating with third-party systems or other microservices exposing a REST API), then we need to be able to mock HTTP requests and responses
But how does TDD fit in with Contract Testing? On one hand, Contract Testing generally relies on inter-team communication; for example, microservice teams align regarding their contract, which is implemented by the consumer team and verified by the provider team. Often this process has longer communication cycles. On the other hand, TDD is an iterative and incremental approach to development, with very short feedback cycles. How do we use both TDD & Contract Testing? We’ll explore communication and incrementalism during this session.