Skip to main content
Engineering/tdd-tests

TDD Tests

Generate failing tests from acceptance criteria.

Use this when you have a user story with Gherkin acceptance criteria and need to generate failing tests before writing implementation code. Maps Given/When/Then directly to test setup/action/assertion. This is the first step in the TDD++ workflow -- tests come before code.

Full guide: See the TDD guide for additional workflows (ping-pong, refactoring, pinning tests) and the TDD guide for the full practice.

Process

Step 1: Gather inputs

Ask the user:

  1. The user story -- paste the full story with Gherkin acceptance criteria (Given/When/Then scenarios)
  2. Tech stack -- language, test framework, and relevant libraries (e.g., TypeScript + Jest + React Testing Library, Python + pytest, Ruby + RSpec)
  3. Existing code context -- (optional) relevant existing code, module structure, or API contracts the tests should work against

If the story doesn't have Gherkin acceptance criteria, ask the user to provide them or offer to help write them first (see /story-write).

Step 2: Map criteria to tests

For each acceptance criterion in the story, generate a failing test:

  • Given → test setup (arrange)
  • When → action (act)
  • Then → assertion (assert)
  • And/But → additional setup, actions, or assertions within the same test

Output the tests as a complete, runnable test file:

// Example structure (adapt to the user's tech stack)

describe('(Feature name)', () => {

  // Scenario 1: (Scenario name from acceptance criteria)
  it('should (expected behavior from Then clause)', () => {
    // Given: (setup from Given clause)
    // When: (action from When clause)
    // Then: (assertion from Then clause)
  });

  // Scenario 2: (Error path scenario name)
  it('should (error handling behavior)', () => {
    // Given: (error setup)
    // When: (action that triggers error)
    // Then: (error assertion)
  });

  // Scenario 3: (Edge case scenario name)
  it('should (edge case behavior)', () => {
    // Given: (edge case setup)
    // When: (action)
    // Then: (expected behavior)
  });
});

Step 3: Apply test design techniques

After the initial AC-to-test mapping, expand coverage using formal test design techniques:

For each input in the AC:

  • Boundary value analysis (BVA): Test at min, max, min-1, max+1, zero, negative for numeric inputs
  • Equivalence class partitioning (ECP): Test one value from each valid and invalid partition
  • Decision tables: For features with multiple interacting conditions, test key combinations

Add expanded tests and mark them as // [BVA], // [ECP], or // [DT] to trace which technique generated them.

Related skills: For a more thorough expansion, use /test-case-design before generating tests.

Step 4: Check for missing coverage

After generating tests for all acceptance criteria, review for gaps:

  • Are there edge cases not covered by the acceptance criteria? (Empty states, max lengths, concurrent actions, invalid inputs)
  • Are error paths covered? (Network failures, auth failures, validation errors)
  • Are boundary conditions tested? (First item, last item, zero, one, many)

Propose additional tests for any gaps found. Mark them clearly as "additional coverage beyond AC."

Step 5: Wire the tests as agent guardrails (Spec-Driven Development)

If the implementation will be written by a coding agent (Claude Code, Copilot, Cursor, Codex), the failing tests are not just a Red phase, they are the executable guardrail layer of a versioned spec. In 2026 Spec-Driven Development, the spec is the primary artifact and code is generated from it. Tools like GitHub Spec Kit make the spec the source of truth, with 30-plus agent integrations. Your generated tests are what keep the agent from operating in a vacuum.

Translate each test into an agent acceptance constraint:

  • Promote assertions into the spec's Success Criteria. Copy the "these inputs produce these outputs" assertions (including the BVA, ECP, and DT cases from Step 3) into a ## Success Criteria block the agent reads before writing code. The spec states the behavior; the test file proves it.
  • Pin the test file as a non-negotiable. Instruct the agent that the test file is fixed: it implements until the suite goes green, and it does not edit the tests to make them pass. This is the architectural separation TDD always provided, now enforced against an autonomous writer.
  • Commit the failing tests first. A committed Red state gives the agent a verifiable target and gives you a clean diff to review. The test file is the contract; the agent's job is to satisfy it, not redefine it.

State plainly which tests are guardrails and which are exploratory. If you can't yet assert exact outputs for a scenario, mark it rather than letting the agent invent the expected behavior.

Related skills: Hand the green-phase work to /tdd-implement, then /tdd-refactor.

Step 6: Review with the user

Present the test file and ask:

  • Do these tests capture what the acceptance criteria intended?
  • Are the test names clear enough that a failing test tells you exactly what's broken?
  • Is the setup realistic for your codebase? (Mocking strategy, test data, dependencies)
  • Any tests that seem redundant or that test the wrong thing?

Make edits as needed.

Related skills

  • /story-write -- if the story doesn't have Gherkin acceptance criteria yet, write them first.

Output location

Present the test file as a code block in the conversation. The user copies it into their codebase and runs it to confirm all tests fail (Red phase of TDD).

Example Output

Input

  • User story: As a customer, I want to apply a promo code at checkout so that I receive a discount on my order. Acceptance Criteria: Scenario 1 (Valid code): Given a cart totaling $120 and a valid promo code "SAVE20" offering 20% off, When the customer applies the code, Then the order total should be $96 and a success message "Promo code applied" should display. Scenario 2 (Expired code): Given a cart totaling $120 and an expired promo code "SUMMER23", When the customer applies the code, Then the total should remain $120 and an error "Promo code expired" should display. Scenario 3 (Invalid code): Given any cart and an unrecognized code "FAKECODE", When the customer applies the code, Then an error "Invalid promo code" should display.
  • Tech stack: TypeScript + Jest + React Testing Library
  • Existing code context: applyPromoCode(code: string, cartTotal: number): Promise<{ total: number; message: string; error?: string }> lives in src/checkout/promoService.ts; the UI component is src/checkout/PromoCodeInput.tsx

