CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/api-testing-first-steps

Get test coverage on an Express/Node API fast — the first 5 tests that catch

94

1.26x
Quality

90%

Does it follow best practices?

Impact

100%

1.26x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/api-testing-first-steps/

name:
api-testing-first-steps
description:
Get test coverage on an Express/Node API fast — the first 5 tests that catch real bugs. Concrete patterns for testing routes, database operations, and validation with Vitest and supertest. Use when a project has zero tests and needs to start, when adding routes to an existing API, when a code reviewer says "where are the tests?", or when you see an Express app without a __tests__ or *.test.ts file. Also use when expanding test coverage for PATCH/status routes, query parameter filtering, or error format consistency.
keywords:
api testing, express testing, supertest, vitest, jest, integration test, route testing, http testing, test coverage, first test, test setup, testing express routes, database testing, test isolation, error format, PATCH testing, query parameter testing, test prioritization
license:
MIT

API Testing — First Steps

Get meaningful test coverage on a Node/Express API. Not test philosophy — the specific tests and patterns that catch real bugs before users do.


The 5-Test Discipline

When a project has zero tests, write exactly 5 tests — no more. Resist the urge to write a comprehensive suite. Five focused tests that take 15 minutes give more value than 20 tests that take a day and nobody maintains.

These 5 tests cover the failures that actually happen in production:

  1. Happy path — does the main GET return 200 with a data array?
  2. Validation — does the API reject bad input with 400?
  3. Not found — does it return 404, not 500, for missing resources?
  4. State change — does a POST actually persist (create then GET it back)?
  5. Error format — are ALL error responses the same shape?

CRITICAL — the error format test pattern: Test that BOTH validation errors (400) AND not-found errors (404) return the exact same shape: { error: { message: "..." } }. Assert typeof res.body.error.message === 'string' on each. Do NOT just check that res.body.error exists — check that error.message is a string. Loop over multiple error responses and check the same assertions on each one.


Setup

Install dependencies

npm install -D vitest supertest @types/supertest

Configure Vitest

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
  },
});

Package.json scripts — always include all three

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

Always add test:coverage even for initial setup — it costs nothing and the team will need it.

