Dylan Watson
Posted on November 11, 2021
I have been known to complain about how slow and painful to debug SpringBoot integration tests can be. Recently, however, I have found an alternative! 😁 A way to reclaim confidence in integration testing, without sacrificing the speed and debug-ability of unit tests.
For the most part, unit tests offer a great alternative to integration tests but sometimes they are not enough. Often it's still important to have a small number of tests that test how your system fits together. For me, sociable unit tests fill this role.
Integration tests (Such as Spring Boot tests) give confidence that your system hangs together, that you can wire all your dependencies and that your @Controllers are configured to expose your endpoints properly. But for all that, they are just too slow when it gets to testing your logic to any considerable degree. I want tests that run in milliseconds.. not seconds. Especially when I'm talking about running 1000s of tests.
This is where sociable unit tests come in.
Sociable Unit Tests
Sociable Unit Tests are an attempt at a happy medium between unit tests and integration tests. They attempt to test a larger portion of the system than unit tests. Where possible, we want to avoid mocks and restrict mocking to things that would make our tests slow, brittle or non-deterministic (like API or database calls).
Solitary unit tests isolate themselves with mocks. Sociable unit tests try not to.
What are we after?
We just want our tests to be:
-
Fast
- This ensures we get fast feedback when we make changes (Think milliseconds)
-
Reliable
- The test needs to return the same result every time (You might also call this deterministic)
-
Realistic
- Your tests should be running production-like code. That's their whole point.
-
Structure-insensitive
- Decoupling our tests from the implementation allows us to safely refactor our code without breaking tests
Kent Beck talks more about some other important test properties here
What can I do?
Let's take a look at a few things that can help push our tests in this direction.
- ✅ Avoid spring-style (@Autowire-ing) dependency injection in tests (Fast)
- ✅ Construct objects within the test (Reliable)
- ✅ Prefer real dependencies for testing (Realistic)
- ✅ Only test against public methods (Structure-insensitive)
So what about these Sociable Unit Tests then?
This image illustrates the different types of Microservice tests. Sociable Unit Tests are Unit tests that may encompass the same amount of business logic as an integration test without the pain of also testing the network and framework!
"Sociable Unit Tests" are essentially unit tests that don't mock their dependencies.
Don't mock your dependencies. It's rude.
Show me a f$@%ing example
Imagine we have these 2 classes, ProductService and PricingEngine:
class ProductService {
PricingEngine engine;
public ProductService(PricingEngine engine) {
this.engine = engine;
}
public double getPriceFor(Product product) {
return engine.calculatePrice(product.getCost());
}
}
class PricingEngine {
double markup;
public PricingEngine(double markup) {
this.markup = markup;
}
public double calculatePrice(double cost) {
return cost * markup;
}
}
The ProductService has a PricingEngine which it delegates, to calculate the price.
Let's take a look at how we'd test it in both approaches (solitary and sociable).
Solitary Approach
You will probably recognise the following approach to testing as it has become one of the most popular in recent years. To isolate our system-under-test, we will mock its dependencies, like so:
// Solitary Unit Test
@Test
public void shouldGetPrice() {
PricingEngine engine = mock(PricingEngine.class);
Product product = new Product(10);
when(engine.calculatePrice(10)).thenReturn(13);
ProductService productService = new ProductService(engine);
double price = productService.getPriceFor(product);
assertThat(price).isCloseTo(13.0, within(0.1));
verify(engine, times(1)).calculatePrice(10)
}
But if we think about it, the only thing this is testing is that we call a specific method on the engine.
If we refactor ProductService to call a different (but equivalent) method, our test would fail. Now not all mock-heavy tests are this terrible but it happens far too often.
Wouldn't it be great if our test continued to pass, so we could prove we haven't broken anything?!
Enter the sociable unit test.
Sociable Unit Test Approach
The core thing we want to test is that we can get a price for a given product.. so let's give it another go with that in mind!
// Sociable Unit Test
@Test
public void shouldGetPrice() {
Product product = new Product(10);
ProductService productService = new ProductService(new PricingEngine(1.3));
double price = productService.getPriceFor(product);
assertThat(price)
.isCloseTo(13.0, within(0.1));
}
That's it! A much simpler test.. and it's less sensitive to breakages from structure changes. In fact, in this case, we mostly just removed code.
Make a change
What if we now add the requirement for our price to include a discount based on the customer?
We might end up with a change that looks like:
// ProductService
public double getPriceFor(Product product, Customer customer) {
return engine.calculatePrice(product.getCost(), customer.getDiscount());
}
// PricingEngine
public double calculatePrice(double cost, double discount) {
return cost * markup - cost * discount;
}
Solitary Approach
For a "solitary"-style unit test, to test this requirement, we need to create a customer, pass it to the getPriceFor method... as well as pass the discount into every reference to the PricingEngine#calculatePrice
method.
@Test
public void shouldGetPrice() {
PricingEngine engine = mock(PricingEngine.class);
Product product = new Product(10);
Customer customer = new Customer(0);
when(engine.calculatePrice(10, 0)).thenReturn(13);
ProductService productService = new ProductService(engine);
double price = productService.getPriceFor(product, customer);
assertThat(price)
.isCloseTo(13.0, within(0.1));
verify(engine, times(1)).calculatePrice(10, 0);
}
However, with a sociable unit test we only test the public methods of ProductService so don't care how PricingEngine is used. This makes the change much simpler.
// Sociable Unit Test
@Test
public void shouldGetPrice() {
Product product = new Product(10);
Customer customer = new Customer(0);
ProductService productService = new ProductService(new PricingEngine(1.3));
double price = productService.getPriceFor(product, customer);
assertThat(price)
.isCloseTo(13.0, within(0.1));
}
We can see from the above that the required change is just:
- Create a customer
- Pass it to
ProductService#getPriceFor
to fix the compilation error.
Even better, if ProductService already had the info it needed to pass to PricingEngine, the test wouldn't even need updating.
On a suite of hundreds or thousands of tests, the effect sociable-style tests can have on refactoring is huge!
We'd want to add other tests or assertions for this new functionality but the simple act of using real dependencies in our tests puts us in a much better place from the start.
Conclusion
Sociable unit tests are an approach that works just as well for small scoped unit tests as it does for integration testing whole pieces of a microservice. Whilst you may still find the need to mock external dependencies such as APIs or slow, non-deterministic ones like a database or filesystem, attempting to use more real dependencies in your tests can have a huge impact on both the speed and usefulness of your tests.
Give it a go and let me know what you think!
Posted on November 11, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.