Documentation

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


That's it! You now have a complete testing setup. Write tests first, ship with confidence!