$ The Feedback Loop Series Part 1: Test-Driven Development and the Foundation of Observable Engineering
TDD isn't just about testing—it's about creating fast, objective feedback loops that make software observable. Understanding this foundation is critical for modern development, from frontend tooling to AI agents.
This is Part 1 of a 3-part series on feedback loops in software engineering. Read the overview | Part 2: UI Feedback Patterns | Part 3: AI Agent Loops
The Misunderstood Practice
Test-driven development gets a bad rap. I’ve heard countless objections:
- “It slows me down”
- “It’s dogmatic”
- “I don’t have time to write tests first”
- “My code is too complex for TDD”
- “It only works for simple CRUD apps”
Here’s the truth: if you’re arguing against TDD, you’re probably misunderstanding what TDD actually is.
TDD isn’t about testing. It’s about creating a feedback loop that makes your software observable.
What TDD Actually Is
Strip away the ceremony, the dogma, the “rules”—and TDD is simply:
1. Externalize your expectation
test('user can log in with valid credentials', () => {
// This test IS your specification
const user = { email: '[email protected]', password: 'secure123' };
const result = login(user);
expect(result.success).toBe(true);
expect(result.token).toBeDefined();
});
2. Observe it fail
❌ user can log in with valid credentials
ReferenceError: login is not defined
3. Make it pass (minimally)
function login(user) {
// Simplest thing that could possibly work
return {
success: true,
token: 'mock-token'
};
}
4. Refine and refactor
function login(user) {
// Now add real logic
const validUser = validateCredentials(user);
if (!validUser) return { success: false };
const token = generateAuthToken(validUser);
return { success: true, token };
}
This cycle is not unique to testing. It’s universal.
The Psychology of TDD: Why It Actually Works
TDD works because it hacks your brain in specific, powerful ways:
1. Externalized Intent
When you write a test first, you’re forced to articulate what success looks like before you start coding.
Without TDD:
"I need to build login... let me start coding...
oh wait, what about password validation...
and session management...
and error cases..."
With TDD:
"First test: user can log in with valid credentials.
That's it. That's the only thing I'm building right now."
One clear goal. One feedback mechanism.
2. Objective Judge
Tests remove ambiguity. You’re not asking yourself “does this work?” You’re asking the test whether it works.
Your brain doesn’t have to:
- Remember the requirements
- Manually verify edge cases
- Wonder if you broke something
- Question whether you’re done
The test tells you.
3. Immediate Feedback
The TDD cycle should be fast:
- Write test: 30 seconds
- Watch it fail: 2 seconds
- Make it pass: 2 minutes
- Run tests: 5 seconds
Total loop time: ~3 minutes
Compare this to:
- Write code: 30 minutes
- Deploy to staging: 10 minutes
- Manually test: 15 minutes
- Find bug: 5 minutes
- Debug: 20 minutes
- Repeat…
Total loop time: 80+ minutes
The faster your feedback, the faster you learn, the faster you ship.
4. Reduced Cognitive Load
You’re not holding the entire system in your head. You’re focused on:
- This test
- This implementation
- This outcome
Everything else is verified by the existing tests.
This is how you build complex systems without going insane.
The Red-Green-Refactor Cycle: Deeper Than You Think
“Red → Green → Refactor” is taught as a mantra, but let’s examine what’s really happening:
Red: Create a Falsifiable Expectation
test('calculateTotal handles empty cart', () => {
expect(calculateTotal([])).toBe(0);
});
You’re creating a falsifiable expectation. This is scientific method applied to code:
- Hypothesis: “An empty cart should total zero”
- Experiment: Run the test
- Result: Pass or fail
Red proves your test can fail. This is critical—a test that never fails is worthless.
Green: Make It Work (Not Perfect)
function calculateTotal(items) {
if (items.length === 0) return 0;
// That's it. Just make it pass.
}
Resist the urge to over-engineer. Just. Make. It. Pass.
Why? Because you’re building incrementally. Each test adds one more constraint. The full solution emerges through accumulation, not up-front design.
Refactor: Make It Right
function calculateTotal(items) {
// Now we have multiple tests passing
// We can safely refactor
return items.reduce((sum, item) => {
const price = item.price || 0;
const quantity = item.quantity || 1;
return sum + (price * quantity);
}, 0);
}
Refactoring is only safe because you have tests. Without tests, refactoring is just “changing stuff and hoping.”
TDD Scales to Any Complexity
I hear “TDD doesn’t work for complex systems” all the time. This is backwards.
TDD is MORE valuable for complex systems, not less.
Example: Complex State Machine
Imagine building a state machine for order processing:
- New → Pending → Processing → Shipped → Delivered
- With edge cases: cancellations, refunds, partial shipments
Without TDD, you write a massive state machine and manually test every path. Good luck.
With TDD, you build it incrementally:
// Test 1: Basic transition
test('order transitions from new to pending', () => {
const order = new Order();
order.submit();
expect(order.state).toBe('pending');
});
// Test 2: Can't skip states
test('order cannot go directly to shipped', () => {
const order = new Order();
expect(() => order.ship()).toThrow('Invalid state transition');
});
// Test 3: Cancellation from pending
test('order can be cancelled when pending', () => {
const order = new Order();
order.submit();
order.cancel();
expect(order.state).toBe('cancelled');
});
// Test 4: Cannot cancel after shipped
test('order cannot be cancelled after shipping', () => {
const order = new Order();
order.submit();
order.process();
order.ship();
expect(() => order.cancel()).toThrow('Cannot cancel shipped order');
});
Each test adds one rule. The complexity builds up gradually, and every rule is verified.
TDD and the Dunning-Kruger Effect
There’s a fascinating relationship between TDD adoption and the Dunning-Kruger effect:
Beginners: “TDD is amazing! I’m writing tests for everything!”
Intermediate: “TDD is slowing me down. I know what I’m doing, I don’t need tests.”
Advanced: “TDD is essential. My code is too complex to trust without tests.”
The intermediate stage is where most people get stuck. They’ve gained enough skill to write code quickly, but not enough experience to have been burned by their own bugs repeatedly.
I’ve been burned. Many times. That’s why I use TDD.
What You Can TDD (Hint: Everything)
A common misconception: “TDD only works for business logic.”
False. You can TDD:
1. Algorithms and Data Structures
test('binary search finds element in sorted array', () => {
expect(binarySearch([1, 3, 5, 7, 9], 5)).toBe(2);
expect(binarySearch([1, 3, 5, 7, 9], 10)).toBe(-1);
});
2. API Integrations
test('fetches user profile from API', async () => {
const mockFetch = jest.fn().mockResolvedValue({
json: async () => ({ id: 1, name: 'John' })
});
const profile = await getUserProfile(1, mockFetch);
expect(profile.name).toBe('John');
});
3. Database Queries
test('finds active users created in last 30 days', async () => {
const users = await db.users.findActive({ daysAgo: 30 });
expect(users.every(u => u.active)).toBe(true);
expect(users.every(u => isRecent(u.createdAt, 30))).toBe(true);
});
4. UI Component Logic
test('form validates email format', () => {
const form = new ContactForm();
form.setEmail('invalid-email');
expect(form.isValid()).toBe(false);
expect(form.errors.email).toBe('Invalid email format');
});
5. System Integration
test('order creates payment and updates inventory', async () => {
const order = await processOrder({ item: 'widget', quantity: 5 });
expect(order.paymentStatus).toBe('charged');
expect(await getInventory('widget')).toBe(95); // Started at 100
});
If you can verify it, you can TDD it.
The TDD Mindset: Expectations and Observations
Here’s the key insight that changed how I think about software:
Engineering is the discipline of creating expectations and observing whether reality matches.
This applies everywhere:
- Science: hypothesis → experiment → result
- Manufacturing: specification → production → quality control
- Software: test → implementation → verification
TDD is simply making this process explicit and fast.
When NOT to TDD (Yes, Really)
TDD isn’t dogma. There are legitimate cases where you skip it:
1. Spike/Prototype Code
When you’re exploring an unfamiliar API or architecture, write throwaway code first. Learn. Then TDD the real implementation.
2. Truly Trivial Code
// Do you really need a test for this?
function getUserName(user) {
return user.name;
}
Use judgment. But be honest about what’s “trivial.”
3. Visual/Interactive Exploration
When you’re tweaking CSS or exploring UI interactions, visual feedback is your loop. (We’ll cover this in Part 2.)
4. When You Have a Better Feedback Loop
If you have a REPL, a live reload system, or another fast feedback mechanism—use it. TDD is about the loop, not the specific technique.
TDD Prepares You for the Future
Here’s why understanding TDD matters even more in an AI-first world:
AI agents need feedback loops too.
When you ask an agent to generate code, the agent needs to know:
- What is the goal? (The test defines it)
- Did it work? (The test verifies it)
- What broke? (The test shows failures)
Tests are specifications that agents can verify.
The engineers who understand TDD will naturally understand how to:
- Create verifiable expectations for agents
- Design objective evaluation criteria
- Build systems that agents can iterate on safely
Practical TDD: Starting Tomorrow
If you want to adopt TDD, start small:
Week 1: Write Tests for Bugs
When you find a bug:
- Write a failing test that reproduces it
- Fix the bug
- Confirm the test passes
You’re building a regression suite and practicing the TDD cycle.
Week 2: TDD One Function Per Day
Pick one new function per day. TDD it. Just one.
Week 3: TDD One Feature
Pick a small feature. Commit to TDD-ing the whole thing.
Week 4: Reflect
Did TDD help? Where did it slow you down? Where did it catch bugs?
Adjust your approach based on feedback. (See what I did there?)
The Foundation for Everything Else
TDD teaches you the fundamental pattern of software engineering:
- Create an expectation
- Observe the outcome
- Iterate
This pattern applies to:
- Frontend development (Part 2)
- UI component libraries
- API design
- System architecture
- AI agent orchestration (Part 3)
Master TDD, and you’re mastering the universal pattern of feedback-driven development.
Next: Part 2 - UI Feedback Patterns: Hot Reload, Storybook, and Visual Development
Read the full overview: The Software Feedback Loop: From TDD to Agentic AI Development
This post was co-created with Claude Code, using the feedback-driven approach it describes.