The case for TDD vibe coding

TLDR: TDD was hyped but never fully reached the mainstream. Now it could be what makes vibe engineering more sustainable.

I have been trying to find a good balance between traditional coding as in 2020 and using AI coding assistants. It gets frustrating pretty quickly when you have a clear instruction and the agentic model touches 5 files instead of the one you specified. Also it's hard to check every single line of code that is generated. I feel like cursor's or Zed's auto-completion is way more pleasant to use than leaving the task to an agentic model entirely. But as I try to find good ways to automate parts of my workflow I have been thinking of how TDD could be a good fit for vibe engineering.

Why TDD?

TDD stands for Test Driven Development and it is a software development technique that involves writing tests before writing the code. Tests are then used to verify that the code works as expected. TDD is great when you already have defined requirements up to the unit level. And if not, TDD actually forces you to define and plan ahead of time. Many projects nowadays lack a plan and predefined requirements and only start with vague ideas of how a new feature should work.

Software tests in general also work as a form of documentation. When you write a test, you are essentially writing a specification of how the code should work and documenting relevant examples. This helps new developers in your team and nowadays also has the benefit of helping AI agents understand the codebase better. You can write rules for your agentic models to first check the tests or to always run the tests as a form of validation. And with newer testing frameworks like Playwright and Vitest they also run pretty fast.

What can be TDD'd?

Not everything is a good fit for this type of coding. Whenever you do not have clear requirements or you are exploring a new domain or feature, TDD might not be a good fit. On the other hand, if you have a clear idea and you can define it out further in advance, TDD can be the thing that makes the difference in your productivity.

Real example

For a hobby project of mine I recently needed a way to check if a user has a certain entitlement. In Supabase the user table has a JSON column called app_metadata where you can store arbitrary data about the user. I decided to use this column to store the entitlements. Let's start with the tests. You can write them yourself or let the AI do that for you but make sure to double check what it wrote.

describe('hasEntitlements', () => {
    const createAppMetadata = (entitlements?: string[]): AppMetadata => ({
        role: 'authenticated',
        provider: 'email',
        providers: ['email'],
        entitlements,
    });

    it('should return true when appMetadata contains one of the requested entitlements', () => {
        const appMetadata = createAppMetadata(['beta-tester', 'premium-feature']);
        expect(hasEntitlements(['beta-tester'], appMetadata)).toBe(true);
        expect(hasEntitlements(['premium-feature'], appMetadata)).toBe(true);
        expect(hasEntitlements(['beta-tester', 'premium-feature'], appMetadata)).toBe(true);
    });

    it('should return false when appMetadata does not contain any of the requested entitlements', () => {
        const appMetadata = createAppMetadata(['some-other-entitlement']);
        expect(hasEntitlements(['beta-tester'], appMetadata)).toBe(false);
        expect(hasEntitlements(['beta-tester', 'premium-feature'], appMetadata)).toBe(false);
    });

    it('should return false when appMetadata is undefined', () => {
        expect(hasEntitlements(['beta-tester'], undefined)).toBe(false);
    });

    it('should return false when entitlements array is empty', () => {
        const appMetadata = createAppMetadata([]);
        expect(hasEntitlements(['beta-tester'], appMetadata)).toBe(false);
    });

    it('should return true when at least one entitlement matches', () => {
        const appMetadata = createAppMetadata(['entitlement1', 'entitlement2']);
        expect(hasEntitlements(['entitlement1', 'entitlement3'], appMetadata)).toBe(true);
    });

    it('should return false when no entitlements match', () => {
        const appMetadata = createAppMetadata(['entitlement1', 'entitlement2']);
        expect(hasEntitlements(['entitlement3', 'entitlement4'], appMetadata)).toBe(false);
    });

    it('should return false when entitlements array is empty', () => {
        const appMetadata = createAppMetadata([]);
        expect(hasEntitlements([], appMetadata)).toBe(false);
    });
});

When writing the tests make sure to check for the edge cases as well. In this case I have also checked what happens when the appMetadata is undefined or empty.

Now let's vibe code the function to pass the tests.

export function hasEntitlements(entitlements: string[], appMetadata?: AppMetadata): boolean {
  if (!appMetadata) return false
  return appMetadata?.entitlements?.some(entitlement => entitlements.includes(entitlement)) === true || false;
}

Now let's run the tests.

✓ hasEntitlements > should return true when appMetadata contains one of the requested entitlements [0.05ms]
✓ hasEntitlements > should return false when appMetadata does not contain any of the requested entitlements [0.01ms]
✓ hasEntitlements > should return false when appMetadata is undefined
✓ hasEntitlements > should return false when entitlements array is empty [0.02ms]
✓ hasEntitlements > should return true when at least one entitlement matches
✓ hasEntitlements > should return false when no entitlements match
✓ hasEntitlements > should return false when entitlements array is empty

Well that was easy but hopefully you get the idea. Try it out and maybe it's a good addition to your workflow.