TDD in Practice for Java/Spring: Red-Green-Refactor, BDD, Mocking, Pitfalls
Fast recall
Quick revision
Core answer
@WebMvcTest, @DataJpaTest) for tighter feedback; use Testcontainers for integration. Real practice looks like: write a domain-level test for behavior → implement a thin slice → add edge case tests → emerge the design through tests rather than designing upfront. Outside-in TDD (London school) starts with high-level acceptance test and mocks dependencies; classic TDD (Detroit/Chicago school) starts with domain entities and avoids over-mocking. BDD (Cucumber, Spock) wraps TDD with business-readable scenarios. TDD shines for: domain logic, algorithms, parsing, refactoring legacy code, regression prevention. TDD struggles with: UI exploration, performance/concurrency tuning, throwaway prototypes, learning new APIs (spike first). Common failures: writing tests after (TLD = Test-Last Development), brittle mock-heavy tests that don't verify behavior, integration tests masquerading as unit tests, long red phases (>10 min), test code rot. Senior move: TDD as one tool among many — use it where it accelerates design and confidence; pair with property-based testing, mutation testing, and exploratory testing for full coverage.Real practical TDD; not just definition; trade-offs; honest about where it breaks
TDD as religion; mock-heavy tests; testing implementation not behavior
Discuss London vs Detroit, when TDD doesn't apply, BDD, characterization tests, mutation testing pairing
Interview-ready guide
Interview answer
TDD is a design discipline.
The cycle
- Write a failing test (red)
- Make it pass with simplest code (green)
- Refactor without changing behavior (refactor)
- Done in tight loops measured in minutes, not hours
For Java/Spring
- JUnit 5 + AssertJ + Mockito for unit tests
- @SpringBootTest sparingly (slow)
- Prefer slice tests (@WebMvcTest, @DataJpaTest) for tighter feedback
- Testcontainers for integration
Real practice
- Write a domain-level test for behavior
- Implement a thin slice
- Add edge case tests
- Emerge the design through tests rather than designing upfront
Two schools
Outside-in (London)
- Starts with high-level acceptance test
- Mocks dependencies
Classic (Detroit/Chicago)
- Starts with domain entities
- Avoids over-mocking
BDD
- Cucumber, Spock
- Wraps TDD with business-readable scenarios
TDD shines for
- Domain logic
- Algorithms
- Parsing
- Refactoring legacy code
- Regression prevention
TDD struggles with
- UI exploration
- Performance/concurrency tuning
- Throwaway prototypes
- Learning new APIs (spike first)
Common failures
- Writing tests after (TLD = Test-Last Development)
- Brittle mock-heavy tests that don't verify behavior
- Integration tests masquerading as unit tests
- Long red phases (>10 min)
- Test code rot
Senior move
- TDD as one tool among many
- Use it where it accelerates design and confidence
- Pair with property-based testing, mutation testing, and exploratory testing for full coverage
Detail: design discipline
Three steps
- 1Red — failing test expressing what you want
- 2Green — minimum code to pass
- 3Refactor — improve design without changing behavior; tests green
Repeat tight cycles
- Forces designing from consumer perspective (write call before implementation)
- Small testable units (can't test a sprawling thing)
- Continuous regression coverage
- Confidence to refactor (tests catch breakage)
Two schools detail
Detroit/Chicago/Classical (Beck)
- Start with domain; real objects
- Mocks only for I/O boundaries
- Tests verify state
London (Freeman, Pryce — GOOS)
- Outside-in; mock collaborators
- Tests verify interactions
- Emerges design through dialogue with mocks
Neither right; both useful in different situations.
TDD is not
- Writing tests first as checklist
- 100% code coverage as goal
- Replacement for design thinking
- Always faster than not-TDD
- Guarantee of correctness (can write wrong tests)
TDD is
- Way to design code testable, decoupled, well-factored
- Regression safety net
- Focus tool (one test, one behavior)
Worked example: transfer money
Iteration 1 — simplest case
- RED: transfer_movesMoneyBetweenAccounts; from balance 100, to balance 0; service.transfer 30; assert from 70, to 30
- GREEN: TransferService.transfer { from.debit(amount); to.credit(amount); }
Iteration 2 — insufficient funds
- RED: transfer_throws_whenInsufficientFunds; from 20, to 0, transfer 50; assertThatThrownBy InsufficientFundsException; assert balances unchanged
- GREEN: Account.debit { if (balance.lessThan(amount)) throw new InsufficientFundsException(); balance = balance.minus(amount); }
Iteration 3 — idempotency/atomicity
- Add tests for: same transferId not double-applied; partial failure rolls back; concurrent transfers safe
- Drives design toward: idempotency keys, transactional boundaries, locking strategy
Iteration 4 — wire up Spring
- @DataJpaTest for repository
- @SpringBootTest(webEnvironment=MOCK) + @AutoConfigureMockMvc for controller
- Testcontainers + Postgres for full integration
Notice design emerged
- Account got debit method with validation
- TransferService stayed thin
- Persistence came later when needed
- No premature @Service/@Repository ceremony — just code tests demanded
Slice tests (faster than full @SpringBootTest)
- @WebMvcTest(TransferController.class)
- @MockBean TransferService
- mvc.perform(post('/transfers').contentType(APPLICATION_JSON).content(json)).andExpect(status().isOk())
- verify(service).transfer(any(), any(), any())
Tools
- JUnit 5 (parameterized, lifecycle, extensions)
- AssertJ (fluent, readable)
- Mockito (collaborator mocks; use sparingly)
- Testcontainers (real DB/Kafka/Redis)
- WireMock (HTTP collaborator stubs)
- Awaitility (async conditions)
- JsonAssert (JSON comparison)
- ArchUnit (architecture tests)
- Spock (BDD-style in Groovy)
TDD shines in detail
- Domain logic — pricing, validation, state machines, calculations
- Algorithms — sorting, parsing, encoding
- Refactoring legacy — characterization tests first; then change
- Bug fixing — test demonstrates bug; fix; test passes
- API design — from caller's perspective
- Defensive coding — forces edge case consideration
TDD struggles in detail
- UI/visual exploration — acceptance/snapshot later, not TDD
- Performance tuning — measure, profile, change; tests can't capture nuance
- Concurrency — deterministic tests for races hard; use stress + property
- Throwaway prototypes — spike first, then formalize
- Learning new API/library — spike to understand, then TDD integration
- Highly dynamic data structures — generative/property-based fit better
- External integrations you don't control — contract tests fit
Common TDD failures
- 1Test-Last masquerading — write code, then tests; missed design benefit
- 2Mock-everything: tests pass but don't verify real behavior; refactoring breaks tests
- 3Implementation-coupled: testing private methods, internal state; brittle
- 4Long red phases: 30 min red → lost focus; reduce scope
- 5Skipping refactor: green code accumulates smell; debt grows
- 6'TDD slows me down': usually slow tests (full Spring context every test); fix with slice tests
- 7Coverage as goal: 100% with weak assertions; meaningless
- 8No assertions: 'no exception thrown' ≠ 'works'
- 9Brittle test data: hardcoded UUIDs/timestamps; randomize or freeze clock
- 10No test cleanup: tests interfere; flakiness
- 11Big-bang test rewrite: thousand tests at once; do incrementally
- 12No characterization tests on legacy: refactoring blind
Speed matters
- Unit test loop: <1 sec per test, <10 sec total in module
- Slice tests: <1 sec each
- Integration with Testcontainers: <5 sec
- Full E2E: <2 min
- If tests slow, devs run less often → TDD breaks down
Mocking discipline (Mockito)
- Mock at I/O boundaries (HTTP, DB, queues)
- Real domain objects internally
- @Mock for stubs of out-of-scope collaborators
- Avoid verify(...) for everything — verify only what matters
- Don't mock value objects, DTOs, simple data
- BDDMockito (given/willReturn/then) reads better
TDD + other techniques
- Property-based (jqwik): generates inputs; finds edge cases TDD misses
- Mutation testing (PIT, Pitest): verifies test quality; if mutations survive, tests weak
- Approval testing (ApprovalTests): legacy refactoring; lock current behavior
- Contract testing (Pact, Spring Cloud Contract): service boundaries
- Snapshot testing: serialization formats
- Fuzz testing (Jazzer): security-critical parsers
BDD complement
- Cucumber/Spock wrap TDD with business-readable scenarios
- 'Given user with $100 balance, when they transfer $30, then…'
- Useful for shared understanding with PMs/QA
- Doesn't replace developer-level unit tests
Adoption maturity
- 1Tests after for everything (most teams start)
- 2Tests first for new code; tests after for legacy
- 3Tight TDD loops for domain logic; tests after for plumbing
- 4Outside-in TDD for new features (London)
- 5TDD + property + mutation as standard
- 6TDD as design discipline influencing architecture
- Most teams 2-3; 4-6 mature
TDD vs other approaches
- TDD: tests first; design emerges; small loops
- TLD: code first, tests after; common but loses design benefit
- BDD: scenario-driven; collaborative; wraps TDD
- ATDD (Acceptance TDD): high-level acceptance tests drive iteration
- Spec-by-Example: business writes examples; devs implement
Honest critique
- Doesn't always speed development; sometimes slower at first, faster overall
- Doesn't guarantee correctness; still need exploratory + manual + integration
- Requires good test infrastructure (fast, isolated); slow tests kill it
- Skill; takes time to learn; pair with experienced practitioner
- Some codebases (legacy, UI-heavy, perf-critical) need different techniques
Senior engineer moves
- Use TDD where it fits: domain logic + algorithms + refactoring; not everywhere as religion
- Invest in test infrastructure: fast unit; slice for Spring; Testcontainers for integration; CI parallelization
- Pair TDD with mutation testing: validates test quality; catches weak assertions
- Pair TDD with property-based: finds edge cases unit tests miss
- Refactor tests: tests are code; rotate, deduplicate, extract helpers
- Test against behavior, not implementation: refactoring shouldn't break unrelated
- Demonstrate via pairing/mob: contagious when seen well
- Measure: test runtime, flakiness, mutation score, coverage trend
- Push for fast CI: <10 min push to test result; otherwise discipline erodes
- Balance with exploratory: tests find expected; humans find unexpected
- Train juniors: teachable; pairing best teacher
Strategic take
- Powerful design discipline when applied thoughtfully
- Not silver bullet; pair with property/mutation/exploratory
- Best where domain logic dominates; less useful UI/perf/concurrency
- Test infrastructure speed determines whether TDD survives
- Seniors use TDD as one tool among many, applied where it accelerates design and confidence
TDD in Practice for Java/Spring: Red-Green-Refactor, BDD, Mocking, Pitfalls
Direct Answer
TDD is a design discipline — write a failing test (red), make it pass with the simplest code (green), then refactor without changing behavior (refactor) — done in tight loops measured in minutes, not hours. For Java/Spring: use JUnit 5 + AssertJ + Mockito for unit tests; @SpringBootTest sparingly (slow); prefer slice tests (@WebMvcTest, @DataJpaTest) for tighter feedback; use Testcontainers for integration. Real practice looks like: write a domain-level test for behavior → implement a thin slice → add edge case tests → emerge the design through tests rather than designing upfront. Outside-in TDD (London school) starts with high-level acceptance test and mocks dependencies; classic TDD (Detroit/Chicago school) starts with domain entities and avoids over-mocking. BDD (Cucumber, Spock) wraps TDD with business-readable scenarios. TDD shines for: domain logic, algorithms, parsing, refactoring legacy code, regression prevention. TDD struggles with: UI exploration, performance/concurrency tuning, throwaway prototypes, learning new APIs (spike first). Common failures: writing tests after (TLD = Test-Last Development), brittle mock-heavy tests that don't verify behavior, integration tests masquerading as unit tests, long red phases (>10 min), test code rot. Senior move: TDD as one tool among many — use it where it accelerates design and confidence; pair with property-based testing, mutation testing, and exploratory testing for full coverage.
Common Mistakes & Pitfalls
TDD as religion; mock-heavy tests; testing implementation not behavior
Check your understanding
Answer these without looking back at the article.
- Walk through TDD on a discount calculation service; show 3 iterations.
- When would you NOT use TDD?
- Explain mutation testing and how it complements TDD.
- Compare London vs Detroit schools of TDD with a concrete example.
Related interview prep categories
1Looking for a structured roadmap?
Every module on this page is also part of the full Java backend interview track.