DEV Community

Cover image for Sociable Tests: Integration tests without the pain!
Dylan Watson
Dylan Watson

Posted on • Updated on

Sociable Tests: Integration tests without the pain!

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

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?

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.

Nelson laughing at 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());
  }
}
Enter fullscreen mode Exit fullscreen mode
class PricingEngine {
  double markup;

  public PricingEngine(double markup) {
    this.markup = markup;
  }

  public double calculatePrice(double cost) {
    return cost * markup;
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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());
  }
Enter fullscreen mode Exit fullscreen mode
// PricingEngine

  public double calculatePrice(double cost, double discount) {
    return cost * markup - cost * discount;
  }
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (2)

Collapse
kahdev profile image
Kah

Nice article! I think both approaches have their trade offs. Sure the social test has less lines of code, but it also "relies" on PricingEngine working correctly. Don't get me wrong - that could be totally fine too!

If you were using the social unit test approach, would you also have a separate test just for PricingEngine.calculatePrice?

Collapse
dylanwatsonsoftware profile image
Dylan Watson Author

Yep I think they both have their merits.
But the trade-off as I see it is, would you rather:
1) needing to change the tests for all the classes that depend on yours, when you change a method signature (or move it entirely)
OR
2) Have the occasional extra test break, when you actually make a breaking change to functionality?

With regards to PricingEngine, it probably depends on how complicated it is... But I would usually expect it to have its own set of tests... Though I did always like the idea of testing functionality in groups rather than classes... Since classes can change and be refactored away.