Skip to content

Testing Guide

Seed uses Vitest for fast, modern testing with TypeScript support. This guide covers writing and running tests effectively.

Testing Architecture

Test Framework

Vitest Configuration

Configuration file: vitest.config.ts

typescript
export default defineConfig({
  test: {
    globals: true,                        // Use global test APIs
    environment: "node",                  // Node environment
    include: ["src/**/*.test.ts"],       // Test file pattern
    coverage: {
      provider: "v8",                     // V8 coverage provider
      reporter: ["text", "json", "html"], // Coverage formats
      include: ["src/**/*.ts"],           // Files to cover
      exclude: ["src/**/*.test.ts", "src/types/**/*.ts"],
    },
  },
});

Running Tests

CommandPurpose
npm testRun all tests once
npm run test:watchRun tests in watch mode
npm run test:coverageRun tests with coverage report

Running specific tests:

bash
# Run tests matching pattern
npx vitest run -t "auth middleware"

# Run specific test file
npx vitest run src/middleware/auth.test.ts

# Run tests in specific directory
npx vitest run src/routes/

Test Structure

File Organization

Tests are co-located with source files:

src/
├── middleware/
│   ├── auth.ts           # Implementation
│   └── auth.test.ts      # Tests
├── routes/
│   ├── health.ts
│   └── health.test.ts
└── services/
    ├── redis.ts
    └── redis.test.ts

Test File Template

typescript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

describe("Feature name", () => {
  // Setup before each test
  beforeEach(() => {
    // Initialize mocks, reset state
  });

  // Cleanup after each test
  afterEach(() => {
    // Clear mocks, restore state
  });

  it("should do something specific", () => {
    // Arrange: Set up test data
    const input = "test";

    // Act: Execute the code
    const result = functionUnderTest(input);

    // Assert: Verify the result
    expect(result).toBe("expected");
  });

  it("should handle error cases", () => {
    expect(() => functionUnderTest(null)).toThrow("error message");
  });
});

Unit Testing

Testing Pure Functions

typescript
// src/utils/helpers.ts
export function generateClientId(prefix: string): string {
  const random = randomBytes(6).toString("base64url");
  return `${prefix}${random}`;
}

// src/utils/helpers.test.ts
import { describe, it, expect } from "vitest";
import { generateClientId } from "./helpers.js";

describe("generateClientId", () => {
  it("should generate ID with correct prefix", () => {
    const clientId = generateClientId("seed-");
    expect(clientId).toMatch(/^seed-[a-zA-Z0-9_-]{8}$/);
  });

  it("should generate unique IDs", () => {
    const id1 = generateClientId("seed-");
    const id2 = generateClientId("seed-");
    expect(id1).not.toBe(id2);
  });
});

Testing with Mocks

typescript
// src/services/client-store.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Redis } from "ioredis";
import { ClientStore } from "./client-store.js";

// Mock Redis module
vi.mock("ioredis");

describe("ClientStore", () => {
  let mockRedis: Redis;
  let store: ClientStore;

  beforeEach(() => {
    // Create mock Redis client
    mockRedis = {
      get: vi.fn(),
      setex: vi.fn(),
      del: vi.fn(),
    } as unknown as Redis;

    store = new ClientStore(mockRedis);
  });

  it("should save client to Redis", async () => {
    const client = {
      client_id: "seed-abc123",
      client_name: "Test Client",
    };

    await store.save(client);

    expect(mockRedis.setex).toHaveBeenCalledWith(
      "dcr:client:seed-abc123",
      2592000,
      JSON.stringify(client)
    );
  });

  it("should retrieve client from Redis", async () => {
    const clientData = JSON.stringify({
      client_id: "seed-abc123",
      client_name: "Test Client",
    });

    vi.mocked(mockRedis.get).mockResolvedValue(clientData);

    const client = await store.get("seed-abc123");

    expect(client).toEqual({
      client_id: "seed-abc123",
      client_name: "Test Client",
    });
    expect(mockRedis.get).toHaveBeenCalledWith("dcr:client:seed-abc123");
  });

  it("should return null for non-existent client", async () => {
    vi.mocked(mockRedis.get).mockResolvedValue(null);

    const client = await store.get("seed-nonexistent");

    expect(client).toBeNull();
  });
});

Testing MCP Tools

typescript
// src/mcp/tools/seed_ping.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerSeedPing } from "./seed_ping.js";

// Mock configuration
vi.mock("../../config/index.js", () => ({
  config: {
    server: {
      version: "0.1.0",
    },
  },
}));

