Skip to content

Testing Guide

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

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%+

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.