Skip to content
Lesson 14 of 22

Testing Without Pain

16 min read

Why Testing Matters for Vibe Coders

When you're vibe coding, AI makes changes fast. Really fast. It can refactor an entire component, rewrite a database query, or restructure an API endpoint in seconds. That speed is a superpower — but it's also a risk. A single change can break something in a completely different part of your app, and without tests, you won't know until a user reports it.

Tests are your safety net. They catch bugs before users do. Here's why they matter especially for vibe coders:

AI makes changes fast — tests catch what humans miss. When AI rewrites your checkout flow, tests verify that existing users can still complete a purchase. You're moving fast, and tests keep you from moving fast in the wrong direction.

Tests are documentation. A well-written test tells you exactly what a function or component is supposed to do. When you come back to code six months later and wonder "what does this do?", the test suite has the answer.

Tests give confidence to refactor. Want to switch from one database library to another? Rewrite your API layer? Move to a new framework version? If you have good tests, you refactor boldly. Without them, you refactor anxiously.

Tests save time long-term. Yes, writing tests takes time upfront. But debugging a production issue at 2 AM takes more time, costs more money, and ruins more sleep. Tests are an investment that pays off every single time.

And here's the best part: AI writes your tests for you. You describe what should be tested, and AI generates the test code. Testing has never been more accessible.

The Testing Pyramid

Not all tests are created equal. The testing pyramid helps you balance speed, scope, and confidence.

Unit Tests (The Base)

Unit tests check individual functions and components in isolation. They're fast (milliseconds), focused, and you should have the most of them.

Examples:

  • Does the calculateTotal function add up prices correctly?
  • Does the formatDate utility return the right string?
  • Does the Button component render with the correct text?

Integration Tests (The Middle)

Integration tests check how pieces work together. They're medium speed (seconds) and test the connections between units.

Examples:

  • Does the API route validate input and return the right response?
  • Does the form component submit data and show a success message?
  • Does the database query return the expected results with filters?

End-to-End Tests (The Top)

E2E tests simulate real user behavior in a real browser. They're slow (seconds to minutes) and you should have the fewest, focused on critical user flows.

Examples:

  • Can a user sign up, create a project, and invite a teammate?
  • Can a user add items to cart, enter payment, and complete checkout?
  • Can an admin user log in, view reports, and export data?

The Right Ratio

A healthy test suite looks something like:

  • 70% unit tests — fast, focused, catch logic bugs
  • 20% integration tests — verify connections work
  • 10% E2E tests — validate critical user journeys

Don't obsess over these numbers. The point is: lots of fast unit tests, some integration tests, and a few comprehensive E2E tests.

AI Writes Your Tests

This is where vibe coding transforms testing from a chore into a conversation. You don't need to know testing syntax, assertion libraries, or mocking frameworks. You describe what needs testing, and AI writes the code.

Prompt: "Write tests for the calculateShipping function. It should return 0 for orders over $50, $5.99 for standard shipping, and $12.99 for express shipping. Test edge cases like exactly $50, negative amounts, and empty cart."

AI generates:

import { describe, it, expect } from "vitest"
import { calculateShipping } from "./shipping"

describe("calculateShipping", () => {
  it("returns 0 for orders over $50", () => {
    expect(calculateShipping(75, "standard")).toBe(0)
    expect(calculateShipping(50.01, "express")).toBe(0)
  })

  it("returns $5.99 for standard shipping under $50", () => {
    expect(calculateShipping(25, "standard")).toBe(5.99)
  })

  it("returns $12.99 for express shipping under $50", () => {
    expect(calculateShipping(25, "express")).toBe(12.99)
  })

  it("returns 0 for exactly $50 (free shipping threshold)", () => {
    expect(calculateShipping(50, "standard")).toBe(0)
  })

  it("throws for negative amounts", () => {
    expect(() => calculateShipping(-10, "standard")).toThrow()
  })

  it("throws for empty cart (0 amount)", () => {
    expect(() => calculateShipping(0, "standard")).toThrow()
  })
})

You didn't write a single test. You described the expected behavior, and AI translated it into code. Your job is to review the tests, make sure they cover the right cases, and run them.

Vitest for Unit and Integration Tests

Vitest is the modern test runner for JavaScript and TypeScript projects. It's fast, compatible with the Jest API (so AI's vast knowledge of Jest tests works here too), and integrates beautifully with Vite-based projects.

Setup

npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom

Configuration