Export the app (don't call listen in the module)

// src/server.ts
import express from 'express';
import routes from './routes';
import { errorHandler } from './middleware/error-handler';

export const app = express();
app.use(express.json());
app.use('/api', routes);
app.use(errorHandler);

// Only listen when running directly, not when imported by tests
if (process.env.NODE_ENV !== 'test') {
  const PORT = process.env.PORT || 3000;
  app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
}

The 5 Tests — Full Example

// __tests__/api.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import { app } from '../src/server';

describe('API', () => {
  // Test 1: Happy path — main flow works
  it('GET /api/items returns items', async () => {
    const res = await request(app).get('/api/items');

    expect(res.status).toBe(200);
    expect(res.body.data).toBeDefined();
    expect(Array.isArray(res.body.data)).toBe(true);
  });

  // Test 2: Validation — rejects bad input
  it('POST /api/items rejects empty body', async () => {
    const res = await request(app)
      .post('/api/items')
      .send({});

    expect(res.status).toBe(400);
    expect(res.body.error).toBeDefined();
  });

  // Test 3: Not found — returns 404, not 500
  it('GET /api/items/nonexistent returns 404', async () => {
    const res = await request(app).get('/api/items/99999');

    expect(res.status).toBe(404);
    expect(res.body.error).toBeDefined();
  });

  // Test 4: State change — POST actually persists
  it('POST /api/items creates and persists', async () => {
    const res = await request(app)
      .post('/api/items')
      .send({ name: 'Test Item', category: 'testing' });

    expect(res.status).toBe(201);
    expect(res.body.data.id).toBeDefined();

    // Verify it persisted by GETting it back
    const getRes = await request(app).get(`/api/items/${res.body.data.id}`);
    expect(getRes.status).toBe(200);
    expect(getRes.body.data.name).toBe('Test Item');
  });

  // Test 5: Error format consistency — THE MOST IMPORTANT PATTERN
  it('all error responses have consistent shape', async () => {
    const badPost = await request(app).post('/api/items').send({});
    const notFound = await request(app).get('/api/items/99999');

    // Both MUST have error.message as a string — not just error existing
    for (const res of [badPost, notFound]) {
      expect(res.body.error).toBeDefined();
      expect(typeof res.body.error.message).toBe('string');
    }
  });
});

What NOT to Test

These are common mistakes. Follow them strictly:

  • Don't test Express internals. Never assert res.type === 'application/json' or check content-type headers. Express handles JSON serialization — test the body shape, not the transport.
  • Don't mock the database for API integration tests. Hit the real database. Use SQLite :memory: for speed.
  • Don't test private functions. Test through the routes with supertest. If you need to test a helper, it should probably be a route-level behavior anyway.
  • Don't aim for 100% coverage. Aim for confidence. Five tests that cover real failure modes beat 50 tests that check trivia.
  • Don't write more than 5 tests when starting from zero. More tests = more maintenance = abandoned test suite.

Database Isolation

Tests that share a database will interfere with each other. Use one of these approaches:

For SQLite: in-memory database (preferred)

// src/db.ts
import Database from 'better-sqlite3';

const DB_PATH = process.env.NODE_ENV === 'test' ? ':memory:' : './data.db';
const db = new Database(DB_PATH);

Reset before each test

import { resetDatabase } from '../src/db';

beforeEach(() => {
  resetDatabase();  // DROP and recreate tables, re-seed
});

Transactions (faster for large suites)

import db from '../src/db';

beforeEach(() => { db.exec('BEGIN'); });
afterEach(() => { db.exec('ROLLBACK'); });

Never mock the database module (vi.mock('../src/db') or jest.mock). Use the real module with an in-memory or test-specific database. Mocking hides the actual bugs you're trying to catch.


Patterns for Expanding Coverage

Testing PATCH/status updates — always create the resource first

Never hardcode an assumed ID (like 1). Always create the resource in the test, then use the returned ID:

it('PATCH /api/orders/:id/status updates the order', async () => {
  // Create an order first — don't assume ID 1 exists
  const created = await request(app)
    .post('/api/orders')
    .send({ customer_name: 'Test', items: [{ menu_item_id: 1, size: 'small', quantity: 1 }] });

  const orderId = created.body.data.id;

  const res = await request(app)
    .patch(`/api/orders/${orderId}/status`)
    .send({ status: 'preparing' });

  expect(res.status).toBe(200);
  expect(res.body.data.status).toBe('preparing');
});

it('PATCH /api/orders/:id/status rejects invalid status', async () => {
  const created = await request(app)
    .post('/api/orders')
    .send({ customer_name: 'Test', items: [{ menu_item_id: 1, size: 'small', quantity: 1 }] });

  const res = await request(app)
    .patch(`/api/orders/${created.body.data.id}/status`)
    .send({ status: 'flying' });

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

Testing query parameter filtering — verify ALL results match

Don't just check that the response is 200. Iterate every item in the result array and verify each one matches the filter:

it('GET /api/orders?status=received filters correctly', async () => {
  const res = await request(app).get('/api/orders?status=received');

  expect(res.status).toBe(200);
  // Check EVERY item, not just the first
  for (const order of res.body.data) {
    expect(order.status).toBe('received');
  }
});

Adding Tests to an Existing Project

When joining a project with routes but no tests, follow this prioritization:

  1. Start with exactly 5 tests. Not 10, not 20. Five.
  2. Test routes that handle money or state changes first. Payments, orders, status transitions — these are where bugs cost real money.
  3. Test the routes that change most often. Run git log --name-only to find high-churn files.
  4. Add a test when you fix a bug. The test proves the fix and prevents regression.
  5. Don't try to test everything. Deliberately leave out routes that are stable and low-risk.

Document your testing strategy

When writing a testing-strategy.md or similar document, always include these three sections:

  1. What you tested and why — explain that you prioritized routes handling state, money, or payments because bugs there have real consequences
  2. What you deliberately left out and why — name the specific routes you skipped (e.g., /api/health, /api/config) and explain they are stable or low-risk. State explicitly: "100% coverage is NOT the goal" or "We are deliberately NOT aiming for comprehensive coverage." This must be an explicit statement, not implied.
  3. The technical approach — supertest through HTTP routes, real database (not mocked), in-memory SQLite for isolation

Example phrasing for the strategy doc:

"We deliberately chose NOT to aim for 100% test coverage. Instead, we focused on the routes that handle state changes and financial transactions, where bugs have the highest business impact. Routes like /api/health and /api/config are stable, rarely modified, and low-risk — testing them would add maintenance burden without meaningful safety."


Checklist

Setup:

  • Vitest and supertest installed as devDependencies
  • test, test:watch, AND test:coverage scripts in package.json
  • App exported without calling .listen() in test mode
  • Database isolation (in-memory SQLite, reset, or transactions)

The 5 tests:

  • Happy path GET returns 200 with data array
  • Validation POST returns 400 with error
  • Missing resource returns 404 with error
  • POST creates and GET confirms persistence
  • Error format test: BOTH 400 and 404 have { error: { message: string } } shape

Expanding coverage:

  • PATCH tests create the resource first (never hardcode IDs)
  • PATCH tests verify the response contains the updated value
  • PATCH tests reject invalid values with 400
  • Query filter tests iterate ALL results to verify the filter
  • All tests use supertest request(app) — no direct function calls
  • No Express internals tested (no content-type assertions)
  • No database mocking — real database with isolation

Verifiers

  • database-isolation — Isolate test database from production database
  • five-core-tests — Write the 5 core API tests: happy path, validation, 404, persistence, error format
  • test-file-created — Create at least one test file for the API

skills

api-testing-first-steps

tile.json