Output

TDD Tests: Promo Code at Checkout

All tests below are written to fail until the implementation exists. Copy into your repo and confirm red before writing code.

// src/checkout/__tests__/promoCode.test.tsx

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { applyPromoCode } from '../promoService';
import { PromoCodeInput } from '../PromoCodeInput';

jest.mock('../promoService');
const mockApplyPromoCode = applyPromoCode as jest.MockedFunction<typeof applyPromoCode>;

describe('Promo Code at Checkout', () => {

  beforeEach(() => {
    jest.clearAllMocks();
  });

  // ─── Acceptance Criteria Tests ───────────────────────────────────────────

  // Scenario 1: Valid promo code applies discount
  it('should reduce order total and show success message when a valid code is applied', async () => {
    // Given: a cart totaling $120 and valid code "SAVE20" (20% off)
    mockApplyPromoCode.mockResolvedValue({ total: 96, message: 'Promo code applied' });
    render(<PromoCodeInput cartTotal={120} />);

    // When: the customer enters and submits the promo code
    await userEvent.type(screen.getByRole('textbox', { name: /promo code/i }), 'SAVE20');
    fireEvent.click(screen.getByRole('button', { name: /apply/i }));

    // Then: total updates to $96 and success message is shown
    await waitFor(() => {
      expect(screen.getByText('$96.00')).toBeInTheDocument();
      expect(screen.getByText('Promo code applied')).toBeInTheDocument();
    });
    expect(mockApplyPromoCode).toHaveBeenCalledWith('SAVE20', 120);
  });

  // Scenario 2: Expired promo code shows error, total unchanged
  it('should keep original total and show expiry error when an expired code is applied', async () => {
    // Given: a cart totaling $120 and expired code "SUMMER23"
    mockApplyPromoCode.mockResolvedValue({
      total: 120,
      message: '',
      error: 'Promo code expired',
    });
    render(<PromoCodeInput cartTotal={120} />);

    // When: the customer applies the expired code
    await userEvent.type(screen.getByRole('textbox', { name: /promo code/i }), 'SUMMER23');
    fireEvent.click(screen.getByRole('button', { name: /apply/i }));

    // Then: total stays at $120 and error is displayed
    await waitFor(() => {
      expect(screen.getByText('$120.00')).toBeInTheDocument();
      expect(screen.getByText('Promo code expired')).toBeInTheDocument();
    });
  });

  // Scenario 3: Unrecognized promo code shows error
  it('should show invalid code error when an unrecognized code is applied', async () => {
    // Given: any cart and an unrecognized code "FAKECODE"
    mockApplyPromoCode.mockResolvedValue({
      total: 80,
      message: '',
      error: 'Invalid promo code',
    });
    render(<PromoCodeInput cartTotal={80} />);

    // When: the customer applies the fake code
    await userEvent.type(screen.getByRole('textbox', { name: /promo code/i }), 'FAKECODE');
    fireEvent.click(screen.getByRole('button', { name: /apply/i }));

    // Then: error message is displayed
    await waitFor(() => {
      expect(screen.getByText('Invalid promo code')).toBeInTheDocument();
    });
  });

  // ─── Boundary Value Analysis [BVA] ───────────────────────────────────────

  // [BVA] Cart total at exactly $0
  it('should handle a $0 cart total without crashing', async () => {
    mockApplyPromoCode.mockResolvedValue({ total: 0, message: 'Promo code applied' });
    render(<PromoCodeInput cartTotal={0} />);
    await userEvent.type(screen.getByRole('textbox', { name: /promo code/i }), 'SAVE20');
    fireEvent.click(screen.getByRole('button', { name: /apply/i }));
    await waitFor(() => expect(screen.getByText('$0.00')).toBeInTheDocument());
  });

  // [BVA] Cart total of $0.01 (minimum non-zero order)
  it('should apply a discount to a $0.01 cart total', async () => {
    mockApplyPromoCode.mockResolvedValue({ total: 0.01, message: 'Promo code applied' });
    render(<PromoCodeInput cartTotal={0.01} />);
    await userEvent.type(screen.getByRole('textbox', { name: /promo code/i }), 'SAVE20');
    fireEvent.click(screen.getByRole('button', { name: /apply/i }));
    await waitFor(() => expect(screen.getByText('$0.01')).toBeInTheDocument());
  });

  // [BVA] Very large cart total ($99,999.99)
  it('should correctly apply a discount to a large cart total', async () => {
    mockApplyPromoCode.mockResolvedValue({ total: 79999.99, message: 'Promo code applied' });
    render(<PromoCodeInput cartTotal={99999.99} />);
    await userEvent.type(screen.getByRole('textbox', { name: /promo code/i }), 'SAVE20');
    fireEvent.click(screen.getByRole('button', { name: /apply/i }));
    await waitFor(() => expect(screen.getByText('$79,999.99')).toBeInTheDocument());
  });

  // ─── Equivalence Class Partitioning [ECP] ────────────────────────────────

  // [ECP] Empty promo code string (invalid input class)
  it('should not call the service and should show a validation error when code is empty', async () => {
    render(<PromoCodeInput cartTotal={120} />);
    fireEvent.click(screen.getByRole('button', { name: /apply/i }));
    expect(screen.getByText('Please enter a promo code')).toBeInTheDocument();
    expect(mockApplyPromoCode).not.toHaveBeenCalled();
  });

  // [ECP] Code with only whitespace (invalid input class)
  it('should treat a whitespace-only code as empty and show a validation error', async () => {
    render(<Pro

Run this now

0/4000

Part of these Playbook topics