// vitest.config.ts
import { defineConfig } from "vitest/config"
import react from "@vitejs/plugin-react"
import path from "path"

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./vitest.setup.ts"],
  },
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
})
// vitest.setup.ts
import "@testing-library/jest-dom/vitest"

Add a test script to package.json:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

Writing Your First Test

The basic structure uses describe blocks to group related tests, it (or test) blocks for individual test cases, and expect for assertions:

import { describe, it, expect } from "vitest"

describe("math utilities", () => {
  it("adds two numbers correctly", () => {
    expect(add(2, 3)).toBe(5)
  })

  it("handles negative numbers", () => {
    expect(add(-1, 1)).toBe(0)
  })
})

Common Matchers

Matchers are the assertions you use to verify values:

// Exact equality
expect(result).toBe(42)

// Deep equality (objects, arrays)
expect(user).toEqual({ name: "Alice", age: 30 })

// Truthiness
expect(isValid).toBeTruthy()
expect(error).toBeFalsy()
expect(nullValue).toBeNull()
expect(value).toBeDefined()

// Numbers
expect(price).toBeGreaterThan(0)
expect(count).toBeLessThanOrEqual(100)
expect(ratio).toBeCloseTo(0.3, 2)

// Strings
expect(message).toContain("success")
expect(email).toMatch(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)

// Arrays
expect(items).toHaveLength(3)
expect(roles).toContain("admin")

// Errors
expect(() => divide(1, 0)).toThrow("Division by zero")

Testing Utility Functions

The easiest things to test — pure functions with clear inputs and outputs:

import { describe, it, expect } from "vitest"
import { slugify, truncate, formatCurrency } from "@/lib/utils"

describe("slugify", () => {
  it("converts spaces to hyphens", () => {
    expect(slugify("Hello World")).toBe("hello-world")
  })

  it("removes special characters", () => {
    expect(slugify("Hello, World!")).toBe("hello-world")
  })

  it("handles multiple spaces", () => {
    expect(slugify("hello   world")).toBe("hello-world")
  })
})

describe("formatCurrency", () => {
  it("formats USD correctly", () => {
    expect(formatCurrency(1234.5, "USD")).toBe("$1,234.50")
  })

  it("handles zero", () => {
    expect(formatCurrency(0, "USD")).toBe("$0.00")
  })
})

Testing API Routes

You can test Next.js API routes by calling the handler function directly:

import { describe, it, expect, vi } from "vitest"
import { POST } from "@/app/api/tasks/route"

describe("POST /api/tasks", () => {
  it("creates a task with valid data", async () => {
    const request = new Request("http://localhost/api/tasks", {
      method: "POST",
      body: JSON.stringify({ title: "Buy groceries", priority: "high" }),
      headers: { "Content-Type": "application/json" },
    })

    const response = await POST(request)
    const data = await response.json()

    expect(response.status).toBe(201)
    expect(data.data.title).toBe("Buy groceries")
    expect(data.data.priority).toBe("high")
  })

  it("returns 400 for missing title", async () => {
    const request = new Request("http://localhost/api/tasks", {
      method: "POST",
      body: JSON.stringify({ priority: "high" }),
      headers: { "Content-Type": "application/json" },
    })

    const response = await POST(request)
    expect(response.status).toBe(400)
  })
})

Testing React Components

Using Testing Library, you test components from the user's perspective — what they see and interact with:

import { describe, it, expect } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import { Counter } from "@/components/counter"

describe("Counter", () => {
  it("renders with initial count of 0", () => {
    render(<Counter />)
    expect(screen.getByText("Count: 0")).toBeInTheDocument()
  })

  it("increments when clicking the button", () => {
    render(<Counter />)
    fireEvent.click(screen.getByRole("button", { name: /increment/i }))
    expect(screen.getByText("Count: 1")).toBeInTheDocument()
  })

  it("accepts an initial count prop", () => {
    render(<Counter initialCount={10} />)
    expect(screen.getByText("Count: 10")).toBeInTheDocument()
  })
})

The key insight: you're testing what the user sees (getByText, getByRole), not internal state. This makes tests resilient to implementation changes.

Mocking

Sometimes you need to replace real dependencies with controlled substitutes:

import { describe, it, expect, vi } from "vitest"

// Mock an entire module
vi.mock("@/lib/db", () => ({
  db: {
    task: {
      findMany: vi.fn().mockResolvedValue([
        { id: "1", title: "Task 1" },
        { id: "2", title: "Task 2" },
      ]),
      create: vi.fn().mockResolvedValue({ id: "3", title: "New Task" }),
    },
  },
}))

