Claude Code Plugins

Community-maintained marketplace

Feedback

Write and run API tests with Vitest for endpoints, middleware, and integrations. Use when testing API functionality, request/response validation, error handling.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name api-testing
description Write and run API tests with Vitest for endpoints, middleware, and integrations. Use when testing API functionality, request/response validation, error handling.
allowed-tools Read, Edit, Write, Bash, Grep, Glob

API Testing Skill

This skill helps you write comprehensive API tests using Vitest for the Hono-based API service.

When to Use This Skill

  • Testing API endpoints and routes
  • Validating request/response payloads
  • Testing middleware and error handling
  • Integration testing with database
  • Testing tRPC procedures
  • Testing workflows and background jobs
  • Authentication and authorization testing
  • Rate limiting and caching tests

Testing Framework

The project uses Vitest for API testing:

  • Fast execution with native ESM support
  • Compatible with Jest API
  • TypeScript support out of the box
  • V8 coverage reporting
  • Watch mode for development

Project Configuration

Vitest Config

// apps/api/vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,
    environment: "node",
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"],
      exclude: [
        "node_modules/",
        "__tests__/",
        "dist/",
        "*.config.ts",
      ],
    },
    setupFiles: ["__tests__/setup.ts"],
  },
});

Test Setup

// apps/api/__tests__/setup.ts
import { beforeAll, afterAll, beforeEach } from "vitest";
import { db } from "../src/config/database";

beforeAll(async () => {
  // Connect to test database
  console.log("Setting up test database...");
});

afterAll(async () => {
  // Clean up connections
  console.log("Cleaning up test database...");
});

beforeEach(async () => {
  // Clear test data before each test
  // await db.delete(testTable);
});

Test Structure

File Organization

apps/api/
├── __tests__/
│   ├── setup.ts                    # Test setup
│   ├── helpers.ts                  # Test utilities
│   ├── routes/
│   │   ├── cars.test.ts           # Cars endpoints
│   │   ├── coe.test.ts            # COE endpoints
│   │   └── health.test.ts         # Health check
│   ├── trpc/
│   │   └── router.test.ts         # tRPC procedures
│   ├── workflows/
│   │   ├── update-car-data.test.ts
│   │   └── social-media.test.ts
│   └── middleware/
│       ├── auth.test.ts           # Auth middleware
│       └── error.test.ts          # Error handling

Testing Hono Endpoints

Basic Endpoint Test

// apps/api/__tests__/routes/health.test.ts
import { describe, it, expect } from "vitest";
import app from "../../src/index";

describe("Health Check", () => {
  it("should return 200 OK", async () => {
    const res = await app.request("/health");

    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ status: "ok" });
  });

  it("should include timestamp", async () => {
    const res = await app.request("/health");
    const data = await res.json();

    expect(data).toHaveProperty("timestamp");
    expect(typeof data.timestamp).toBe("string");
  });
});

Testing GET Endpoints

// apps/api/__tests__/routes/cars.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import app from "../../src/index";
import { db } from "../../src/config/database";
import { cars } from "@sgcarstrends/database/schema";

describe("GET /api/v1/cars/makes", () => {
  beforeEach(async () => {
    // Seed test data
    await db.insert(cars).values([
      { make: "Toyota", model: "Corolla", month: "2024-01", number: 100 },
      { make: "Honda", model: "Civic", month: "2024-01", number: 80 },
    ]);
  });

  it("should return list of car makes", async () => {
    const res = await app.request("/api/v1/cars/makes");

    expect(res.status).toBe(200);

    const data = await res.json();
    expect(data).toHaveLength(2);
    expect(data[0]).toHaveProperty("make");
    expect(data[0]).toHaveProperty("count");
  });

  it("should filter by month", async () => {
    const res = await app.request("/api/v1/cars/makes?month=2024-01");

    expect(res.status).toBe(200);

    const data = await res.json();
    expect(data).toHaveLength(2);
  });

  it("should return 400 for invalid month format", async () => {
    const res = await app.request("/api/v1/cars/makes?month=invalid");

    expect(res.status).toBe(400);
    expect(await res.json()).toHaveProperty("error");
  });
});

