test driven insight

src:

Abstract

When tests are hard to write, it’s because they are trying to tell you how the production design can be improved.

  1. Collapse Middle-Man vs “What am I testing here?” Syndrome or bigger test
  2. Honeycomb Testing Strategy = more integration tests over fragile Unit Tests
  3. Precise Signatures: simpler arguments, better names
  4. Dedicated Data Structure vs Creepy Object Mother
  5. Agnostic Domain vs mixing core logic with External-APIs or Heavy-Library calls
  6. Split complexity by Layers of Abstraction (vertical) vs Partial Mocks (aka spy)
  7. Split Unrelated Complexity (horizontal) vs Fixture Creep (large shared setup)
  8. Clarify Roles, Social Unit Tests vs blindly mock all dependencies
  9. Decouple Complexity in Pure functions vs Many tests full of mocks
  10. Immutable Objects vs Temporal Coupling

Writing unit tests early increases friction with careless design.

What am I testing here?

Too many dependencies in the class too many mocks injection in the test class.

Tested prod code has 4 lines, but test has 20 lines full of mocks we don’t know what we are testing.

prefer honeycomb testing strategy

Testing pyramid is for monoliths.

Honeycomb Testing Strategy

  • integrated
    • deploy all micro-services in dockers / staging env
    • expensive, slow, flaky tests
    • use for: business-critical flows (e.g. checkout)
  • integration
    • cover one micro-service fully
    • use for: default for every flow
    • isolate tests without mocks
  • implementation detail
    • cover 1 class / role (solitary / social unit tests)
    • use for: naturally isolated pieces with high complexity
    • mocks are allowed here

Unit tests give you most Design Feedback. More complexity Better design.

Solitary / Social unit tests

Instead of fine-grained Solitary Unit Tests testing one class in isolation, strive to write Social Unit Tests for components (groups of objects) with a clear responsibility:

  • ✅ internal refactoring of the component won’t break the tests
  • ❌ more complexity to understand! More mock to face?

Example: mappers

Robust unit testing requires identifying responsibilities.

Precise Signatures

  • pass only necessary data to function
    • instead of method(bigObj), prefer method(a, b)
  • when testing highly complex logic, introduce a Parameter Object

Object mothers

  • use object mothers to create specific data structures, e.g a customer with infinite of money in test classes
  • use object mothers to create personas
  • ⚠️ same object mother used in different verticals (e.g. invoicing & shipping)
    • split object mother per vertical (e.g. InvoicingTestData & ShippingTestData)
    • break domain entities in separate bounded contexts
  • agnostic domain: isolate complex logic from external work via Adapters
    • tests should speak your Domain language

Unit testing encourages:

  • minimal signatures
  • tailored data structures
  • agnostic domain

Split complexities

Vertically

  • split by layers of abstraction == vertical split of a class
class BigService {
  f() { // complex
    g();
  }
  g() {
    // complex
  }
}

to

class HighLevel {
  LowLevel low;
  f() { // complex
    low.g();
  }
}
// ----------
class LowLevel {
  g() {
    // complex
  }
}

Horizontally

Unrelated complex methods in the same class use different sets of dependencies:

class Wide {
  A a;
  B b;
 
  f() { ..a.a().. }
  g() { ..b.b().. }
}

to

class ComplexF {
  A a;
  f() { ..a.a().. }
}
class ComplexG {
  B b;
  g() { ..b.b().. }
}

See Vertical Slice Architecture for applying project wide (like CQRS).

Should I mock it?

  • repository
    • e.g. Spring Data JpaRepository
    • YES: clear responsibility with stable API + expensive to integration test
  • message sender
    • e.g. RabbitTemplate
    • YES: clear responsibility, simple API + expensive to integration test
  • flexible library class
    • e.g. RestTemplate / WebClient
    • NO: complex API integration test with Wiremocks
  • adapter wrapping an external API call
    • YES: allow to keep your tests agnostic
  • satellite class
    • e.g. simple DTO > entity mapper
    • NO: too simple social / integration test
  • a logic component
    • e.g. @Service
    • YES: if clear, non-trivial responsibility: NotificationService, UpdateProductStockService
    • NO: if unclear role: ProductService doing “anything” social / integration test
  • a data structure
    • e.g. @Entity, @Document, DTO, …
    • NO: don’t mock populate an instance

Pure functions

  • no side effects
  • just calculates a value
  • same input same output
  • extract heaviest complexity as pure functions

Temporal coupling

  • paranoid testing
    • if you swap two lines (2 & 3 in the following example), tests still pass
1. method(Mutable order, discounts) {
2.  ds.applyDiscounts(order, discounts);
3.  var price = cs.computePrice(order);
4.  return price;
5. }
  • InOrder can verify method call order
  • using Immutable objects is better as swapping line would make the compilation fail