your unit tests are telling you something

Abstract

When should I refine the design? Writing tests give you hints on when to improve the design.

Rules of simple design by Kent Beck:

  1. passes all tests
  2. expresses intent = SRP, domain names
  3. DRY
  4. KISS

Tests give us hints on how to break complexity:

  • vertically
  • horizontally
  • by roles

Mocks

Why we live mocks:

  • isolated tests from external systems
  • fast (no framework, db, external APIs)
  • simpler when testing high complexity

Cyclomatic complexity = number of independent execution paths through code = max number of tests.

Why we hate mocks:

  • uncaught bugs
  • fragile tests that break when refactoring codes
  • unreadable tests

Testing pyramid vs honeycomb testing strategy

Testing pyramid is for the bad monolith (“big ball of mud”) which has:

  • highly coupled code
  • huge complexity behind a few entry points the need for lots of unit tests

Testing microservices / modulith is different.

  • huge complexity in a single microservice = bad practice break microservice / module
  • many APIs, hiding a decent amount of complexity
  • easier to test more at the API level honeycomb testing strategy
    • integrated
    • integration
    • implementation details
      • test roles, not methods / classes
      • write social unit tests

Test manageable complexity without mocks, instead, test “components” (group of objects) internal refactoring won’t break tests.

Constrained objects

Constrained objects = data structures that guard their internal consistency by throwing exceptions (e.g. required fields, string size, …)

  • mutable (e.g. domain entities, aggregate)
  • immutable (value objects)

Creating valid test data for a large constrained object is cumbersome.

  • test the entire group with a social unit test requires larger setup and larger input object mother pattern, i.e. a shared class creating valid test objects
  • same test data factory used in different verticals
    • introduces coupling, hard to change
    • break object mother per vertical
  • break domain entities in separated bounded contexts

Unit tests speak your domain model. Unit tests are first class citizen of your project. Respect them. Unit testing loves:

  • precise signatures
  • smaller data structures
  • agnostic domain

Unit testing puts design pressure on highly complex logic.

Fixture creep

Complex methods in the same class use different sets of dependencies, e.g.:

class Wide {
  A a;
  B b;
  complexA() { .. a.fa(); .. }
  complexB() { .. b.fb(); .. }
}

Then you might have to mock A and B. Mitigation: split unrelated complexity: horizontal split class. In this example, we could have a WideA that contains only A and WideB that contains only B.

Mock roles, not objects

contract testing Before mocking a dependency, clarify its responsibility. Changing an API you mocked is painful.

Functional programming

Unit testing promotes functional programming:

  • pure functions
    • has no side-effects
    • same inputs same outputs
    • just compute value
    • no network or file
    • no changes to data
    • no time/random
  • immutable objects

If you have a very complex logic using many dependencies (e.g. computePrice, applyDiscounts), move the complex part in another class, i.e. reduce coupling of complex logic.

a = repo1.findById(..);
b = repo2.findById(..);
c = api.call(..);
doComplex(a,b,c);
repo3.save(d);
mq.send(d.id);

move complex logic in pure function:

D pure(a,b,c) {
  return d;
}

It will be easier to test with less mocks.

Imperative shell / functional core segregation

Move complex logic in the functional core. All the imperative shell shall be used for dependencies, state mutation, API calls, DB, files, …

imperative shell functional core

Temporal coupling

You use mutable objects, so by swapping two lines can still cause bugs in production. This might lead to paranoid testing, i.e. verifying method call order.

Instead, use immutable objects.

Design hints from tests

  1. collapse middle-mand vs “What am I testing here?” syndrom
  2. honeycomb testing strategy vs fragile microscopic unit tests
  3. precise signatures
  4. tailored data structures vs creepy object mother
  5. keep complexity inside agnostic domain, not on APIs or extensive libraries
  6. separate complexity by layers of abstraction (vertical split) vs @Spy
  7. separate unrelated complexity (horizontal split) vs fixture creep (bloated setup)
  8. refine roles (mock roles, not objects) vs blindly @Mock all dependencies
  9. more complexity less dependencies vs mock-full tests
  10. promote immutable objects vs termporal coupling