Astro API Route Testing Strategies

Astro API Route Testing Strategies

Astro's API routes (files in src/pages/api/) let you build backend endpoints alongside your frontend pages. They handle HTTP methods, parse request bodies, set cookies, and return JSON — all within the same project. But they need their own testing approach: unit tests for handler logic, integration tests with real HTTP requests, and security tests for authentication and authorization.

This guide covers the full testing strategy for Astro API routes, from pure unit tests to production-like integration tests.

How Astro API Routes Work

Before writing tests, understand what you're testing. An Astro API route is a file that exports handler functions for HTTP methods:

// src/pages/api/users/[id].ts
import type { APIRoute } from 'astro';
import { getUserById, updateUser, deleteUser } from '../../../lib/users';
import { requireAuth } from '../../../lib/auth';

export const GET: APIRoute = async ({ params, request }) => {
  const auth = await requireAuth(request);
  if (!auth.success) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), {
      status: 401,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  const user = await getUserById(params.id!);
  if (!user) {
    return new Response(JSON.stringify({ error: 'User not found' }), {
      status: 404,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  return new Response(JSON.stringify(user), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });
};

export const PUT: APIRoute = async ({ params, request }) => {
  const auth = await requireAuth(request);
  if (!auth.success) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), {
      status: 401,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  const body = await request.json();
  const updated = await updateUser(params.id!, body);

  return new Response(JSON.stringify(updated), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });
};

export const DELETE: APIRoute = async ({ params, request }) => {
  const auth = await requireAuth(request);
  if (!auth.success) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
  }

  await deleteUser(params.id!);
  return new Response(null, { status: 204 });
};

Unit Testing API Route Handlers

API route handlers are just async functions that receive a context object and return a Response. You can test them directly without running a server.

// src/lib/auth.ts
export interface AuthResult {
  success: boolean;
  userId?: string;
  role?: 'admin' | 'user';
}

export async function requireAuth(request: Request): Promise<AuthResult> {
  const authHeader = request.headers.get('Authorization');
  if (!authHeader?.startsWith('Bearer ')) {
    return { success: false };
  }

  const token = authHeader.slice(7);
  // In real code: verify JWT, check database, etc.
  if (token === 'invalid') return { success: false };

  return { success: true, userId: 'user-123', role: 'user' };
}
// src/pages/api/__tests__/users-id.test.ts
import { describe, expect, test, vi, beforeEach } from 'vitest';
import type { APIContext } from 'astro';

// Mock dependencies before importing the route
vi.mock('../../../lib/users', () => ({
  getUserById: vi.fn(),
  updateUser: vi.fn(),
  deleteUser: vi.fn(),
}));

vi.mock('../../../lib/auth', () => ({
  requireAuth: vi.fn(),
}));

import { GET, PUT, DELETE } from '../users/[id]';
import { getUserById, updateUser, deleteUser } from '../../../lib/users';
import { requireAuth } from '../../../lib/auth';

// Helper to create a mock APIContext
function createContext(overrides: Partial<APIContext> = {}): APIContext {
  return {
    params: { id: 'user-123' },
    request: new Request('http://localhost/api/users/user-123', {
      headers: { 'Authorization': 'Bearer valid-token' },
    }),
    url: new URL('http://localhost/api/users/user-123'),
    site: new URL('http://localhost'),
    generator: 'Astro',
    props: {},
    redirect: vi.fn(),
    locals: {},
    cookies: {
      get: vi.fn(),
      set: vi.fn(),
      delete: vi.fn(),
      has: vi.fn(),
      headers: vi.fn(),
    },
    ...overrides,
  } as unknown as APIContext;
}

describe('GET /api/users/[id]', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.mocked(requireAuth).mockResolvedValue({ success: true, userId: 'admin', role: 'admin' });
  });

  test('returns user data for valid ID', async () => {
    const mockUser = { id: 'user-123', name: 'Alice', email: 'alice@example.com' };
    vi.mocked(getUserById).mockResolvedValue(mockUser);

    const context = createContext();
    const response = await GET(context);

    expect(response.status).toBe(200);
    const body = await response.json();
    expect(body).toEqual(mockUser);
  });

  test('returns 404 when user not found', async () => {
    vi.mocked(getUserById).mockResolvedValue(null);

    const response = await GET(createContext());

    expect(response.status).toBe(404);
    const body = await response.json();
    expect(body.error).toBe('User not found');
  });

  test('returns 401 when not authenticated', async () => {
    vi.mocked(requireAuth).mockResolvedValue({ success: false });

    const response = await GET(createContext());

    expect(response.status).toBe(401);
    const body = await response.json();
    expect(body.error).toBe('Unauthorized');
  });

  test('does not call getUserById when auth fails', async () => {
    vi.mocked(requireAuth).mockResolvedValue({ success: false });

    await GET(createContext());

    expect(getUserById).not.toHaveBeenCalled();
  });

  test('returns correct Content-Type header', async () => {
    vi.mocked(getUserById).mockResolvedValue({ id: 'user-123', name: 'Alice', email: 'alice@example.com' });

    const response = await GET(createContext());

    expect(response.headers.get('Content-Type')).toBe('application/json');
  });
});