describe("seed_ping tool", () => {
  let server: McpServer;
  let registeredHandler: () => Promise<{ content: { type: string; text: string }[] }>;

  beforeEach(() => {
    // Mock MCP server
    server = {
      registerTool: vi.fn((name, options, handler) => {
        registeredHandler = handler;
      }),
    } as unknown as McpServer;

    registerSeedPing(server);
  });

  it("should register the seed_ping tool", () => {
    expect(server.registerTool).toHaveBeenCalledWith(
      "seed_ping",
      { description: "Health check tool - returns server status" },
      expect.any(Function)
    );
  });

  it("should return status ok with version", async () => {
    const result = await registeredHandler();

    expect(result.content).toHaveLength(1);
    const content = result.content[0];
    expect(content?.type).toBe("text");

    const parsed = JSON.parse(content?.text ?? "{}");
    expect(parsed.status).toBe("ok");
    expect(parsed.version).toBe("0.1.0");
  });

  it("should include valid ISO timestamp", async () => {
    const before = new Date();
    const result = await registeredHandler();
    const after = new Date();

    const content = result.content[0];
    const parsed = JSON.parse(content?.text ?? "{}");
    const timestamp = new Date(parsed.timestamp);

    expect(timestamp.getTime()).toBeGreaterThanOrEqual(before.getTime());
    expect(timestamp.getTime()).toBeLessThanOrEqual(after.getTime());
  });
});

Integration Testing

Testing HTTP Endpoints

Use Supertest for HTTP endpoint testing:

typescript
// src/routes/health.test.ts
import { describe, it, expect, vi } from "vitest";
import request from "supertest";
import express from "express";
import { healthRouter } from "./health.js";

// Mock configuration
vi.mock("../config/index.js", () => ({
  config: {
    server: {
      version: "0.1.0",
    },
  },
}));

describe("/health endpoint", () => {
  const app = express();
  app.use("/health", healthRouter);

  it("should return 200 OK", async () => {
    const response = await request(app).get("/health");
    expect(response.status).toBe(200);
  });

  it("should return status ok", async () => {
    const response = await request(app).get("/health");
    expect(response.body.status).toBe("ok");
  });

  it("should return the server version", async () => {
    const response = await request(app).get("/health");
    expect(response.body.version).toBe("0.1.0");
  });

  it("should return JSON content type", async () => {
    const response = await request(app).get("/health");
    expect(response.headers["content-type"]).toMatch(/application\/json/);
  });
});

Testing Middleware

typescript
// src/middleware/auth.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, Response, NextFunction } from "express";
import { authMiddleware } from "./auth.js";

describe("authMiddleware", () => {
  let mockReq: Partial<Request>;
  let mockRes: Partial<Response>;
  let mockNext: NextFunction;

  beforeEach(() => {
    mockReq = {
      path: "/mcp",
      headers: {},
    };

    mockRes = {
      status: vi.fn().mockReturnThis(),
      json: vi.fn().mockReturnThis(),
      setHeader: vi.fn(),
    };

    mockNext = vi.fn();
  });

  it("should call next() for public paths", async () => {
    mockReq.path = "/health";

    await authMiddleware(
      mockReq as Request,
      mockRes as Response,
      mockNext
    );

    expect(mockNext).toHaveBeenCalled();
  });

  it("should return 401 for missing Authorization header", async () => {
    await authMiddleware(
      mockReq as Request,
      mockRes as Response,
      mockNext
    );

    expect(mockRes.status).toHaveBeenCalledWith(401);
    expect(mockRes.json).toHaveBeenCalledWith(
      expect.objectContaining({
        error: expect.objectContaining({
          code: -32001,
          message: "Unauthorized",
        }),
      })
    );
  });

  it("should validate Bearer token format", async () => {
    mockReq.headers = { authorization: "InvalidFormat" };

    await authMiddleware(
      mockReq as Request,
      mockRes as Response,
      mockNext
    );

    expect(mockRes.status).toHaveBeenCalledWith(401);
  });
});

Testing with Async Operations

typescript
describe("async operations", () => {
  it("should handle async functions", async () => {
    const result = await someAsyncFunction();
    expect(result).toBeDefined();
  });

  it("should handle promise rejections", async () => {
    await expect(failingAsyncFunction()).rejects.toThrow("error");
  });

  it("should timeout long operations", async () => {
    const promise = longRunningOperation();
    await expect(promise).rejects.toThrow("timeout");
  }, 5000); // 5 second timeout
});

Test Patterns

AAA Pattern (Arrange, Act, Assert)

typescript
it("should calculate total correctly", () => {
  // Arrange: Set up test data
  const items = [
    { price: 10, quantity: 2 },
    { price: 5, quantity: 3 },
  ];

  // Act: Execute the function
  const total = calculateTotal(items);

  // Assert: Verify the result
  expect(total).toBe(35);
});

Testing Error Conditions

typescript
it("should throw error for invalid input", () => {
  expect(() => validateInput(null)).toThrow("Input cannot be null");
  expect(() => validateInput("")).toThrow("Input cannot be empty");
  expect(() => validateInput(-1)).toThrow("Input must be positive");
});