// Mock a single function
const sendEmail = vi.fn()

When to mock: External APIs, databases in unit tests, timers, random number generators. When not to mock: The thing you're actually testing. Over-mocking leads to tests that pass but don't catch real bugs.

Playwright for E2E Tests

Playwright runs a real browser and simulates user actions — clicking, typing, navigating. It tests your entire application stack, from the UI through the API to the database.

Setup

npm install -D @playwright/test
npx playwright install
// playwright.config.ts
import { defineConfig } from "@playwright/test"

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  webServer: {
    command: "npm run dev",
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
  use: {
    baseURL: "http://localhost:3000",
    screenshot: "only-on-failure",
    trace: "on-first-retry",
  },
})

Writing Your First E2E Test

// e2e/homepage.spec.ts
import { test, expect } from "@playwright/test"

test("homepage loads and displays the hero section", async ({ page }) => {
  await page.goto("/")
  await expect(page.getByRole("heading", { level: 1 })).toBeVisible()
  await expect(page.getByRole("link", { name: /get started/i })).toBeVisible()
})

test("navigation links work", async ({ page }) => {
  await page.goto("/")
  await page.click("text=Features")
  await expect(page).toHaveURL("/features")
  await expect(page.getByRole("heading", { name: /features/i })).toBeVisible()
})

Testing User Flows

E2E tests shine when testing complete workflows:

// e2e/task-management.spec.ts
import { test, expect } from "@playwright/test"

test("user can create and delete a task", async ({ page }) => {
  // Login
  await page.goto("/login")
  await page.fill('[name="email"]', "test@example.com")
  await page.fill('[name="password"]', "password123")
  await page.click('button[type="submit"]')
  await expect(page).toHaveURL("/dashboard")

  // Create a task
  await page.click("text=New Task")
  await page.fill('[name="title"]', "Buy groceries")
  await page.fill('[name="description"]', "Milk, eggs, bread")
  await page.click('button:has-text("Create")')

  // Verify it appears
  await expect(page.getByText("Buy groceries")).toBeVisible()

  // Delete the task
  await page.getByText("Buy groceries").hover()
  await page.click('[aria-label="Delete task"]')
  await page.click('button:has-text("Confirm")')

  // Verify it's gone
  await expect(page.getByText("Buy groceries")).not.toBeVisible()
})

Testing Responsive Layouts

Playwright can emulate different devices:

test("mobile navigation shows hamburger menu", async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 667 })
  await page.goto("/")

  // Desktop nav should be hidden
  await expect(page.getByRole("navigation").locator(".desktop-menu")).not.toBeVisible()

  // Hamburger button should be visible
  const hamburger = page.getByRole("button", { name: /menu/i })
  await expect(hamburger).toBeVisible()

  // Click hamburger to open mobile menu
  await hamburger.click()
  await expect(page.getByRole("navigation").locator(".mobile-menu")).toBeVisible()
})

Running Tests in Headed Mode

For debugging, run Playwright with a visible browser:

npx playwright test --headed

You can also use the Playwright UI for step-by-step debugging:

npx playwright test --ui

This opens a visual interface where you can step through each action, see screenshots at each step, and inspect the DOM. It's incredibly useful for understanding why a test fails.

TDD with Vibe Coding

Test-Driven Development (TDD) follows a simple cycle: write a failing test, implement the code until the test passes, then refactor. With AI, this becomes even more powerful.

The Vibe TDD Workflow

  1. Describe the behavior you want in a test
  2. Ask AI to write the failing test based on your description
  3. Ask AI to implement the function until the test passes
  4. Repeat for the next behavior

Here's how a real TDD session looks:

You: "Write a test for a function called calculateDiscount that applies a 10% discount for orders over $100, 20% for orders over $500, and no discount otherwise."

AI writes the test. You run it — it fails because the function doesn't exist yet.

You: "Now implement calculateDiscount to pass all those tests."

AI writes the function. You run the tests — they pass. Now you're confident the function works correctly because the tests prove it.

You: "Add a test for a VIP customer who gets an extra 5% on top of the regular discount."

AI adds the test. It fails. AI updates the implementation. Tests pass. You've just built a feature with complete test coverage in minutes.

Why TDD Works So Well with AI

Traditional TDD requires developers to write tests manually, which many find tedious. With AI, you're just describing behavior in natural language. AI handles the syntax. This removes the friction that makes TDD unpopular while keeping all the benefits.

TDD also gives AI better context. When AI can see the tests, it knows exactly what the code should do. It generates more accurate implementations because the expected behavior is spelled out in assertions.

