Loading...
Loading...

I shipped a SaaS with zero tests for six months. Every deploy was a prayer.
Then I broke billing on a Friday afternoon. Lost three customers before Monday. Added tests that weekend.
You don't need to be burned to learn this lesson. Here's the setup that would have caught that billing bug. And the hundred others I found after I actually started testing.
Forget the sixteen-layer testing pyramid diagrams. You need three kinds of tests:
Unit tests. Does this function return the right thing? Fast. Thousands of them. Run in milliseconds.
Integration tests. Do these systems work together? Database queries, API routes, authentication flows. Slower. Dozens of them.
E2E tests. Does the user flow work from click to result? Browser-based. Slow. A handful of critical paths.
Start with unit tests. Add integration tests for your most important features. Add E2E for your most critical flows. In that order.
Vitest is Jest but faster and configured for modern projects out of the box.
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom// 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",
setupFiles: ["./tests/setup.ts"],
globals: true,
css: false,
coverage: {
provider: "v8",
reporter: ["text", "html"],
exclude: ["node_modules/", "tests/", "*.config.*"],
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});// tests/setup.ts
import "@testing-library/jest-dom";// package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}Done. Run npm test and Vitest watches for changes.
Don't overthink what to test first. Pick the function you're most afraid to change. That's the one that needs a test.
// lib/pricing.ts
export function calculatePrice(
basePriceInCents: number,
quantity: number,
discountPercent: number = 0
): number {
if (quantity < 0) throw new Error("Quantity cannot be negative");
if (discountPercent < 0 || discountPercent > 100) {
throw new Error("Discount must be between 0 and 100");
}
const subtotal = basePriceInCents * quantity;
const discount = Math.round(subtotal * (discountPercent / 100));
return subtotal - discount;
}
// lib/__tests__/pricing.test.ts
import { describe, it, expect } from "vitest";
import { calculatePrice } from "../pricing";
describe("calculatePrice", () => {
it("calculates base price correctly", () => {
expect(calculatePrice(1000, 3)).toBe(3000);
});
it("applies discount correctly", () => {
expect(calculatePrice(1000, 3, 10)).toBe(2700);
});
it("handles zero quantity", () => {
expect(calculatePrice(1000, 0)).toBe(0);
});
it("rounds discount to nearest cent", () => {
expect(calculatePrice(999, 1, 33)).toBe(669);
});
it("rejects negative quantity", () => {
expect(() => calculatePrice(1000, -1)).toThrow("negative");
});
it("rejects invalid discount", () => {
expect(() => calculatePrice(1000, 1, 150)).toThrow("between 0 and 100");
});
});Six tests. Covers the happy path, edge cases, and error cases. Would have caught my billing bug.
// components/__tests__/LoginForm.test.tsx
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { LoginForm } from "../LoginForm";
describe("LoginForm", () => {
it("renders email and password fields", () => {
render(<LoginForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
it("shows validation error for empty email", async () => {
render(<LoginForm onSubmit={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
});
it("calls onSubmit with form data", async () => {
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: "test@example.com" },
});
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: "password123" },
});
fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: "test@example.com",
password: "password123",
});
});
});
});Test behavior, not implementation. Don't test that a state variable changed. Test that the user sees the right thing.
// app/api/users/__tests__/route.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { GET, POST } from "../route";
describe("Users API", () => {
beforeEach(async () => {
await resetTestDatabase();
});
it("GET returns list of users", async () => {
await seedTestUsers(3);
const response = await GET();
const data = await response.json();
expect(response.status).toBe(200);
expect(data.users).toHaveLength(3);
});
it("POST creates a new user", async () => {
const response = await POST(
new Request("http://localhost/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Test User",
email: "test@test.com",
}),
})
);
expect(response.status).toBe(201);
const data = await response.json();
expect(data.user.email).toBe("test@test.com");
});
it("POST rejects duplicate email", async () => {
await seedTestUsers(1, { email: "existing@test.com" });
const response = await POST(
new Request("http://localhost/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Duplicate",
email: "existing@test.com",
}),
})
);
expect(response.status).toBe(409);
});
});npm install -D @playwright/test
npx playwright install chromium// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Authentication", () => {
test("user can sign in", async ({ page }) => {
await page.goto("/login");
await page.fill('[name="email"]', "user@test.com");
await page.fill('[name="password"]', "testpass123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/dashboard");
await expect(page.locator("h1")).toContainText("Dashboard");
});
test("invalid credentials show error", async ({ page }) => {
await page.goto("/login");
await page.fill('[name="email"]', "wrong@test.com");
await page.fill('[name="password"]', "wrongpass");
await page.click('button[type="submit"]');
await expect(page.locator('[role="alert"]')).toContainText("Invalid");
});
});E2E tests are slow. Only write them for flows that would lose you money or customers if they broke. Login. Checkout. The core feature your product exists for.
# .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: npm run test:run
- run: npm run test:coverageTests run on every push and every PR. If they fail, the PR can't merge. Simple.
Always test: Business logic, data transformations, API routes, authentication, payment flows, validation rules.
Usually skip: Styling, third-party library behavior, one-line getter functions, configuration files.
The billing test I should have written from the start:
it("subscription upgrade prorates correctly", () => {
const result = calculateProration({
currentPlan: { price: 2900, billedAt: daysAgo(15) },
newPlan: { price: 4900 },
billingCycle: 30,
});
expect(result.credit).toBe(1450); // Half month of old plan
expect(result.charge).toBe(2450); // Half month of new plan
expect(result.net).toBe(1000); // Difference charged
});That test would have caught my bug in 3 milliseconds. Instead, three customers caught it over a weekend.
Write the test.

AI agents can generate, maintain, and evolve your test suite automatically — from unit tests to end-to-end scenarios and security audits.

How to test AI agents effectively when outputs are non-deterministic — evaluation frameworks, regression testing, and quality metrics.

Your AI agent is only as useful as the services it can talk to. Here are the patterns I use to connect AI to everything else.
Stop reading about AI and start building with it. Book a free discovery call and see how AI agents can accelerate your business.