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.
- Collapse Middle-Man vs “What am I testing here?” Syndrome or bigger test
- Honeycomb Testing Strategy = more integration tests over fragile Unit Tests
- Precise Signatures: simpler arguments, better names
- Dedicated Data Structure vs Creepy Object Mother
- Agnostic Domain vs mixing core logic with External-APIs or Heavy-Library calls
- Split complexity by Layers of Abstraction (vertical) vs Partial Mocks (aka spy)
- Split Unrelated Complexity (horizontal) vs Fixture Creep (large shared setup)
- Clarify Roles, Social Unit Tests vs blindly mock all dependencies
- Decouple Complexity in Pure functions vs Many tests full of mocks
- 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)
, prefermethod(a, b)
- instead of
- 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
to
Horizontally
Unrelated complex methods in the same class use different sets of dependencies:
to
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
- e.g. Spring Data
- message sender
- e.g.
RabbitTemplate
- YES: clear responsibility, simple API + expensive to integration test
- e.g.
- flexible library class
- e.g.
RestTemplate
/WebClient
- NO: complex API ⇒ integration test with Wiremocks
- e.g.
- 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
- e.g.
- a data structure
- e.g.
@Entity
,@Document
, DTO, … - NO: don’t mock ⇒ populate an instance
- e.g.
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
InOrder
can verify method call order- using Immutable objects is better as swapping line would make the compilation fail