describe('PUT /api/users/[id]', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.mocked(requireAuth).mockResolvedValue({ success: true, userId: 'admin', role: 'admin' });
  });

  test('updates user and returns updated data', async () => {
    const updatedUser = { id: 'user-123', name: 'Alice Updated', email: 'alice@example.com' };
    vi.mocked(updateUser).mockResolvedValue(updatedUser);

    const context = createContext({
      request: new Request('http://localhost/api/users/user-123', {
        method: 'PUT',
        headers: {
          'Authorization': 'Bearer valid-token',
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ name: 'Alice Updated' }),
      }),
    });

    const response = await PUT(context);

    expect(response.status).toBe(200);
    expect(updateUser).toHaveBeenCalledWith('user-123', { name: 'Alice Updated' });
    const body = await response.json();
    expect(body.name).toBe('Alice Updated');
  });
});

describe('DELETE /api/users/[id]', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.mocked(requireAuth).mockResolvedValue({ success: true, userId: 'admin', role: 'admin' });
    vi.mocked(deleteUser).mockResolvedValue(undefined);
  });

  test('deletes user and returns 204', async () => {
    const response = await DELETE(createContext());

    expect(response.status).toBe(204);
    expect(deleteUser).toHaveBeenCalledWith('user-123');
  });

  test('returns 401 for unauthenticated delete', async () => {
    vi.mocked(requireAuth).mockResolvedValue({ success: false });

    const response = await DELETE(createContext());

    expect(response.status).toBe(401);
    expect(deleteUser).not.toHaveBeenCalled();
  });
});

Testing Middleware

Astro middleware (src/middleware.ts) runs on every request before route handlers. It's common ground for auth, logging, rate limiting, and header injection.

// src/middleware.ts
import { defineMiddleware } from 'astro/middleware';
import { verifyJWT } from './lib/auth';

export const onRequest = defineMiddleware(async ({ request, locals, redirect }, next) => {
  // Add request ID for tracing
  const requestId = crypto.randomUUID();
  locals.requestId = requestId;

  // Parse auth token
  const authHeader = request.headers.get('Authorization');
  if (authHeader?.startsWith('Bearer ')) {
    try {
      const payload = await verifyJWT(authHeader.slice(7));
      locals.user = payload;
    } catch {
      // Token invalid — locals.user stays undefined
    }
  }

  // Rate limiting check
  const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown';
  const rateKey = `rate:${ip}`;
  // In real code: check Redis, increment counter, etc.

  const response = await next();

  // Add security headers to all responses
  response.headers.set('X-Request-Id', requestId);
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');

  return response;
});

Test middleware logic directly:

// src/__tests__/middleware.test.ts
import { describe, expect, test, vi, beforeEach } from 'vitest';

vi.mock('./lib/auth', () => ({
  verifyJWT: vi.fn(),
}));

import { verifyJWT } from './lib/auth';

// Extract the middleware logic for testing without the Astro wrapper
async function runMiddlewareLogic(
  request: Request,
  locals: Record<string, unknown>,
  next: () => Promise<Response>
) {
  const authHeader = request.headers.get('Authorization');
  if (authHeader?.startsWith('Bearer ')) {
    try {
      const payload = await verifyJWT(authHeader.slice(7));
      locals.user = payload;
    } catch {
      // invalid token
    }
  }

  const response = await next();

  const requestId = crypto.randomUUID();
  response.headers.set('X-Request-Id', requestId);
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');

  return response;
}