Testing POST Endpoints

// apps/api/__tests__/routes/blog.test.ts
import { describe, it, expect } from "vitest";
import app from "../../src/index";

describe("POST /api/v1/blog/posts", () => {
  it("should create a new post", async () => {
    const payload = {
      title: "Test Post",
      content: "Test content",
      slug: "test-post",
    };

    const res = await app.request("/api/v1/blog/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });

    expect(res.status).toBe(201);

    const data = await res.json();
    expect(data).toHaveProperty("id");
    expect(data.title).toBe(payload.title);
  });

  it("should validate required fields", async () => {
    const res = await app.request("/api/v1/blog/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "Test" }), // Missing content
    });

    expect(res.status).toBe(400);
    expect(await res.json()).toHaveProperty("error");
  });

  it("should prevent duplicate slugs", async () => {
    const payload = {
      title: "Test Post",
      content: "Test content",
      slug: "duplicate",
    };

    // First insert
    await app.request("/api/v1/blog/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });

    // Duplicate insert
    const res = await app.request("/api/v1/blog/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });

    expect(res.status).toBe(409);
  });
});

Testing tRPC Procedures

Create Test Caller

// apps/api/__tests__/trpc/router.test.ts
import { describe, it, expect } from "vitest";
import { appRouter } from "../../src/trpc/router";

describe("tRPC Router", () => {
  const caller = appRouter.createCaller({
    user: null, // Unauthenticated caller
  });

  describe("cars.getMakes", () => {
    it("should return car makes", async () => {
      const result = await caller.cars.getMakes({ month: "2024-01" });

      expect(result).toBeDefined();
      expect(Array.isArray(result)).toBe(true);
    });

    it("should validate input", async () => {
      await expect(
        caller.cars.getMakes({ month: "invalid" })
      ).rejects.toThrow();
    });
  });

  describe("blog.createPost (protected)", () => {
    it("should require authentication", async () => {
      await expect(
        caller.blog.createPost({
          title: "Test",
          content: "Test",
          slug: "test",
        })
      ).rejects.toThrow("Unauthorized");
    });

    it("should create post when authenticated", async () => {
      const authenticatedCaller = appRouter.createCaller({
        user: { id: "test-user", role: "admin" },
      });

      const result = await authenticatedCaller.blog.createPost({
        title: "Test Post",
        content: "Test content",
        slug: "test-post",
      });

      expect(result).toHaveProperty("id");
      expect(result.title).toBe("Test Post");
    });
  });
});

Testing Middleware

Auth Middleware

// apps/api/__tests__/middleware/auth.test.ts
import { describe, it, expect, vi } from "vitest";
import { Hono } from "hono";
import { authMiddleware } from "../../src/middleware/auth";

describe("Auth Middleware", () => {
  const app = new Hono();
  app.use("*", authMiddleware);
  app.get("/protected", (c) => c.json({ success: true }));

  it("should allow requests with valid token", async () => {
    const res = await app.request("/protected", {
      headers: {
        Authorization: "Bearer valid-token",
      },
    });

    expect(res.status).toBe(200);
  });

  it("should reject requests without token", async () => {
    const res = await app.request("/protected");

    expect(res.status).toBe(401);
    expect(await res.json()).toHaveProperty("error");
  });

  it("should reject requests with invalid token", async () => {
    const res = await app.request("/protected", {
      headers: {
        Authorization: "Bearer invalid-token",
      },
    });

    expect(res.status).toBe(401);
  });
});

Error Handling Middleware

// apps/api/__tests__/middleware/error.test.ts
import { describe, it, expect } from "vitest";
import { Hono } from "hono";
import { errorHandler } from "../../src/middleware/error";

describe("Error Handler", () => {
  const app = new Hono();
  app.onError(errorHandler);

  app.get("/error", () => {
    throw new Error("Test error");
  });

  it("should catch errors and return 500", async () => {
    const res = await app.request("/error");

    expect(res.status).toBe(500);

    const data = await res.json();
    expect(data).toHaveProperty("error");
  });

  it("should not expose stack traces in production", async () => {
    process.env.NODE_ENV = "production";

    const res = await app.request("/error");
    const data = await res.json();

    expect(data).not.toHaveProperty("stack");

    process.env.NODE_ENV = "test";
  });
});

Testing Workflows

QStash Workflow Testing

// apps/api/__tests__/workflows/update-car-data.test.ts
import { describe, it, expect, vi } from "vitest";
import { updateCarDataWorkflow } from "../../src/lib/workflows/update-car-data";

describe("Update Car Data Workflow", () => {
  it("should fetch and process car data", async () => {
    // Mock external API
    const mockFetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({
        records: [
          { make: "Toyota", model: "Corolla", number: 100 },
        ],
      }),
    });

    global.fetch = mockFetch;

    const result = await updateCarDataWorkflow.execute();

    expect(result.success).toBe(true);
    expect(mockFetch).toHaveBeenCalled();
  });

  it("should handle API errors", async () => {
    const mockFetch = vi.fn().mockResolvedValue({
      ok: false,
      status: 500,
    });

    global.fetch = mockFetch;

    await expect(updateCarDataWorkflow.execute()).rejects.toThrow();
  });

  it("should save data to database", async () => {
    // Mock successful fetch
    const mockFetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({
        records: [{ make: "Toyota", model: "Corolla", number: 100 }],
      }),
    });

    global.fetch = mockFetch;

    await updateCarDataWorkflow.execute();

    // Verify database insert
    const cars = await db.query.cars.findMany({
      where: eq(cars.make, "Toyota"),
    });

    expect(cars.length).toBeGreaterThan(0);
  });
});

