AgentSkillsCN

testing-api

在使用supertest为Express端点编写或修复API与后端测试时使用此功能。涵盖集成测试、中间件测试,以及数据库的设置与清理。

SKILL.md
--- frontmatter
name: testing-api
description: Use when writing or fixing API and backend tests with supertest for Express endpoints, including integration tests, middleware testing, and database setup/teardown.

API / Backend Testing with Supertest

Core Principles

  • Test the HTTP interface, not internal functions. Hit real endpoints with supertest.
  • Isolate tests with per-test database transactions or truncation.
  • Mock only external services (Stripe, SendGrid), never your own database layer.

Basic Endpoint Testing

ts
import request from 'supertest';
import { describe, it, expect } from 'vitest';
import { app } from '../app';

describe('GET /api/users', () => {
  it('returns 200 with a list of users', async () => {
    const res = await request(app)
      .get('/api/users')
      .set('Authorization', `Bearer ${testToken}`)
      .expect(200);

    expect(res.body).toEqual({
      success: true,
      data: expect.arrayContaining([
        expect.objectContaining({ id: expect.any(Number), email: expect.any(String) }),
      ]),
    });
  });

  it('returns 401 without auth token', async () => {
    await request(app).get('/api/users').expect(401);
  });
});

describe('POST /api/users', () => {
  it('creates a user and returns 201', async () => {
    const res = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${adminToken}`)
      .send({ email: 'new@example.com', name: 'New User' })
      .expect(201);

    expect(res.body.data).toMatchObject({
      email: 'new@example.com',
      name: 'New User',
    });
  });

  it('returns 400 for invalid payload', async () => {
    const res = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${adminToken}`)
      .send({ email: 'not-an-email' })
      .expect(400);

    expect(res.body.success).toBe(false);
    expect(res.body.errors).toBeDefined();
  });
});

Database Setup and Teardown

ts
import { db } from '../db';
import { migrate } from 'drizzle-orm/node-postgres/migrator';

beforeAll(async () => {
  await migrate(db, { migrationsFolder: './drizzle' });
});

beforeEach(async () => {
  // Truncate all tables before each test
  await db.execute(sql`TRUNCATE users, projects, tasks RESTART IDENTITY CASCADE`);
  // Seed required baseline data
  await db.insert(users).values({ email: 'admin@test.com', role: 'admin' });
});

afterAll(async () => {
  await db.$client.end();
});

Transaction-Based Isolation (Alternative)

ts
import { db } from '../db';

let tx: Transaction;

beforeEach(async () => {
  tx = await db.transaction();
  // Replace db reference in app with transaction
  app.locals.db = tx;
});

afterEach(async () => {
  await tx.rollback();
});

Middleware Testing

ts
describe('auth middleware', () => {
  it('attaches user to request on valid token', async () => {
    const token = signTestToken({ userId: 1, role: 'user' });
    const res = await request(app)
      .get('/api/me')
      .set('Authorization', `Bearer ${token}`)
      .expect(200);

    expect(res.body.data.id).toBe(1);
  });

  it('rejects expired tokens', async () => {
    const token = signTestToken({ userId: 1 }, { expiresIn: '-1h' });
    await request(app)
      .get('/api/me')
      .set('Authorization', `Bearer ${token}`)
      .expect(401);
  });
});

describe('rate limiter', () => {
  it('blocks after exceeding limit', async () => {
    for (let i = 0; i < 100; i++) {
      await request(app).get('/api/public');
    }
    await request(app).get('/api/public').expect(429);
  });
});

Mocking External Services

ts
import { vi } from 'vitest';
import * as stripeService from '../services/stripe';

vi.mock('../services/stripe', () => ({
  createCustomer: vi.fn().mockResolvedValue({ id: 'cus_test123' }),
  createSubscription: vi.fn().mockResolvedValue({ id: 'sub_test123', status: 'active' }),
}));

it('creates subscription on signup', async () => {
  const res = await request(app)
    .post('/api/subscribe')
    .set('Authorization', `Bearer ${testToken}`)
    .send({ plan: 'pro' })
    .expect(200);

  expect(stripeService.createSubscription).toHaveBeenCalledWith(
    expect.objectContaining({ plan: 'pro' })
  );
});

Test Helpers

ts
// test/helpers.ts
export function authHeader(role: 'admin' | 'user' = 'user') {
  const token = signTestToken({ userId: 1, role });
  return { Authorization: `Bearer ${token}` };
}

export function expectPaginated(body: any) {
  expect(body).toMatchObject({
    success: true,
    data: expect.any(Array),
    meta: { page: expect.any(Number), limit: expect.any(Number), total: expect.any(Number) },
  });
}

Anti-Patterns

  • NEVER import and call controller functions directly. Always go through supertest.
  • NEVER share mutable state between tests without resetting it in beforeEach.
  • NEVER leave test data in the database after tests complete.
  • NEVER mock your own database layer in integration tests; use a real test database.
  • NEVER hardcode ports; let supertest bind to a random available port.