logo
Projects

Published on

July 5, 2022

testing

Sociable Tests: Integration tests without the pain!

People laughing about their super fast tests
People laughing about their super fast tests

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.

Before we jump in to see what's different about sociable unit tests, let's go through the definitions of the types of tests we'll talk about in this article:

Test Definitions

Unit Tests

Unit tests are used to verify the behaviour of a small subset of your system - often testing a single class (though not always). In many Java code bases (rightly or wrongly), you'll find most of the dependencies of these classes mocked.

Integration

Integration tests are larger in scope than unit tests. They often spin up a real server but usually only connect to an in-memory database. Any calls to other APIs/services are mocked (If the external services are not mocked, this would be classed as an "end-to-end" test - not in scope for this article but probably flakey). Integration tests test more of the system than unit tests and are closer (but not exactly) to "production likeness". However, they come at a cost - they are often slower and harder to debug than unit tests. Despite this, they may make it easier to reproduce bugs and prove the functionality of a system.

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. Like integration tests, the only mocked dependencies should be things outside of the APIs "sphere of influence" - things like network calls or databases I.e API clients or Repository services. Server/Framework/Spring specific code should also be avoided - we only want to test OUR code.

Sociable vs Solitary Unit Tests
Sociable vs Solitary Unit Tests 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?

Microservice Test Landscape
Microservice Test Landscape

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.
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!

# testing# java# discuss

This post was updated on

July 5, 2022.

Related articles

Table of contents