Why can't we just turn off infrastructure?
The other day I read Testing Without Mocks: A Pattern Language by Jame Shore. The post is positively provocative, and it really made me think. The underlying idea is that we add code to production to simply "turn off" infrastructure requirements for the application.
The notion of simply switching off infrastructure got me thinking: Why don't we do that today? Wouldn't it make our lives much easier if we could just remove the requirement of having a database?... and maintaining a schema? Wouldn't it make it easier to run our services if we could just turn off its integration with other services?
Switching off infrastructure: An example
For the sake of having an example: We are making a PetService which lists pets, and for some reason, we have a view where we have to display the owner's name. So, in this scenario, a pet can have a single owner.
Let us also say we are making a quick fix and make our PetService query the OwnerService - simply lists out all the owners and maps the correct one internally. It is neither pretty nor efficient, but it will get the job done.
This is how the integration with the OwnerService might look like:
@Component
public class OwnerIntegration {
private final RestTemplate restTemplate;
private final String ownerServiceUrl;
public OwnerIntegration(
RestTemplate restTemplate, @Value("${integration.ownerServiceUrl}") String ownerServiceUrl) {
this.restTemplate = restTemplate;
this.ownerServiceUrl = ownerServiceUrl;
}
public OwnerDto[] listOwners() {
return restTemplate.exchange(ownerServiceUrl, HttpMethod.GET, null, OwnerDto[].class).getBody();
}
}
The solution here is simple enough. We take in a RestTemplate
and a URL in the constructor (injected by Spring). When listOwners
is called, we return the result of this.
So how can we simply "turn off" this requirement? After all, there is logic in the application that uses this data for its processing. So the answer is simply wrapping the call to infrastructure and stub the result in the production code.
The first challenge to consider is that RestTemplate
is an actual class. It is not an interface which we must work around. The closest interface we could use is the RestOperations
interface, which is huge. We could have used the interface, but we'd want to avoid it due to its size.
Instead, we're going to build a wrapper around the RestTemplate
:
public class HttpApiWrapper {
private final RestTemplate restTemplate;
public HttpApiWrapper(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public <T> ResponseEntity<T> exchange(
String url,
HttpMethod method,
HttpEntity<?> requestEntity,
Class<T> responseType,
Object... uriVariables) throws RestClientException {
return restTemplate.exchange(url, method, requestEntity, responseType, uriVariables);
}
}
Now we have managed to de-couple our OwnerIntegration
(and potentially others) from the concrete implementation of RestTemplate
. However, this wrapper only serves as a public API - we still need the switch that allows us to turn off the infrastructure, and to make that happen, we need an internal wrapper:
public class HttpApiWrapper {
private final RestTemplateWrapper wrapper;
private HttpApiWrapper(RestTemplateWrapper restTemplateWrapper) {
this.wrapper = restTemplateWrapper;
}
public static HttpApiWrapper create(RestTemplate restTemplate) {
return new HttpApiWrapper(new RestTemplateWrapperImpl(restTemplate));
}
public <T> ResponseEntity<T> exchange(
String url,
HttpMethod method,
HttpEntity<?> requestEntity,
Class<T> responseType,
Object... uriVariables) throws RestClientException {
return wrapper.exchange(url, method, requestEntity, responseType, uriVariables);
}
private interface RestTemplateWrapper {
<T> ResponseEntity<T> exchange(
String url,
HttpMethod method,
HttpEntity<?> requestEntity,
Class<T> responseType,
Object... uriVariables) throws RestClientException;
}
private record RestTemplateWrapperImpl(RestTemplate restTemplate) implements RestTemplateWrapper {
@Override
public <T> ResponseEntity<T> exchange(
String url,
HttpMethod method,
HttpEntity<?> requestEntity,
Class<T> responseType,
Object... uriVariables) throws RestClientException {
return restTemplate.exchange(url, method, requestEntity, responseType, uriVariables);
}
}
}
Here we've done a few things:
- We've made the constructor private so that classes that want to use this instance must go through the
create
method (we'll get more into this later). - We've created a new private interface
RestTemplateWrapper
. - We've created a new private implementation of RestTemplateWrapper
called
RestTemplateWrapperImpl`.
We've also managed to hide this complexity from the rest of the application by using private interfaces and classes. The idea is that the rest of the application should be able to use HttpApiWrapper
as if it were a RestTemplate
. That is why the method signature is identical.
Now we can add the infrastructure switch:
public class HttpApiWrapper {
private final RestTemplateWrapper wrapper;
private HttpApiWrapper(RestTemplateWrapper restTemplateWrapper) {
this.wrapper = restTemplateWrapper;
}
public static HttpApiWrapper create(RestTemplate restTemplate) {
return new HttpApiWrapper(new RestTemplateWrapperImpl(restTemplate));
}
public static <T> HttpApiWrapper createNull(T data) {
return new HttpApiWrapper(new StubbedRestTemplateWrapper<>(data));
}
public <T> ResponseEntity<T> exchange(
String url,
HttpMethod method,
HttpEntity<?> requestEntity,
Class<T> responseType,
Object... uriVariables) throws RestClientException {
return wrapper.exchange(url, method, requestEntity, responseType, uriVariables);
}
private interface RestTemplateWrapper {
<T> ResponseEntity<T> exchange(
String url,
HttpMethod method,
HttpEntity<?> requestEntity,
Class<T> responseType,
Object... uriVariables) throws RestClientException;
}
private record RestTemplateWrapperImpl(RestTemplate restTemplate) implements RestTemplateWrapper {
@Override
public <T> ResponseEntity<T> exchange(
String url,
HttpMethod method,
HttpEntity<?> requestEntity,
Class<T> responseType,
Object... uriVariables) throws RestClientException {
return restTemplate.exchange(url, method, requestEntity, responseType, uriVariables);
}
}
private record StubbedRestTemplateWrapper<D> (D data) implements RestTemplateWrapper {
@Override
public <T> ResponseEntity<T> exchange(
String url,
HttpMethod method,
HttpEntity<?> requestEntity,
Class<T> responseType,
Object... uriVariables) throws RestClientException {
if (responseType.isInstance(data.getClass())) {
throw new InvalidParameterException("Invalid return type");
}
return new ResponseEntity<>(responseType.cast(data), HttpStatusCode.valueOf(200));
}
}
}
In the article, James Shore uses the Null-object pattern, which is why we have two static methods: create
and createNull
.
We also have the StubbedRestTemplateWrapper
class, which takes in some data, allowing us to set this up with some default data if we want to.
If we now look back at our OwnerIntegration
class, it will look like this:
@Component
public class OwnerIntegration {
private final HttpApiWrapper httpApiWrapper;
private final String ownerServiceUrl;
public OwnerIntegration(
HttpApiWrapper httpApiWrapper, @Value("${integration.ownerServiceUrl}") String ownerServiceUrl) {
this.httpApiWrapper = httpApiWrapper;
this.ownerServiceUrl = ownerServiceUrl;
}
public OwnerDto[] listOwnerNameByPetId() {
return httpApiWrapper.exchange(ownerServiceUrl, HttpMethod.GET, null, OwnerDto[].class).getBody();
}
}
Overall, very little has (and needs to) change in the rest of the application to support these switches.
Since we're using Spring, we can simply say which one we want to be using based on the Spring profile:
@Configuration
public class ApplicationConfig {
private final boolean isDryRun;
public ApplicationConfig(Environment env) {
this.isDryRun = Arrays.asList(env.getActiveProfiles()).contains("dryrun");
}
@Bean
HttpApiWrapper httpApiWrapper() {
if (isDryRun) {
return HttpApiWrapper.createNull(new OwnerDto(123L, "Charlie", 2L));
}
return HttpApiWrapper.create(new RestTemplate());
}
}
Here we simply use the correct call to HttpApiWrapper
based on whether we're running with the profile "dryrun".
In fact, I have this set up for databases as well:
@Configuration
public class ApplicationConfig {
private final boolean isDryRun;
public ApplicationConfig(Environment env) {
this.isDryRun = Arrays.asList(env.getActiveProfiles()).contains("dryrun");
}
@Bean
HttpApiWrapper httpApiWrapper() {
if (isDryRun) {
return HttpApiWrapper.createNull(new OwnerDto(123L, "Charlie", 2L));
}
return HttpApiWrapper.create(new RestTemplate());
}
@Bean
public DatabaseWrapper databaseWrapper(JdbcTemplate jdbcTemplate) {
if (isDryRun) {
return DatabaseWrapper.createNull(Collections.emptyList());
}
return DatabaseWrapper.create(jdbcTemplate);
}
}
The fun thing about the DatabaseWrapper
is that it has its own standard dataset if we just insert an empty list:
public static DatabaseWrapper createNull(List<Map<String, Object>> initialData) {
//code...
if (initialData.isEmpty()) {
initialData = List.of(
new InMemoryResultSet(0,
Map.of("PetId", 1L, "PetName", "Perry", "Race", "Platypus", "OwnerId", 432L)),
new InMemoryResultSet(1,
Map.of("PetId", 2L, "PetName", "Terry", "Race", "Elephant", "OwnerId", 123L)));
}
//code...
}
If I run the application using the "dryrun" profile and query the API, I get the following result:
[
{
"petId": 1,
"petName": "Perry",
"race": "Platypus",
"nameOfOwner": null
},
{
"petId": 2,
"petName": "Terry",
"race": "Elephant",
"nameOfOwner": "Charlie"
}
]
We see that we've successfully managed to fetch data correctly, even if we have no actual external service available or any database.
Infrastructure switches makes it easier to run your application
Have you ever been in a situation where you just want to run your application? You just want to have an instance of it running so that you can set a breakpoint to see what it is doing. Or you want to step through to see how it all ties together - no matter the reason: Have you ever been in a situation where you have to create SQL scripts to inject data into a local database so that you get to test the thing you want to test?
Have you ever been in a situation where you have to set up a locally running Kafka and configure your application to work with it just to see that your application creates a message? And don't get me started on Avro schemas.
With infrastructure switches, we can simply turn off all those requirements. We can simply run our services without messing about with any of this stuff. Do you just want your service to start? Well, here you go!
In fact, I've seen places where this is such an issue that they've coupled themselves to a common test environment - which means that if a developer breaks the test environment, they also prevent everyone else's progress. They essentially halt development. I've seen places where development cannot happen unless one uses a test environment, and that environment only works because they merge production data into it.
Using infrastructure switches can be one tool to avoid such dysfunction.
Infrastructure switches makes it easier to write tests
it's no secret that I'm a person that finds a lot of value in automated testing. I find the idea of infrastructure switches very compelling, especially seen in light of automated testing.
By using infrastructure switches, we essentially remove the need for mocks entirely. We can reuse the stubs in our tests. Since these stubs basically serve as an anti-corruption layer, we also end up with tests that are easier to maintain due to the lack of mocks. Finally, infrastructure switches make it much easier to maintain tests focusing on features and behaviours. As a result, the tests become much easier to read since we no longer have to deal with mocks.
In the example above, look at the test where we use mocks (These are Spock tests, by the way):
class PetControllerSpec extends Specification {
PetController controller
def jdbcTemplate = Mock(JdbcTemplate)
def restOperations = Mock(RestOperations)
def url = "http://i-dont-exist:9999/no/really"
def setup() {
def petRepository = new PetRepository(jdbcTemplate)
def ownerIntegration = new OwnerIntegration(restOperations, url)
def petService = new PetService(petRepository, ownerIntegration)
controller = new PetController(petService)
}
def "Given that pets exist, then they should be returned in the API"() {
given:
def mockedResultSets = [[PetId: 1, PetName: "Perry", Race: "Platypus", OwnerId: 432],
[PetId: 2, PetName: "Terry", Race: "Elephant", OwnerId: 123l]]
.collect { mockResultSet(it) }
handleJdbcTemplateQuery(mockedResultSets)
restOperations.exchange(url, HttpMethod.GET, null, OwnerDto[].class) >> new ResponseEntity(new OwnerDto[]{}, HttpStatusCode.valueOf(200))
expect:
controller.getPets().size() == 2
}
def "Given that pets exist, then they should be mapped correctly"() {
given:
def mockedResultSets = [[PetId: 2, PetName: "Terry", Race: "Elephant", OwnerId: 123l]]
.collect { mockResultSet(it) }
handleJdbcTemplateQuery(mockedResultSets)
def restResponse = new OwnerDto[]{
new OwnerDto(123l, "Charlie", 2l)
}
1 * restOperations.exchange(*_) >> new ResponseEntity(restResponse, HttpStatusCode.valueOf(200))
expect:
controller.getPets()[0] == new PetDto(2l, "Terry", "Elephant", "Charlie")
}
private handleJdbcTemplateQuery(mockedResultSets) {
jdbcTemplate.query(_, _) >> { String sql, RowMapper mapper ->
mockedResultSets.withIndex().collect {
mapper.mapRow(it.v1, it.v2)
}
}
}
private ResultSet mockResultSet(petInfo) {
def resultSet = Mock(ResultSet)
resultSet.getLong('PetId') >> petInfo['PetId']
resultSet.getLong('OwnerId') >> petInfo['OwnerId']
resultSet.getString('PetName') >> petInfo['PetName']
resultSet.getString('Race') >> petInfo['Race']
resultSet
}
}
And these are the tests when we have Nullables and infrastructure switches:
class PetControllerSpec extends Specification {
PetController controller
def setup() {
def owners = [new OwnerDto(123L, "Charlie", 2L)] as OwnerDto[]
controller = new PetController(new PetService(new PetRepository(DatabaseWrapper.createNull([])),
new OwnerIntegration(
HttpApiWrapper.createNull(owners),
"http://an-url:9999")))
}
def "Given that pets exist, then they should be returned in the API"() {
expect:
controller.getPets().size() == 2
}
def "Given that pets exist, then they should be mapped correctly"() {
given:
def pet = controller.getPets().find { it.petId() == 2l }
expect:
pet == new PetDto(2l, "Terry", "Elephant", "Charlie")
}
}
What? Test code in production?
I totally get this argument - I had the same reaction myself. It feels wrong. We've been told not to mix test and production code. Some developers would refuse to make a private method into a public one even if it meant it became easier to test. So I understand this reaction when our industry has such a strong culture of not mixing test and production code.
My counterargument is that this is not test code. I really like how James Shore puts it:
At first glance, Nullables look like test doubles, but they're actually production code with an “off” switch.
The fact that Nullables make it easier to write tests is just one feature. The other feature is to have this "off-switch", and that switch is a feature of the system.
Granted, it might be a feature that isn't very useful in most production environments, but it can be highly valuable to the developers working on that system.
If we come to terms with Nullables not being production code, then we have the remaining system: Is it worth putting these switches into the production code even if they don't support any features that is delivered?
Here we have to separate code that makes developers' lives easier and code that supports functionality. To make our lives easier, we structure our code properly, split our code over multiple files, and use patterns to make the code easier to work with. For example, we might make a slightly larger method because it is easier to read versus a very condensed one. Sometimes, we might add explicit log messages to make traceability a little easier.
There are so many examples of things developers do that benefits their productivity - even if that means adding extra code that doesn't directly benefit any feature.
The question isn't whether we write code specifically to make our lives easier - we absolutely do. If Nullables would make an aspect of our work easier, then why not?
TestContainers, Docker, MockServer, regular mocks...
One argument is that one doesn't need to do all of the above to get what you want - you only need to spin up the environment through containers. Need a Postgres database? Here you go ! Need RabbitMQ? No problem!
Pretty much anything is available as a container, and those things that aren't can either be made to fit into a container anyway with a custom docker-compose file, or you can simply mock them using something like MockServer.
The vast majority of dependencies can either be mocked or simply run on local machines, and often it is trivial to do so. Just create a docker-compose file that simply spins up everything a system needs, and you're off to the races! Or, you would be if it wasn't for all that pesky state.
Databases need to be populated, Kafka queues needs to be filled, data needs to connect with each other and so forth. Turns out there's a lot of maintenance keeping everything valid as the system changes - and maintaining it through other systems can be incredibly time-consuming.
The other downside of this approach is that it is slow. Sure, we're not talking many minutes on modern systems, but it is still comparable to slower than simply using in-code stubbs.
Don't get me wrong - I've used the "spin up an environment" approach before, and it does work. It is a great way to get something up and running. However, I've never enjoyed writing SQL queries in an attempt to force the system into a specific state simply to test a scenario.
Spinning up environments is absolutely not a bad one. It is way better than many other approaches one could take, but is it the best one? For some - maybe.
Nullables: A pattern language
I'm very much intrigued by Nullables. It is a very simple solution to a problem that plagues countless companies today, and I find it strange that it isn't more widespread than it currently is, or at least that it isn't talked about more than it is.
We know running our services locally due to requirements on external dependencies, and we decided to code our applications to only work with these dependencies. We have made, and continue to make, it more difficult for ourselves to execute our own code. That is a choice we constantly make whenever we add infrastructure dependencies without considering how that impacts running the application locally.
I'd love to try out Nullables in an actual production codebase - but doing so requires buy-in from the team. A lot seem to see the downsides rather than the benefits, and I agree there are some uncomfortable aspects to Nullables.