describe('Middleware', () => {
  const mockNext = vi.fn().mockResolvedValue(new Response('OK', { status: 200 }));

  beforeEach(() => {
    vi.clearAllMocks();
    mockNext.mockResolvedValue(new Response('OK', { status: 200 }));
  });

  test('sets security headers on all responses', async () => {
    const request = new Request('http://localhost/api/test');
    const locals: Record<string, unknown> = {};

    const response = await runMiddlewareLogic(request, locals, mockNext);

    expect(response.headers.get('X-Frame-Options')).toBe('DENY');
    expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
    expect(response.headers.get('X-Request-Id')).toBeTruthy();
  });

  test('populates locals.user for valid JWT', async () => {
    const mockUser = { sub: 'user-123', role: 'admin' };
    vi.mocked(verifyJWT).mockResolvedValue(mockUser);

    const request = new Request('http://localhost/api/test', {
      headers: { 'Authorization': 'Bearer valid.jwt.token' },
    });
    const locals: Record<string, unknown> = {};

    await runMiddlewareLogic(request, locals, mockNext);

    expect(locals.user).toEqual(mockUser);
  });

  test('leaves locals.user undefined for invalid JWT', async () => {
    vi.mocked(verifyJWT).mockRejectedValue(new Error('Invalid token'));

    const request = new Request('http://localhost/api/test', {
      headers: { 'Authorization': 'Bearer invalid.token' },
    });
    const locals: Record<string, unknown> = {};

    await runMiddlewareLogic(request, locals, mockNext);

    expect(locals.user).toBeUndefined();
  });

  test('leaves locals.user undefined when no auth header', async () => {
    const request = new Request('http://localhost/api/test');
    const locals: Record<string, unknown> = {};

    await runMiddlewareLogic(request, locals, mockNext);

    expect(locals.user).toBeUndefined();
    expect(verifyJWT).not.toHaveBeenCalled();
  });
});

Integration Tests with Real HTTP Calls

Unit tests validate logic in isolation. Integration tests validate the whole stack — routing, middleware, handler logic, and response formatting — against a running server.

Create a test server utility:

// src/test/server.ts
import { createServer } from 'node:http';
import { preview } from 'astro';

let previewServer: Awaited<ReturnType<typeof preview>> | null = null;
let baseURL: string;

export async function startTestServer() {
  if (previewServer) return baseURL;

  // Build and start preview server for integration tests
  previewServer = await preview({
    root: process.cwd(),
    server: { port: 0 }, // Use random available port
  });

  const address = previewServer.address;
  baseURL = `http://localhost:${(address as any).port}`;
  return baseURL;
}

export async function stopTestServer() {
  if (previewServer) {
    await previewServer.stop();
    previewServer = null;
  }
}

export function getBaseURL() {
  return baseURL;
}
// src/pages/api/__tests__/users.integration.test.ts
import { describe, expect, test, beforeAll, afterAll } from 'vitest';
import { startTestServer, stopTestServer } from '../../../test/server';

describe('Users API — integration tests', () => {
  let baseURL: string;

  beforeAll(async () => {
    baseURL = await startTestServer();
  }, 30000);

  afterAll(async () => {
    await stopTestServer();
  });

  test('GET /api/users returns list of users', async () => {
    const response = await fetch(`${baseURL}/api/users`, {
      headers: { 'Authorization': 'Bearer test-admin-token' },
    });

    expect(response.status).toBe(200);
    expect(response.headers.get('content-type')).toContain('application/json');

    const data = await response.json();
    expect(Array.isArray(data)).toBe(true);
  });

  test('GET /api/users/1 returns specific user', async () => {
    const response = await fetch(`${baseURL}/api/users/1`, {
      headers: { 'Authorization': 'Bearer test-admin-token' },
    });

    expect(response.status).toBe(200);
    const user = await response.json();
    expect(user).toHaveProperty('id');
    expect(user).toHaveProperty('email');
  });

  test('GET /api/users without auth returns 401', async () => {
    const response = await fetch(`${baseURL}/api/users`);
    expect(response.status).toBe(401);
  });

  test('POST /api/users creates new user', async () => {
    const newUser = {
      name: 'Test User',
      email: `test-${Date.now()}@example.com`,
    };

    const response = await fetch(`${baseURL}/api/users`, {
      method: 'POST',
      headers: {
        'Authorization': 'Bearer test-admin-token',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(newUser),
    });

    expect(response.status).toBe(201);
    const created = await response.json();
    expect(created.email).toBe(newUser.email);
    expect(created.id).toBeTruthy();
  });

  test('POST /api/users with invalid data returns 422', async () => {
    const response = await fetch(`${baseURL}/api/users`, {
      method: 'POST',
      headers: {
        'Authorization': 'Bearer test-admin-token',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ name: '' }), // Missing email
    });

    expect(response.status).toBe(422);
    const error = await response.json();
    expect(error.errors).toBeDefined();
  });

  test('security headers are present on all responses', async () => {
    const response = await fetch(`${baseURL}/api/users`, {
      headers: { 'Authorization': 'Bearer test-admin-token' },
    });

    expect(response.headers.get('X-Frame-Options')).toBe('DENY');
    expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
    expect(response.headers.get('X-Request-Id')).toBeTruthy();
  });
});

Testing Authentication in API Routes

Authentication testing has several dimensions: token validation, role-based access, token expiration, and CORS.

// src/pages/api/__tests__/auth.test.ts
import { describe, expect, test, vi } from 'vitest';

vi.mock('../../../lib/db');
vi.mock('../../../lib/auth');

import { POST as loginHandler } from '../auth/login';
import { POST as refreshHandler } from '../auth/refresh';

