Testing Without Pain
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
calculateTotalfunction add up prices correctly? - Does the
formatDateutility return the right string? - Does the
Buttoncomponent 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
- Describe the behavior you want in a test
- Ask AI to write the failing test based on your description
- Ask AI to implement the function until the test passes
- 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:
- Go to Settings > Branches > Branch protection rules
- Require status checks to pass before merging
- 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.