The TDD Cycle

  1. Red: Write a test that clearly expresses what you expect for the case you want to cover
  2. Green: Make the test pass by implementing the case
  3. 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:

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:

  1. Arrange: Set up the test data and conditions
  2. Act: Execute the behavior you’re testing
  3. Assert: Verify the expected outcome
let sut: EmailValidator;

beforeEach(() => {
sut = new EmailValidator();
});

test("should reject invalid email format", () => {
// Arrange
const invalidEmail = "not-an-email";

// Act
const result = validator.validate(invalidEmail);

// Assert
expect(result.isValid).toBe(false);
});

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:

// Good: Separate tests for different aspects
test("should save user to database", () => {
// Arrange
const user = UserBuilder.create().build();

// Act
repository.save(user);

// Assert
expect(repository.findById(user.id)).toEqual(user);
});

test("should return user id after saving", () => {
// Arrange
const user = UserBuilder.create().build();

// Act
const id = repository.save(user);

// Assert
expect(id).toBe(user.id);
});

// Bad: Multiple assertions testing different things
test("should save user", () => {
// Arrange
const user = UserBuilder.create().build();

// Act
const id = repository.save(user);

// Assert
expect(id).toBe(user.id); // Testing return value
expect(repository.findById(user.id)).toEqual(user); // Testing persistence
expect(repository.count()).toBe(1); // Testing count
});

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.

// Good: Only override what matters for this test
const user = UserBuilder.create()
.withEmail("invalid-email")
.build();

// The builder handles all other defaults (name, id, etc.)

Test Doubles for Repeatability

Use Hexagonal Architecture with test doubles to ensure repeatable tests:

Mocks vs In-Memory Doubles: While mocks can be used, in-memory test doubles are preferred because they can also serve as substitutes for:

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”:

  1. Extract the complex part
  2. Write its own dedicated suite of tests
  3. 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.

// Unit test: runs in milliseconds
test("should calculate discount for premium users", () => {
// Arrange
const calculator = new PriceCalculator();

// Act
const price = calculator.calculatePrice(100, UserType.Premium);

// Assert
expect(price).toBe(85); // 15% discount
});

Integration Tests

Integration tests verify that your application correctly interacts with external systems and dependencies (databases, APIs, message queues, file systems).

Integration Testing Best Practices

Database Testing with Testcontainers

For database integration tests, use Testcontainers to spin up isolated, real database instances:

import { PostgreSqlContainer } from '@testcontainers/postgresql';

let container: StartedPostgreSqlContainer;
let repository: UserRepository;

beforeAll(async () => {
// Spin up a real PostgreSQL instance
container = await new PostgreSqlContainer().start();

// Run all migrations to recreate the schema
await runMigrations(container.getConnectionUri());

repository = new UserRepository(container.getConnectionUri());
});

afterAll(async () => {
await container.stop();
});

beforeEach(async () => {
// Clean database state between tests
await repository.deleteAll();
});

// Note that you can also spin up the container in the beforeEach if you want a fresh instance per test.

Benefits of Testcontainers:

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:

container = await newMongodbReplicaSetContainer();
const connectionString = getConnectionString(container);
client = new MongoClient(connectionString);
await client.connect();
db = client.db("test");
// Not in official docs but it works:
// https://hassansin.github.io/working-with-mongodb-ttl-index#ttlmonitor-sleep-interval
await db.admin().command({setParameter:1, ttlMonitorSleepSecs: Math.floor(TTL_INTERVAL_MS / 1000)});

What to Avoid in Tests

❌ Arbitrary Time-Based Waits

Never use arbitrary sleeps or timeouts—they make tests slow and flaky.

// Bad: Arbitrary wait
test("should process async event", async () => {
eventBus.publish(event);
await sleep(1000); // Hope it's done by now?
expect(result).toBe(expected);
});

// Good: Wait for a specific condition
test("should process async event", async () => {
eventBus.publish(event);
await waitUntil(() => eventBus.hasProcessed(event.id), { timeout: 5000 });
expect(result).toBe(expected);
});

❌ 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.

// Bad: Testing internal state
test("should add to internal cache", () => {
service.process(data);
expect(service._cache.size).toBe(1); // Brittle!
});

// Good: Testing observable behavior
test("should return cached result on second call", () => {
const first = service.process(data);
const second = service.process(data);
expect(second).toBe(first);
});

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:

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.

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.