Mocking

Mock Database Queries

// apps/api/__tests__/helpers.ts
import { vi } from "vitest";
import { db } from "../src/config/database";

export const mockDbQuery = (mockData: any) => {
  return vi.spyOn(db.query.cars, "findMany").mockResolvedValue(mockData);
};

// Use in tests
import { mockDbQuery } from "./helpers";

it("should return mocked data", async () => {
  mockDbQuery([
    { make: "Toyota", model: "Corolla", number: 100 },
  ]);

  const res = await app.request("/api/v1/cars/makes");
  const data = await res.json();

  expect(data[0].make).toBe("Toyota");
});

Mock External APIs

// Mock fetch
import { vi } from "vitest";

const mockFetch = vi.fn();
global.fetch = mockFetch;

it("should fetch data from LTA", async () => {
  mockFetch.mockResolvedValue({
    ok: true,
    json: async () => ({ records: [] }),
  });

  await fetchCarData();

  expect(mockFetch).toHaveBeenCalledWith(
    expect.stringContaining("lta.gov.sg"),
    expect.any(Object)
  );
});

Mock Redis

// Mock Redis client
import { vi } from "vitest";
import { redis } from "@sgcarstrends/utils";

vi.mock("@sgcarstrends/utils", () => ({
  redis: {
    get: vi.fn(),
    set: vi.fn(),
    del: vi.fn(),
  },
}));

it("should cache results", async () => {
  await cacheData("key", { data: "value" });

  expect(redis.set).toHaveBeenCalledWith(
    "key",
    JSON.stringify({ data: "value" }),
    expect.any(Object)
  );
});

Integration Testing

Test with Real Database

// apps/api/__tests__/integration/cars.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import app from "../../src/index";
import { db } from "../../src/config/database";
import { cars } from "@sgcarstrends/database/schema";

describe("Cars API Integration", () => {
  beforeEach(async () => {
    // Clear database
    await db.delete(cars);

    // Seed data
    await db.insert(cars).values([
      { make: "Toyota", model: "Corolla", month: "2024-01", number: 100 },
    ]);
  });

  afterEach(async () => {
    // Clean up
    await db.delete(cars);
  });

  it("should perform full CRUD operations", async () => {
    // Read
    let res = await app.request("/api/v1/cars/makes");
    expect(res.status).toBe(200);

    // Update (if endpoint exists)
    // res = await app.request("/api/v1/cars/1", { method: "PUT", ... });

    // Delete (if endpoint exists)
    // res = await app.request("/api/v1/cars/1", { method: "DELETE" });
  });
});

