The TDD Cycle
- Red: Write a test that clearly expresses what you expect for the case you want to cover
- Green: Make the test pass by implementing the case
- Refactor: Apply Robert C. Martin’s clean code philosophy to make the code readable. ONLY REFACTOR WHEN ALL TESTS ARE GREEN!!
When NOT to Use TDD
TDD is detrimental in these scenarios:
- Hackathons or very quick prototypes
- Basic CRUD operations (though be careful, logic is quick to creep in)
- Basic scripts
When to Use TDD
For everything else, TDD is a must to build reliable software.
Writing Good Tests
Test Structure: Arrange-Act-Assert
Structure your tests with the Arrange-Act-Assert (AAA) pattern for clarity:
- Arrange: Set up the test data and conditions
- Act: Execute the behavior you’re testing
- Assert: Verify the expected outcome
|
Read Like English
Tests should be readable and self-explanatory. When someone reads your test, they should understand what the system does without needing to read implementation code.
Test Isolation
Every test instance of the system under test (SUT) should be re-created for each test to ensure isolation. No shared state between tests.
One Assertion Per Test
Each test should verify one specific behavior with one assertion. This makes tests:
- Easier to understand (the test name matches exactly what’s being verified)
- Easier to maintain (when requirements change, you modify one focused test)
- Easier to debug (when a test fails, you know exactly what broke)
|
Using Builders with Defaults
Use builders to create domain objects with default values. If a value override is specified in a test, it’s because it’s relevant to that specific test case. Otherwise, don’t bother specifying it—use the defaults.
|
Test Doubles for Repeatability
Use Hexagonal Architecture with test doubles to ensure repeatable tests:
StubDateAdapterInMemoryPersistenceAdapter- Other test doubles as needed
Mocks vs In-Memory Doubles: While mocks can be used, in-memory test doubles are preferred because they can also serve as substitutes for:
- End-to-end testing
- App demos
Tests as Living Documentation
Test names are important—they are the living documentation of your program. A well-named test suite tells the story of what your system does and how it behaves.
Gear Down When Complexity Grows
If a test case gets too complicated, “gear down”:
- Extract the complex part
- Write its own dedicated suite of tests
- Test the extracted component in isolation
This keeps your tests focused and maintainable.
Test Types and Performance
Unit Tests
Unit tests verify a single unit of your application (a domain service, a use-case, etc) in isolation.
- Should run in milliseconds
- Use test doubles (stubs, fakes, in-memory implementations) for all external dependencies
- Fast feedback is critical—slow unit tests break the TDD cycle
|
Integration Tests
Integration tests verify that your application correctly interacts with external systems and dependencies (databases, APIs, message queues, file systems).
- Can take longer than unit tests (seconds, not milliseconds)
- Should still be isolated—each test gets a clean environment
- Use real implementations of external dependencies when possible (for example, a real database instance. For AWS, there are projects like LocalStack that may help)
Integration Testing Best Practices
Database Testing with Testcontainers
For database integration tests, use Testcontainers to spin up isolated, real database instances:
|
Benefits of Testcontainers:
- Tests run against the actual database (not an in-memory fake)
- Complete isolation—each test suite gets its own database instance
- Ensures migrations work correctly
- Catches database-specific behavior (constraints, triggers, indexing)
For some tests like TTL indexes you may need to configure the database instance to run the TTL job frequency more often. For mongodb mongo:7.0.21:
|
What to Avoid in Tests
❌ Arbitrary Time-Based Waits
Never use arbitrary sleeps or timeouts—they make tests slow and flaky.
|
❌ Multiple Unrelated Assertions
Already covered above—keep one assertion per test for clarity and maintainability.
❌ Testing Implementation Details
Test behavior, not implementation. Tests should survive refactoring of internal code.
|
Tricks
The Testing Pyramid
The Testing Pyramid is a strategy that guides the quantity and type of tests you should maintain. The goal is to maximize confidence while minimizing execution time and maintenance costs:
- Unit Tests (The Base): Should make up the vast majority of your suite. They are fast, cheap to write, and point exactly to the line of code that failed. Write them for every piece of logic in your application.
- Integration Tests (The Middle): Verify how your code interacts with external dependencies (DB, APIs). Fewer than unit tests because they are slower and more complex to set up. Keep that for adapters only!
- E2E/UI Tests (The Top): Verify the entire system from the user’s perspective. These are the most “realistic” but also the slowest and most brittle (flaky). Use sparingly for critical user journeys only.
Rule of Thumb: As you move up the pyramid, the cost and execution time increase, so the number of tests should decrease.
Triangulation
Triangulation is a specific TDD technique used when you aren’t sure how to implement the general solution yet. It helps you avoid “hardcoding” logic by forcing the implementation to evolve through multiple specific examples.
- Write Test 1: Focus on a specific case (e.g.,
sum(1, 1) = 2). - Implementation: Return a hardcoded
2to get to Green quickly. - Write Test 2: Focus on a different specific case (e.g.,
sum(1, 2) = 3). - Implementation: Now, you cannot hardcode a single value. You are forced to “triangulate” and write the generic logic
(a + b)to make both tests pass.
This prevents you from jumping to complex logic too early and ensures your code is truly driven by requirements.
Wishful Thinking
When writing tests, write code you wish existed. This not compiling code is the first red phase of TDD. Then you can implement the code to make it compile and pass the tests. It helps you focus on the desired behavior first.
One test suite - many classes/functions
The idea that one test suite should cover only one class or function is not a strict rule. Often we start with one test suite and one class but as the complexity grows we may end up creating other classes/functions. As long as the test suites cover the behaviors of the system well, it’s okay to have multiple classes/functions covered by one test suite. For example, a use-case class may depend on multiple domain services. The test suite for the use-case may cover the behaviors of all those domain services as well, as they are part of the overall behavior being tested.