Test-Driven Prompt Patterns

These prompts reliably generate high-quality tests:

Unit test for a utility function: "Write tests for a function that calculates shipping cost based on weight and destination. Weights under 1kg cost $5, 1-5kg cost $10, over 5kg cost $15. International destinations add a $20 surcharge. Test edge cases: exactly 1kg, exactly 5kg, zero weight (should throw), negative weight (should throw)."

E2E test for a user flow: "Write a Playwright E2E test that verifies a user can sign up with email and password, create a new project called 'My Project', add a task to the project, mark the task as complete, and then delete the project."

API endpoint tests: "Write Vitest tests for the /api/tasks endpoint covering all CRUD operations: create a task (POST), list all tasks (GET), update a task (PUT), and delete a task (DELETE). Test validation errors for missing required fields and 404 responses for non-existent tasks."

React component test: "Write tests for a SearchBar component that: renders an input field, calls onSearch with the query when the user types and presses Enter, debounces the search by 300ms when typing without pressing Enter, shows a clear button when the input has text, and clears the input when the clear button is clicked."

Each prompt includes specific behaviors and edge cases. The more precise your description, the better the tests.

Coverage as a Guide

Code coverage measures how much of your code is exercised by tests. It's a useful indicator but not a quality metric.

Running Coverage Reports

npx vitest run --coverage

This generates a report showing which lines, branches, and functions are covered by your tests.

What Coverage Numbers Mean

  • 80% coverage is a reasonable target for most projects. It means the important paths are tested.
  • 100% coverage doesn't mean your code is bug-free. It means every line was executed during tests — but the tests might not check the right things.
  • Low coverage (under 50%) is a warning sign. Large portions of your code are untested, and bugs are likely hiding there.

Coverage as Direction, Not Destination

Use coverage to find gaps. If your auth module has 30% coverage, that's a problem worth fixing. If a utility function has 95% coverage and the missing 5% is an unreachable error handler, don't chase it.

Prompt: "Run the test coverage report and identify the modules with the lowest coverage. Write tests to bring the auth module from 30% to at least 80% coverage."

When to Skip Tests

Not everything needs tests. Here are legitimate reasons to skip them:

True prototypes — If you're building a throwaway prototype to validate an idea, and you'll rebuild it from scratch if the idea works, skip tests. But be honest with yourself about whether it's actually a prototype.

One-off scripts — A migration script you'll run once and never touch again? Tests are overkill.

Exploratory code — When you're experimenting with a new API or library, tests would slow you down. Explore first, test later.

But always add tests before production. If code is going to serve real users, it needs tests. The line is clear: prototype code can skip tests. Production code cannot.

A practical rule: if you'd be embarrassed to show the code to a colleague without tests, add tests.

Continuous Testing Workflow

Tests are only useful if you run them. Here's the workflow that makes testing automatic and effortless.

Run Tests Before Committing

Add a pre-commit hook that runs tests:

npx vitest run --reporter=verbose

If tests fail, the commit is blocked. This prevents broken code from entering your codebase.

Run Tests in CI

Set up your CI pipeline (GitHub Actions, for example) to run tests on every pull request:

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx vitest run --coverage
      - run: npx playwright install --with-deps
      - run: npx playwright test

Fail the Build on Test Failures

This is critical. If tests fail in CI but the build proceeds anyway, you've lost the safety net. Configure your CI to block merges when tests fail.

In GitHub, enable branch protection rules:

  1. Go to Settings > Branches > Branch protection rules
  2. Require status checks to pass before merging
  3. Select your test workflow

The Safety Net Metaphor

A safety net only works if it's deployed. Running tests occasionally is like having a net that's sometimes under the trapeze. The workflow should be: write code, run tests, commit, push, CI runs tests again, merge. No gaps in the net.

Prompt: "Set up a GitHub Actions workflow that runs Vitest unit tests and Playwright E2E tests on every push and pull request. Fail the build if any test fails. Include a coverage report."

What's Next

You now have a complete testing strategy: unit tests with Vitest for fast feedback, E2E tests with Playwright for user journey validation, and a CI pipeline that runs everything automatically. You know how to use AI to write tests, how to practice TDD for complex features, and when tests are worth the investment.

This wraps up Module III — the core technical skills for building production applications. You've learned frontend patterns, backend development, databases, authentication, and testing. In Module IV, we bring it all together by building real projects from start to finish. You'll apply everything you've learned to create complete, deployable applications with AI as your coding partner.