Running Tests

Common Commands

# Run all API tests
pnpm -F @sgcarstrends/api test

# Run specific test file
pnpm -F @sgcarstrends/api test routes/cars.test.ts

# Run tests in watch mode
pnpm -F @sgcarstrends/api test:watch

# Run with coverage
pnpm -F @sgcarstrends/api test:coverage

# Run integration tests only
pnpm -F @sgcarstrends/api test integration/

Package.json Scripts

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

Test Helpers

Create Test Utilities

// apps/api/__tests__/helpers.ts
import { Hono } from "hono";

export const createTestApp = () => {
  const app = new Hono();
  // Add middleware and routes
  return app;
};

export const createAuthHeader = (token: string) => ({
  Authorization: `Bearer ${token}`,
});

export const seedDatabase = async (data: any[]) => {
  await db.insert(cars).values(data);
};

export const clearDatabase = async () => {
  await db.delete(cars);
};

export const expectJson = async (res: Response) => {
  expect(res.headers.get("Content-Type")).toContain("application/json");
  return await res.json();
};

Best Practices

1. Isolate Tests

// ❌ Tests depend on each other
it("create car", async () => {
  await createCar({ make: "Toyota" });
});

it("get car", async () => {
  // Assumes car from previous test exists
  const res = await app.request("/api/v1/cars/1");
});

// ✅ Independent tests
it("get car", async () => {
  // Create car in this test
  await db.insert(cars).values({ make: "Toyota" });

  const res = await app.request("/api/v1/cars/1");
});

2. Test Error Cases

describe("GET /api/v1/cars/:id", () => {
  it("should return car when found", async () => {
    // Test happy path
  });

  it("should return 404 when not found", async () => {
    const res = await app.request("/api/v1/cars/999");
    expect(res.status).toBe(404);
  });

  it("should return 400 for invalid ID", async () => {
    const res = await app.request("/api/v1/cars/invalid");
    expect(res.status).toBe(400);
  });
});

3. Use Descriptive Names

// ❌ Vague test names
it("works", async () => {});
it("returns data", async () => {});

// ✅ Descriptive test names
it("should return 200 OK with list of car makes", async () => {});
it("should validate month parameter format", async () => {});
it("should cache results for 1 hour", async () => {});

4. Clean Up After Tests

import { afterEach } from "vitest";

afterEach(async () => {
  // Clear database
  await db.delete(cars);

  // Clear cache
  await redis.flushdb();

  // Reset mocks
  vi.clearAllMocks();
});

Coverage

Generate Coverage Reports

# Generate coverage
pnpm -F @sgcarstrends/api test:coverage

# View HTML report
open apps/api/coverage/index.html

Coverage Configuration

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80,
      },
      exclude: [
        "__tests__/",
        "*.config.ts",
        "dist/",
      ],
    },
  },
});

Troubleshooting

Tests Failing Randomly

// Issue: Database state from previous tests
// Solution: Clear database in beforeEach

beforeEach(async () => {
  await db.delete(cars);
  await db.delete(coe);
});

Mock Not Working

// Issue: Mock not applied
// Solution: Ensure mock is defined before import

vi.mock("@sgcarstrends/utils", () => ({
  redis: {
    get: vi.fn(),
  },
}));

// Import after mock
import { redis } from "@sgcarstrends/utils";

Timeout Errors

// Increase timeout for slow tests
it("slow test", async () => {
  // ...
}, 10000); // 10 second timeout

References

Best Practices Summary

  1. Isolate Tests: Each test should be independent
  2. Test Error Cases: Test both happy and error paths
  3. Use Mocks: Mock external dependencies
  4. Clean Up: Reset state after tests
  5. Descriptive Names: Clear test descriptions
  6. Coverage Goals: Aim for 80%+ coverage
  7. Integration Tests: Test real database interactions
  8. Fast Tests: Keep unit tests fast, integration tests separate