Testing Guide
Learn how to write and run tests in ShipSecure using Vitest and Playwright.
Overview
ShipSecure follows a Test-Driven Development (TDD) philosophy. All core security features are tested, and the boilerplate includes a complete testing setup:
- Vitest - Unit and integration tests
- Playwright - End-to-end (E2E) tests
- Pre-configured - All security features are tested
Quick Start
# Run all unit tests
npm test
# Run tests in watch mode
npm run test:watch
# Run E2E tests (requires dev server running)
npm run test:e2e
# Run E2E tests with UI
npm run test:e2e:ui
Test Structure
src/
├── components/
│ └── ui/
│ └── tests/ # Component tests
│ └── button.test.tsx
├── lib/
│ ├── __tests__/ # Utility tests
│ │ └── utils.test.ts
│ └── security/
│ ├── headers.ts
│ ├── rate-limit.ts
│ ├── validation.ts
│ └── __tests__/ # Security unit tests
│ ├── headers.test.ts
│ ├── rate-limit.test.ts
│ └── validation.test.ts
├── tests/ # Test setup & mocks
│ ├── mocks/
│ └── setup.ts
e2e/ # End-to-end tests
├── landing.spec.ts
└── docs.spec.ts
Unit Testing with Vitest
Configuration
Vitest is configured in 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: ["./src/tests/setup.ts"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
Writing Tests
Basic Test Structure
import { describe, it, expect } from "vitest";
describe("Feature Name", () => {
it("should do something", () => {
const result = someFunction();
expect(result).toBe(expected);
});
it("should handle edge case", () => {
expect(() => someFunction(null)).toThrow();
});
});
Testing Functions
// src/lib/utils.ts
export function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
// src/lib/__tests__/utils.test.ts
import { describe, it, expect } from "vitest";
import { formatPrice } from "../utils";
describe("formatPrice", () => {
it("formats cents to dollars", () => {
expect(formatPrice(9900)).toBe("$99.00");
expect(formatPrice(100)).toBe("$1.00");
expect(formatPrice(0)).toBe("$0.00");
});
it("handles decimal cents", () => {
expect(formatPrice(999)).toBe("$9.99");
});
});
Testing with Mocks
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock external module
vi.mock("@/lib/db", () => ({
db: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
},
},
}));
import { db } from "@/lib/db";
import { getUser } from "../user-service";
describe("getUser", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns user when found", async () => {
const mockUser = { id: "1", name: "Test", email: "test@example.com" };
vi.mocked(db.user.findUnique).mockResolvedValue(mockUser);
const result = await getUser("1");
expect(result).toEqual(mockUser);
expect(db.user.findUnique).toHaveBeenCalledWith({
where: { id: "1" },
});
});
it("returns null when not found", async () => {
vi.mocked(db.user.findUnique).mockResolvedValue(null);
const result = await getUser("nonexistent");
expect(result).toBeNull();
});
});
Testing Async Functions
import { describe, it, expect } from "vitest";
describe("async operations", () => {
it("resolves with data", async () => {
const result = await fetchData();
expect(result).toEqual({ data: "value" });
});
it("rejects with error", async () => {
await expect(fetchInvalidData()).rejects.toThrow("Not found");
});
});
Running Tests
# Run all tests
npm test
# Run specific test file
npm test -- src/lib/security/__tests__/validation.test.ts
# Run tests matching pattern
npm test -- --grep "validation"
# Run with coverage
npm test -- --coverage
# Watch mode (re-runs on file changes)
npm run test:watch
Existing Security Tests
ShipSecure includes comprehensive tests for security features:
Security Headers Tests
File: src/lib/security/__tests__/headers.test.ts
describe("Security Headers", () => {
it("returns correct security headers");
it("includes Content-Security-Policy");
it("includes Strict-Transport-Security");
it("includes X-Frame-Options");
it("includes X-Content-Type-Options");
it("includes Referrer-Policy");
it("includes Permissions-Policy");
});
Rate Limiting Tests
File: src/lib/security/__tests__/rate-limit.test.ts
describe("Rate Limiting", () => {
it("allows requests under limit");
it("blocks requests over limit");
it("resets after time window");
it("tracks by IP address");
});
Input Validation Tests
File: src/lib/security/__tests__/validation.test.ts
describe("Input Validation", () => {
describe("emailSchema", () => {
it("accepts valid email addresses");
it("rejects invalid email addresses");
});
describe("passwordSchema", () => {
it("accepts valid passwords");
it("rejects passwords without uppercase");
it("rejects passwords without lowercase");
it("rejects passwords without number");
it("rejects short passwords");
});
describe("userSchema", () => {
it("accepts valid user data");
it("accepts user without password");
it("rejects user with invalid email");
});
describe("paginationSchema", () => {
it("accepts valid pagination parameters");
it("uses default values when not provided");
it("coerces string numbers to integers");
});
describe("validateData", () => {
it("returns success with valid data");
it("returns errors with invalid data");
});
describe("formatZodErrors", () => {
it("formats Zod errors as key-value pairs");
});
});
E2E Testing with Playwright
Configuration
Playwright is configured in playwright.config.ts:
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
Writing E2E Tests
Basic Page Test
// e2e/landing.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Landing Page", () => {
test("displays hero section", async ({ page }) => {
await page.goto("/");
await expect(page.locator("h1")).toBeVisible();
await expect(
page.getByRole("button", { name: /get started/i })
).toBeVisible();
});
test("navigation works", async ({ page }) => {
await page.goto("/");
await page.click('a[href="/docs"]');
await expect(page).toHaveURL("/docs");
});
});
Authentication Test
// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Authentication", () => {
test("shows sign in page", async ({ page }) => {
await page.goto("/api/auth/signin");
await expect(page.getByText("Sign in")).toBeVisible();
await expect(page.getByRole("button", { name: /github/i })).toBeVisible();
await expect(page.getByRole("button", { name: /google/i })).toBeVisible();
});
test("redirects unauthenticated users", async ({ page }) => {
await page.goto("/dashboard");
// Should redirect to sign in
await expect(page).toHaveURL(/signin/);
});
});
Form Submission Test
// e2e/license.spec.ts
import { test, expect } from "@playwright/test";
test.describe("License Activation", () => {
// This test requires authentication setup
test.skip("activates valid license", async ({ page }) => {
await page.goto("/settings");
await page.fill('input[placeholder*="license"]', "TEST-KEY");
await page.click('button:has-text("Activate")');
await expect(page.getByText("License activated")).toBeVisible();
});
});
Running E2E Tests
# Run all E2E tests
npm run test:e2e
# Run with browser visible
npm run test:e2e -- --headed
# Run specific test file
npm run test:e2e -- e2e/landing.spec.ts
# Open interactive UI
npm run test:e2e:ui
# Generate test report
npm run test:e2e -- --reporter=html
npx playwright show-report
TDD Workflow
ShipSecure encourages Test-Driven Development:
1. Write the Test First
// src/lib/__tests__/pricing.test.ts
import { describe, it, expect } from "vitest";
import { calculateDiscount } from "../pricing";
describe("calculateDiscount", () => {
it("applies 10% discount for annual billing", () => {
expect(calculateDiscount(100, "annual")).toBe(90);
});
it("applies no discount for monthly billing", () => {
expect(calculateDiscount(100, "monthly")).toBe(100);
});
});
2. Run the Test (It Should Fail)
npm test -- pricing
# [Fail] Test fails - function doesn't exist yet
3. Write the Implementation
// src/lib/pricing.ts
export function calculateDiscount(
price: number,
billingCycle: "monthly" | "annual"
): number {
if (billingCycle === "annual") {
return price * 0.9; // 10% discount
}
return price;
}
4. Run the Test Again (It Should Pass)
npm test -- pricing
# [Pass] All tests pass
5. Refactor if Needed
Now you can safely refactor, knowing the tests will catch regressions.
Testing Best Practices
1. Test One Thing Per Test
// [Bad] - testing multiple things
it("validates user and creates account", () => {
// ...
});
// [Good] - focused tests
it("validates email format", () => {
/* ... */
});
it("requires name field", () => {
/* ... */
});
it("creates user in database", () => {
/* ... */
});
2. Use Descriptive Test Names
// [Bad]
it("works", () => {
/* ... */
});
// [Good]
it("returns error when email is invalid", () => {
/* ... */
});
it("upgrades user plan to PRO on valid license", () => {
/* ... */
});
3. Arrange-Act-Assert Pattern
it("formats price correctly", () => {
// Arrange
const priceInCents = 9900;
// Act
const result = formatPrice(priceInCents);
// Assert
expect(result).toBe("$99.00");
});
4. Clean Up After Tests
import { afterEach, beforeEach, vi } from "vitest";
describe("feature", () => {
beforeEach(() => {
// Setup before each test
vi.useFakeTimers();
});
afterEach(() => {
// Cleanup after each test
vi.useRealTimers();
vi.clearAllMocks();
});
});
5. Test Edge Cases
describe("divideNumbers", () => {
it("divides positive numbers", () => {
expect(divideNumbers(10, 2)).toBe(5);
});
it("throws error when dividing by zero", () => {
expect(() => divideNumbers(10, 0)).toThrow("Division by zero");
});
it("handles negative numbers", () => {
expect(divideNumbers(-10, 2)).toBe(-5);
});
it("handles decimal results", () => {
expect(divideNumbers(10, 3)).toBeCloseTo(3.333, 2);
});
});
CI/CD Integration
ShipSecure includes GitHub Actions for automated testing:
File: .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E tests
run: npm run test:e2e
What happens:
- Tests run on every Pull Request
- Tests run on every push to
main - Both unit tests and E2E tests are executed
- PR cannot be merged if tests fail
Coverage Reports
Generate test coverage reports:
# Generate coverage
npm test -- --coverage
# View HTML report
open coverage/index.html
Coverage goals:
- Security features: 100% coverage
- Business logic: 80%+ coverage
- UI components: Focus on critical paths
Troubleshooting
Tests timing out
// Increase timeout for slow tests
it("slow operation", async () => {
// ...
}, 10000); // 10 second timeout
Mock not working
# Clear Jest cache
npx vitest --clearCache
# Or delete node_modules/.vitest
rm -rf node_modules/.vitest
E2E tests failing locally
# Ensure dev server is running
npm run dev
# Run tests in headed mode to debug
npm run test:e2e -- --headed --debug
Next Steps
- Security Features - See tested security features
- API Reference - Test your API endpoints
- Deployment - CI/CD runs tests automatically
That's it! You now have a complete testing setup. Write tests first, ship with confidence!