function makeLoginRequest(body: Record<string, string>) {
  return {
    params: {},
    request: new Request('http://localhost/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    }),
    cookies: { get: vi.fn(), set: vi.fn(), delete: vi.fn(), has: vi.fn(), headers: vi.fn() },
    locals: {},
    url: new URL('http://localhost/api/auth/login'),
    redirect: vi.fn(),
  } as any;
}

describe('Authentication API routes', () => {
  test('login with valid credentials returns token', async () => {
    const { getUserByEmail, verifyPassword } = await import('../../../lib/db');
    const { signJWT } = await import('../../../lib/auth');

    vi.mocked(getUserByEmail).mockResolvedValue({
      id: 'user-1',
      email: 'user@example.com',
      passwordHash: 'hashed',
      role: 'user',
    });
    vi.mocked(verifyPassword).mockResolvedValue(true);
    vi.mocked(signJWT).mockResolvedValue('jwt.token.here');

    const response = await loginHandler(makeLoginRequest({
      email: 'user@example.com',
      password: 'correct-password',
    }));

    expect(response.status).toBe(200);
    const body = await response.json();
    expect(body.token).toBe('jwt.token.here');
  });

  test('login with wrong password returns 401', async () => {
    const { getUserByEmail, verifyPassword } = await import('../../../lib/db');

    vi.mocked(getUserByEmail).mockResolvedValue({
      id: 'user-1',
      email: 'user@example.com',
      passwordHash: 'hashed',
      role: 'user',
    });
    vi.mocked(verifyPassword).mockResolvedValue(false);

    const response = await loginHandler(makeLoginRequest({
      email: 'user@example.com',
      password: 'wrong-password',
    }));

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

  test('login with unknown email returns 401 (not 404)', async () => {
    const { getUserByEmail } = await import('../../../lib/db');
    vi.mocked(getUserByEmail).mockResolvedValue(null);

    const response = await loginHandler(makeLoginRequest({
      email: 'nobody@example.com',
      password: 'any-password',
    }));

    // Must return 401, not 404 — don't reveal whether email exists
    expect(response.status).toBe(401);
  });

  test('login with missing fields returns 400', async () => {
    const response = await loginHandler(makeLoginRequest({
      email: 'user@example.com',
      // Missing password
    }));

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

Testing CORS and Preflight Requests

// src/pages/api/__tests__/cors.test.ts
import { describe, expect, test } from 'vitest';
import { GET } from '../public-data';

describe('CORS handling', () => {
  test('includes CORS headers for allowed origin', async () => {
    const context = {
      request: new Request('http://localhost/api/public-data', {
        headers: { 'Origin': 'https://app.example.com' },
      }),
      params: {},
      locals: {},
      cookies: { get: vi.fn(), set: vi.fn(), delete: vi.fn(), has: vi.fn(), headers: vi.fn() },
      url: new URL('http://localhost/api/public-data'),
      redirect: vi.fn(),
    } as any;

    const response = await GET(context);

    expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://app.example.com');
  });

  test('OPTIONS preflight returns correct headers', async () => {
    const { OPTIONS } = await import('../public-data');

    const context = {
      request: new Request('http://localhost/api/public-data', {
        method: 'OPTIONS',
        headers: {
          'Origin': 'https://app.example.com',
          'Access-Control-Request-Method': 'GET',
          'Access-Control-Request-Headers': 'Authorization',
        },
      }),
      params: {},
      locals: {},
      cookies: { get: vi.fn(), set: vi.fn(), delete: vi.fn(), has: vi.fn(), headers: vi.fn() },
      url: new URL('http://localhost/api/public-data'),
      redirect: vi.fn(),
    } as any;

    const response = await OPTIONS(context);

    expect(response.status).toBe(204);
    expect(response.headers.get('Access-Control-Allow-Methods')).toContain('GET');
    expect(response.headers.get('Access-Control-Allow-Headers')).toContain('Authorization');
  });
});

Running API Tests

Structure your test commands in package.json:

{
  "scripts": {
    "test:unit": "vitest run src/**/*.test.ts --exclude src/**/*.integration.test.ts",
    "test:integration": "vitest run src/**/*.integration.test.ts",
    "test:api": "vitest run src/pages/api",
    "test:all": "vitest run"
  }
}

In CI, run unit tests on every commit (fast) and integration tests on PRs and main branch merges (slower but more thorough):

# .github/workflows/test.yml
- name: Unit tests
  run: npm run test:unit

- name: Build for integration tests
  run: npm run build











  
- name: Integration tests
  run: npm run test:integration

The key to reliable API route tests is separating concerns: pure unit tests for handler logic (fast, no server needed), middleware tests for cross-cutting concerns, and integration tests for the end-to-end HTTP contract. Together they catch everything from logic errors to response formatting bugs to auth bypass vulnerabilities.

Read more

Start now free