There are two kinds of developers in the ocean: those who write tests, and those who are constantly surprised when things break in production.
I'm here to convert you to the first group. And I'm going to do it using lobsters, because this is my blog and I make the rules.
Welcome to Test Driven Decapod โ TDD, but every example is about crustaceans. You're going to learn real TDD. You're also going to learn more about lobster biology than you expected when you clicked this link. I consider both of these outcomes a win.
What Is TDD?
Test Driven Development is a software development practice where you write the test before you write the code. Not after. Not "eventually." Not "when the sprint has leftover time" (it never does). Before.
The cycle is three steps, often called Red-Green-Refactor:
- ๐ด Red: Write a test that fails. (It must fail โ if it passes without new code, something is wrong.)
- ๐ข Green: Write the minimum code to make the test pass.
- ๐ต Refactor: Clean up the code without changing behaviour. The tests prove it still works.
That's it. That's TDD. The rest of this article is me showing you how to do it with TypeScript and lobsters.
Setting Up the Reef
We're going to build a Lobster Management System. Because every ocean needs one, and because it gives us a rich domain to test against.
Our tech stack:
- TypeScript โ because types are like shells: they protect your soft, vulnerable code
- Jest โ our test runner. Fast, reliable, great error messages
- Node.js โ the ocean our code swims in
// Quick setup
npm init -y
npm install --save-dev typescript jest ts-jest @types/jest
npx tsc --init
// jest.config.ts
export default {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
};
Right. The reef is ready. Let's write our first test.
Cycle 1: Creating a Lobster
๐ด Red: Write the Failing Test
Before we write any lobster code, we write what we expect a lobster to be:
// src/__tests__/lobster.test.ts
import { Lobster } from '../lobster';
describe('Lobster', () => {
it('should create a lobster with a name and claw count', () => {
const larry = new Lobster('Larry', 2);
expect(larry.name).toBe('Larry');
expect(larry.clawCount).toBe(2);
});
});
Run it. Watch it fail.
$ npx jest
FAIL src/__tests__/lobster.test.ts
โ Lobster โบ should create a lobster with a name and claw count
Cannot find module '../lobster' from 'src/__tests__/lobster.test.ts'
Beautiful. A red test. The file doesn't even exist yet. That's the point. The test defines the contract before the implementation. We've told the universe what a Lobster should be, and now we need to make the universe comply.
๐ข Green: Make It Pass
// src/lobster.ts
export class Lobster {
constructor(
public readonly name: string,
public readonly clawCount: number,
) {}
}
$ npx jest
PASS src/__tests__/lobster.test.ts
โ should create a lobster with a name and claw count (3 ms)
Green. Dopamine. Let's keep going.
๐ต Refactor
Nothing to refactor yet โ the code is minimal. That's fine. Not every cycle needs a refactor step. The important thing is that you check. Ask yourself: "Is this the cleanest version of this code?" If yes, move on.
Cycle 2: Lobsters Must Have Valid Claws
A lobster with zero claws is technically possible (they regenerate), but a lobster with negative claws is a cosmic error. Let's enforce that.
๐ด Red
it('should throw if claw count is negative', () => {
expect(() => new Lobster('Sad Larry', -1))
.toThrow('A lobster cannot have negative claws');
});
FAIL โ Expected to throw, but it didn't
๐ข Green
export class Lobster {
constructor(
public readonly name: string,
public readonly clawCount: number,
) {
if (clawCount < 0) {
throw new Error('A lobster cannot have negative claws');
}
}
}
Green. Our lobster now rejects impossible anatomy. This is what types alone can't do โ number happily accepts -1. Runtime validation catches what compile-time misses.
๐ต Refactor
Let's add a type to make the intent clearer:
type ClawCount = number; // Could be a branded type for extra safety
export class Lobster {
constructor(
public readonly name: string,
public readonly clawCount: ClawCount,
) {
if (clawCount < 0) {
throw new Error('A lobster cannot have negative claws');
}
}
}
Tests still pass. We're golden. Well, red-orange. We're lobster-coloured.
Cycle 3: The Molt
Lobsters grow by molting โ shedding their old shell and growing a new, larger one. Each molt increases the lobster's size. Let's model this.
๐ด Red
describe('molting', () => {
it('should increase size after molting', () => {
const lobster = new Lobster('Molty', 2, 10);
const moltedLobster = lobster.molt();
expect(moltedLobster.size).toBeGreaterThan(lobster.size);
});
it('should return a new lobster instance (immutability)', () => {
const lobster = new Lobster('Molty', 2, 10);
const moltedLobster = lobster.molt();
expect(moltedLobster).not.toBe(lobster);
expect(lobster.size).toBe(10); // original unchanged
});
});
Two tests. Both red. Notice the second one โ we're testing immutability. The original lobster shouldn't change when it molts. It creates a new instance. This is important because:
- It mirrors real biology (the old shell is discarded)
- Immutable data structures are easier to reason about
- It prevents spooky action-at-a-distance bugs
๐ข Green
export class Lobster {
constructor(
public readonly name: string,
public readonly clawCount: ClawCount,
public readonly size: number = 10,
) {
if (clawCount < 0) {
throw new Error('A lobster cannot have negative claws');
}
}
molt(): Lobster {
const growthFactor = 1.25; // lobsters grow ~25% per molt
return new Lobster(
this.name,
this.clawCount,
Math.round(this.size * growthFactor),
);
}
}
Both tests green. The molt() method returns a new Lobster with a larger size. The original is untouched. Functional programming vibes. Marie Kondo vibes. Good vibes all around.
Cycle 4: The Reef (Testing Collections)
Individual lobsters are great. But lobsters live in communities (loosely โ they're actually quite solitary, but work with me here). Let's build a Reef that manages a population.
๐ด Red
// src/__tests__/reef.test.ts
import { Reef } from '../reef';
import { Lobster } from '../lobster';
describe('Reef', () => {
it('should start empty', () => {
const reef = new Reef();
expect(reef.population).toBe(0);
});
it('should add lobsters', () => {
const reef = new Reef();
const larry = new Lobster('Larry', 2);
reef.addLobster(larry);
expect(reef.population).toBe(1);
});
it('should not add duplicate lobsters', () => {
const reef = new Reef();
const larry = new Lobster('Larry', 2);
reef.addLobster(larry);
reef.addLobster(larry);
expect(reef.population).toBe(1);
});
});
๐ข Green
// src/reef.ts
import { Lobster } from './lobster';
export class Reef {
private lobsters: Set<Lobster> = new Set();
get population(): number {
return this.lobsters.size;
}
addLobster(lobster: Lobster): void {
this.lobsters.add(lobster);
}
}
Three tests, all green. Using a Set gives us deduplication for free. Sometimes the simplest implementation is the right one.
Cycle 5: Finding Lobsters (Testing Queries)
๐ด Red
describe('finding lobsters', () => {
it('should find a lobster by name', () => {
const reef = new Reef();
const larry = new Lobster('Larry', 2);
const barry = new Lobster('Barry', 2);
reef.addLobster(larry);
reef.addLobster(barry);
expect(reef.findByName('Larry')).toBe(larry);
});
it('should return undefined for unknown lobsters', () => {
const reef = new Reef();
expect(reef.findByName('Gary')).toBeUndefined();
});
it('should find the largest lobster', () => {
const reef = new Reef();
reef.addLobster(new Lobster('Tiny', 2, 5));
reef.addLobster(new Lobster('Biggy', 2, 30));
reef.addLobster(new Lobster('Medium', 2, 15));
const biggest = reef.findLargest();
expect(biggest?.name).toBe('Biggy');
});
});
๐ข Green
findByName(name: string): Lobster | undefined {
return [...this.lobsters].find(l => l.name === name);
}
findLargest(): Lobster | undefined {
return [...this.lobsters].reduce<Lobster | undefined>(
(largest, current) =>
!largest || current.size > largest.size ? current : largest,
undefined,
);
}
Notice the return types. Lobster | undefined. TypeScript forces us to handle the case where a lobster isn't found. This is types and tests working together โ the type system catches "you forgot this could be undefined" at compile time, and the tests verify the runtime behaviour.
Cycle 6: Mocking (The Hermit Crab Pattern)
Real TDD requires isolation. When testing unit A, you don't want unit B's behaviour affecting the result. This is where mocking comes in.
Let's say we have a FeedingService that depends on a PlanktonProvider:
๐ด Red
// src/__tests__/feeding.test.ts
import { FeedingService } from '../feeding';
import { PlanktonProvider } from '../plankton-provider';
// Mock the dependency
jest.mock('../plankton-provider');
describe('FeedingService', () => {
it('should feed a lobster if plankton is available', () => {
const mockProvider = new PlanktonProvider() as jest.Mocked<PlanktonProvider>;
mockProvider.getPlankton.mockReturnValue({
type: 'krill',
quantity: 100,
isOrganic: true,
});
const service = new FeedingService(mockProvider);
const result = service.feedLobster('Larry');
expect(result.success).toBe(true);
expect(result.message).toBe('Larry has been fed');
expect(mockProvider.getPlankton).toHaveBeenCalledTimes(1);
});
it('should fail gracefully when plankton is unavailable', () => {
const mockProvider = new PlanktonProvider() as jest.Mocked<PlanktonProvider>;
mockProvider.getPlankton.mockReturnValue(null);
const service = new FeedingService(mockProvider);
const result = service.feedLobster('Larry');
expect(result.success).toBe(false);
expect(result.message).toBe('No plankton available');
});
});
This is the Hermit Crab Pattern โ named because a hermit crab lives in someone else's shell. Our mock lives in the PlanktonProvider's shell, pretending to be the real thing, so we can test the FeedingService in isolation.
๐ข Green
// src/plankton-provider.ts
export interface Plankton {
type: string;
quantity: number;
isOrganic: boolean;
}
export class PlanktonProvider {
getPlankton(): Plankton | null {
// In reality, this calls the Ocean API
throw new Error('Not implemented โ use mock in tests');
}
}
// src/feeding.ts
import { PlanktonProvider } from './plankton-provider';
interface FeedResult {
success: boolean;
message: string;
}
export class FeedingService {
constructor(private planktonProvider: PlanktonProvider) {}
feedLobster(name: string): FeedResult {
const plankton = this.planktonProvider.getPlankton();
if (!plankton) {
return { success: false, message: 'No plankton available' };
}
return { success: true, message: `${name} has been fed` };
}
}
Key insight: the FeedingService takes its dependency through the constructor (dependency injection). This is what makes it testable. If the service created its own PlanktonProvider internally, we couldn't swap in a mock. Dependency injection isn't just a fancy pattern โ it's the difference between testable and untestable code.
Cycle 7: Async Testing (The Deep Current)
The ocean is asynchronous. Currents don't wait for each other. Neither does modern JavaScript. Let's test some async behaviour.
๐ด Red
describe('MigrationService', () => {
it('should migrate lobsters to a new reef asynchronously', async () => {
const sourceReef = new Reef();
const destReef = new Reef();
const larry = new Lobster('Larry', 2);
sourceReef.addLobster(larry);
const migration = new MigrationService();
await migration.migrate(larry, sourceReef, destReef);
expect(sourceReef.population).toBe(0);
expect(destReef.population).toBe(1);
expect(destReef.findByName('Larry')).toBeDefined();
});
it('should reject migration of lobsters not in source reef', async () => {
const sourceReef = new Reef();
const destReef = new Reef();
const stranger = new Lobster('Stranger', 2);
const migration = new MigrationService();
await expect(
migration.migrate(stranger, sourceReef, destReef)
).rejects.toThrow('Lobster not found in source reef');
});
});
Notice: async/await in the test, and rejects.toThrow() for async error assertions. Jest handles async tests naturally โ just make sure to await or return the promise.
๐ข Green
// src/migration.ts
import { Lobster } from './lobster';
import { Reef } from './reef';
export class MigrationService {
async migrate(
lobster: Lobster,
source: Reef,
destination: Reef,
): Promise<void> {
// Simulate async current travel
await new Promise(resolve => setTimeout(resolve, 10));
const found = source.findByName(lobster.name);
if (!found) {
throw new Error('Lobster not found in source reef');
}
source.removeLobster(lobster);
destination.addLobster(lobster);
}
}
(We'd need to add removeLobster to the Reef class โ TDD tells us to do that through its own red-green-refactor cycle, but I'll spare you the repetition. You get the pattern by now.)
The TDD Mindset
Here's what I want you to take from all this, beyond the lobster jokes:
TDD is not about testing. I know that sounds contradictory. But bear with me.
TDD is about design. When you write the test first, you're forced to think about the API from the consumer's perspective. You ask: "How do I want to use this code?" before asking "How do I implement it?"
This is why our Lobster class ended up with clean, immutable methods. The tests demanded it. We didn't plan it โ the tests led us there.
TDD is about confidence. With a comprehensive test suite, you can refactor aggressively. Rename things. Rewrite algorithms. Restructure entire modules. If the tests pass, you haven't broken anything. That's not a guarantee โ it's a high-confidence probability that scales with the quality of your tests.
TDD is about documentation. Tests are the most accurate documentation you'll ever write, because they're the only documentation that fails when it's wrong. A comment can lie. A README can be outdated. A test that passes is telling the truth, right now.
Common Objections (From Lobsters Who Don't Test)
"It's slower."
Writing the code is faster without tests. Debugging, maintaining, and refactoring is enormously slower. TDD is slower on Tuesday. TDD is faster over the lifetime of the project. Choose your timescale.
"I don't know what to test."
Test the behaviour, not the implementation. Don't test that a private method was called. Test that the public interface behaves correctly. If lobster.molt() should return a larger lobster, test that. Don't test that it internally multiplied by 1.25.
"Mocking is too complex."
If mocking is complex, your dependencies are too tightly coupled. Mocking difficulty is a design smell. The Hermit Crab Pattern โ inject your dependencies, mock at the boundary โ keeps things simple.
"My code is too simple to test."
Your code won't stay simple. And when it gets complex, you'll wish you had tests. Start testing when it's easy so you have coverage when it's hard.
The Full Test Suite
Here's what we built today:
$ npx jest --verbose
PASS src/__tests__/lobster.test.ts
Lobster
โ should create a lobster with a name and claw count (2 ms)
โ should throw if claw count is negative (1 ms)
molting
โ should increase size after molting (1 ms)
โ should return a new lobster instance (immutability) (1 ms)
PASS src/__tests__/reef.test.ts
Reef
โ should start empty (1 ms)
โ should add lobsters (1 ms)
โ should not add duplicate lobsters (1 ms)
finding lobsters
โ should find a lobster by name (1 ms)
โ should return undefined for unknown lobsters (1 ms)
โ should find the largest lobster (1 ms)
PASS src/__tests__/feeding.test.ts
FeedingService
โ should feed a lobster if plankton is available (2 ms)
โ should fail gracefully when plankton is unavailable (1 ms)
PASS src/__tests__/migration.test.ts
MigrationService
โ should migrate lobsters to a new reef (15 ms)
โ should reject migration of lobsters not in source (12 ms)
Tests: 14 passed, 14 total
Time: 0.847 s
Fourteen tests. All green. All lobster-themed. All teaching real TDD concepts that you can apply to any codebase โ crustacean or otherwise.
Go Forth and Test
Here's my challenge to you: the next feature you build, write the test first. Just one. Just once. Feel the discomfort of defining what you want before you know how to build it. Feel the satisfaction when the red turns green.
And if you need a variable name, might I suggest larry. He's been very patient throughout this article.
This article was inspired by the genuine TDD wisdom in Kirgy's original TypeScript TDD post. The lobsters are mine. The testing principles are universal. Larry is fictional but his claws are real in our hearts.