Testing Type Guards

typescript
function isUser(obj: unknown): obj is User {
  return typeof obj === "object" && obj !== null && "id" in obj;
}

it("should correctly identify User objects", () => {
  expect(isUser({ id: 1, name: "Test" })).toBe(true);
  expect(isUser({})).toBe(false);
  expect(isUser(null)).toBe(false);
  expect(isUser("string")).toBe(false);
});

Snapshot Testing

typescript
it("should match snapshot", () => {
  const result = generateComplexObject();
  expect(result).toMatchSnapshot();
});

// Update snapshots with: npx vitest run -u

Mocking Best Practices

Module Mocking

typescript
// Mock entire module
vi.mock("./module.js", () => ({
  functionA: vi.fn(),
  functionB: vi.fn(),
}));

// Mock specific exports
vi.mock("./module.js", async () => {
  const actual = await vi.importActual("./module.js");
  return {
    ...actual,
    specificFunction: vi.fn(),
  };
});

Spy on Functions

typescript
import { vi } from "vitest";
import * as module from "./module.js";

it("should call function", () => {
  const spy = vi.spyOn(module, "functionName");

  module.functionName("arg");

  expect(spy).toHaveBeenCalledWith("arg");
  expect(spy).toHaveBeenCalledTimes(1);

  spy.mockRestore();
});

Mock Return Values

typescript
const mockFn = vi.fn();

// Single return value
mockFn.mockReturnValue("value");

// Async return value
mockFn.mockResolvedValue("async value");

// Sequential return values
mockFn
  .mockReturnValueOnce("first")
  .mockReturnValueOnce("second")
  .mockReturnValue("default");

// Throw error
mockFn.mockRejectedValue(new Error("failure"));

Coverage Requirements

Running Coverage

bash
npm run test:coverage

Output formats:

  • Terminal summary
  • HTML report: coverage/index.html
  • JSON data: coverage/coverage-final.json

Coverage Configuration

typescript
coverage: {
  provider: "v8",
  reporter: ["text", "json", "html"],
  include: ["src/**/*.ts"],
  exclude: [
    "src/**/*.test.ts",     // Test files
    "src/types/**/*.ts",    // Type definitions
  ],
}

Coverage Thresholds

While not enforced in CI yet, aim for:

  • Statements: 80%+
  • Branches: 75%+
  • Functions: 80%+
  • Lines: 80%+

Recent coverage achievements:

  • Configuration Validation (2026-01-06): 42 tests, 96.26% coverage
  • Graceful Shutdown (2026-01-06): Comprehensive signal handling tests
  • Health Checks (2026-01-06): Liveness and readiness probe tests
  • Token Revocation (2026-01-06): RFC 7009 compliance tests

Debugging Tests

Using Console Logging

typescript
it("should debug test", () => {
  console.log("Debug value:", value);
  expect(value).toBe(expected);
});

VS Code Debugging

Add .vscode/launch.json:

json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug Tests",
      "runtimeExecutable": "npm",
      "runtimeArgs": ["run", "test:watch"],
      "console": "integratedTerminal"
    }
  ]
}

Isolating Tests

typescript
// Run only this test
it.only("should run only this test", () => {
  // ...
});

// Skip this test
it.skip("should skip this test", () => {
  // ...
});

// Run only this describe block
describe.only("Feature", () => {
  // ...
});

Common Testing Pitfalls

1. Not Cleaning Up Mocks

Problem:

typescript
it("test 1", () => {
  vi.mock("./module.js");
  // Test runs
});

it("test 2", () => {
  // Mock still active from test 1
});

Solution:

typescript
afterEach(() => {
  vi.restoreAllMocks();
  vi.clearAllMocks();
});

2. Testing Implementation Details

Bad:

typescript
it("should call internal function", () => {
  const spy = vi.spyOn(service, "_internalMethod");
  service.publicMethod();
  expect(spy).toHaveBeenCalled();
});

Good:

typescript
it("should return correct result", () => {
  const result = service.publicMethod();
  expect(result).toBe(expected);
});

3. Flaky Tests

Causes:

  • Time-dependent tests
  • Race conditions
  • External dependencies
  • Random data

Solutions:

  • Mock Date.now() and timers
  • Use await for async operations
  • Mock external services
  • Use deterministic test data

Writing Testable Code

Dependency Injection

Bad:

typescript
export function fetchData() {
  const redis = new Redis();  // Hard to mock
  return redis.get("key");
}

Good:

typescript
export function fetchData(redis: Redis) {
  return redis.get("key");     // Easy to mock
}

Pure Functions

Bad:

typescript
let total = 0;
export function addToTotal(value: number) {
  total += value;  // Side effect
}

Good:

typescript
export function calculateTotal(current: number, value: number): number {
  return current + value;  // Pure function
}

Next Steps

Released under the MIT License.