AWS API Gateway Testing Guide: Lambda Authorizers, Mock Integrations & CDK Tests
AWS API Gateway is notoriously difficult to test. Its configuration is spread across stages, integrations, authorizers, and models — none of which is easily testable in isolation. This guide covers pragmatic strategies: unit-testing Lambda authorizers, using mock integrations for downstream isolation, testing with CDK constructs, and integration testing against real Gateway deployments.
The Testing Pyramid for API Gateway
Unit tests (fast, local) — test Lambda authorizer logic, request/response transformation functions, and input validation independently of API Gateway.
Integration tests (medium, uses real AWS) — deploy to a test stage and send real HTTP requests through API Gateway, hitting real Lambda functions. Catches IAM permission issues, integration config errors, and CORS problems.
Contract tests — verify the API schema matches what clients expect. Catches breaking changes before clients discover them.
Lambda Authorizer Testing
Lambda authorizers are the most testable part of API Gateway. They're just functions.
Token-Based Authorizer
// authorizer.js
const jwt = require('jsonwebtoken');
exports.handler = async (event) => {
const token = extractToken(event.authorizationToken);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
return generatePolicy(decoded.sub, 'Allow', event.methodArn, {
userId: decoded.sub,
role: decoded.role
});
} catch (err) {
if (err.name === 'TokenExpiredError') {
throw new Error('Unauthorized'); // API GW maps to 401
}
throw new Error('Unauthorized');
}
};
function extractToken(header) {
if (!header) throw new Error('Unauthorized');
const parts = header.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') throw new Error('Unauthorized');
return parts[1];
}
function generatePolicy(principalId, effect, resource, context = {}) {
return {
principalId,
policyDocument: {
Version: '2012-10-17',
Statement: [{
Action: 'execute-api:Invoke',
Effect: effect,
Resource: resource
}]
},
context
};
}Authorizer Unit Tests
// authorizer.test.js
const jwt = require('jsonwebtoken');
// Set env before requiring handler
process.env.JWT_SECRET = 'test-secret';
const { handler } = require('./authorizer');
const METHOD_ARN = 'arn:aws:execute-api:us-east-1:123456789:api-id/test/GET/resource';
function makeEvent(token) {
return {
type: 'TOKEN',
authorizationToken: token,
methodArn: METHOD_ARN
};
}
describe('Lambda Authorizer', () => {
describe('valid token', () => {
it('returns Allow policy for valid JWT', async () => {
const token = jwt.sign(
{ sub: 'user-123', role: 'admin' },
'test-secret',
{ expiresIn: '1h' }
);
const result = await handler(makeEvent(`Bearer ${token}`));
expect(result.principalId).toBe('user-123');
expect(result.policyDocument.Statement[0].Effect).toBe('Allow');
expect(result.context.userId).toBe('user-123');
expect(result.context.role).toBe('admin');
});
it('includes method ARN in policy resource', async () => {
const token = jwt.sign({ sub: 'user-123' }, 'test-secret');
const result = await handler(makeEvent(`Bearer ${token}`));
expect(result.policyDocument.Statement[0].Resource).toBe(METHOD_ARN);
});
});
describe('invalid token', () => {
it('throws Unauthorized for missing Authorization header', async () => {
await expect(handler({ authorizationToken: undefined, methodArn: METHOD_ARN }))
.rejects.toThrow('Unauthorized');
});
it('throws Unauthorized for malformed Bearer format', async () => {
await expect(handler(makeEvent('InvalidFormat')))
.rejects.toThrow('Unauthorized');
});
it('throws Unauthorized for wrong secret', async () => {
const token = jwt.sign({ sub: 'user-123' }, 'wrong-secret');
await expect(handler(makeEvent(`Bearer ${token}`)))
.rejects.toThrow('Unauthorized');
});
it('throws Unauthorized for expired token', async () => {
const token = jwt.sign(
{ sub: 'user-123' },
'test-secret',
{ expiresIn: '-1h' } // Already expired
);
await expect(handler(makeEvent(`Bearer ${token}`)))
.rejects.toThrow('Unauthorized');
});
});
});Request-Based Authorizer
// request-authorizer.test.js
// Tests an authorizer that reads headers/query params instead of Bearer token
describe('Request Authorizer', () => {
const event = {
type: 'REQUEST',
methodArn: 'arn:aws:execute-api:...',
headers: {},
queryStringParameters: {},
requestContext: { stage: 'test' }
};
it('allows requests with valid API key header', async () => {
const result = await handler({
...event,
headers: { 'X-Api-Key': 'valid-key-123' }
});
expect(result.policyDocument.Statement[0].Effect).toBe('Allow');
});
it('denies requests with invalid API key', async () => {
const result = await handler({
...event,
headers: { 'X-Api-Key': 'bad-key' }
});
expect(result.policyDocument.Statement[0].Effect).toBe('Deny');
});
it('propagates API key metadata to context', async () => {
const result = await handler({
...event,
headers: { 'X-Api-Key': 'valid-key-123' }
});
expect(result.context.clientId).toBeDefined();
expect(result.context.rateLimit).toBeDefined();
});
});Mock Integrations
API Gateway's mock integration returns a configured response without calling any backend. Use it to test the API layer in isolation — CORS configuration, request validation, error responses — without depending on Lambda functions being deployed.
Creating a Mock Integration with CDK
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
// Mock integration for health check
const api = new apigateway.RestApi(this, 'TestApi');
const health = api.root.addResource('health');
health.addMethod('GET', new apigateway.MockIntegration({
integrationResponses: [{
statusCode: '200',
responseTemplates: {
'application/json': JSON.stringify({ status: 'ok', timestamp: '$context.requestTimeEpoch' })
},
responseParameters: {
'method.response.header.Access-Control-Allow-Origin': "'*'",
'method.response.header.Content-Type': "'application/json'"
}
}],
passthroughBehavior: apigateway.PassthroughBehavior.NEVER,
requestTemplates: {
'application/json': '{"statusCode": 200}'
}
}), {
methodResponses: [{
statusCode: '200',
responseParameters: {
'method.response.header.Access-Control-Allow-Origin': true,
'method.response.header.Content-Type': true
}
}]
});
// Mock integration for error scenarios
const error = api.root.addResource('error');
error.addMethod('GET', new apigateway.MockIntegration({
integrationResponses: [{
statusCode: '500',
responseTemplates: {
'application/json': '{"error": "Internal Server Error", "requestId": "$context.requestId"}'
}
}],
requestTemplates: {
'application/json': '{"statusCode": 500}'
}
}), {
methodResponses: [{ statusCode: '500' }]
});Testing Request Validation with Mock Integrations
// Validate that API Gateway enforces request models before hitting Lambda
const model = api.addModel('UserModel', {
contentType: 'application/json',
modelName: 'UserModel',
schema: {
type: apigateway.JsonSchemaType.OBJECT,
required: ['email', 'name'],
properties: {
email: { type: apigateway.JsonSchemaType.STRING, format: 'email' },
name: { type: apigateway.JsonSchemaType.STRING, minLength: 1 }
}
}
});
const users = api.root.addResource('users');
users.addMethod('POST', new apigateway.MockIntegration({
integrationResponses: [{ statusCode: '201' }],
requestTemplates: { 'application/json': '{"statusCode": 201}' }
}), {
requestValidator: new apigateway.RequestValidator(this, 'Validator', {
restApi: api,
validateRequestBody: true
}),
requestModels: { 'application/json': model },
methodResponses: [
{ statusCode: '201' },
{ statusCode: '400' }
]
});Integration Test Against Mock Endpoints
// api-gateway.test.js
const axios = require('axios');
const BASE_URL = process.env.API_BASE_URL || 'https://abc123.execute-api.us-east-1.amazonaws.com/test';
describe('API Gateway Mock Integrations', () => {
describe('GET /health', () => {
it('returns 200 with status ok', async () => {
const response = await axios.get(`${BASE_URL}/health`);
expect(response.status).toBe(200);
expect(response.data.status).toBe('ok');
});
it('includes CORS headers', async () => {
const response = await axios.get(`${BASE_URL}/health`);
expect(response.headers['access-control-allow-origin']).toBe('*');
});
});
describe('POST /users — request validation', () => {
it('accepts valid request body', async () => {
const response = await axios.post(`${BASE_URL}/users`, {
email: 'test@example.com',
name: 'Test User'
});
expect(response.status).toBe(201);
});
it('rejects request missing required fields', async () => {
try {
await axios.post(`${BASE_URL}/users`, { name: 'Missing Email' });
fail('Should have thrown');
} catch (err) {
expect(err.response.status).toBe(400);
}
});
it('rejects request with invalid email format', async () => {
try {
await axios.post(`${BASE_URL}/users`, {
email: 'not-an-email',
name: 'Test User'
});
fail('Should have thrown');
} catch (err) {
expect(err.response.status).toBe(400);
}
});
});
});CDK Test Constructs
Test CDK infrastructure code before deploying with CDK's assertion library.
import * as cdk from 'aws-cdk-lib';
import { Template, Match } from 'aws-cdk-lib/assertions';
import { MyApiStack } from '../lib/my-api-stack';
describe('MyApiStack', () => {
let template: Template;
beforeAll(() => {
const app = new cdk.App();
const stack = new MyApiStack(app, 'TestStack');
template = Template.fromStack(stack);
});
describe('API Gateway configuration', () => {
it('creates REST API', () => {
template.hasResource('AWS::ApiGateway::RestApi', {});
});
it('enables CloudWatch logging', () => {
template.hasResourceProperties('AWS::ApiGateway::Stage', {
AccessLogSetting: Match.objectLike({
DestinationArn: Match.anyValue()
}),
MethodSettings: Match.arrayWith([
Match.objectLike({
LoggingLevel: 'INFO',
DataTraceEnabled: true
})
])
});
});
it('configures throttling on deployment stage', () => {
template.hasResourceProperties('AWS::ApiGateway::Stage', {
DefaultRouteSettings: Match.objectLike({
ThrottlingBurstLimit: 500,
ThrottlingRateLimit: 100
})
});
});
});
describe('Lambda authorizer', () => {
it('creates Lambda function for authorizer', () => {
template.hasResourceProperties('AWS::Lambda::Function', {
FunctionName: Match.stringLikeRegexp('.*authorizer.*'),
Runtime: 'nodejs20.x',
Timeout: 10
});
});
it('authorizer has correct IAM permissions', () => {
template.hasResourceProperties('AWS::IAM::Policy', {
PolicyDocument: Match.objectLike({
Statement: Match.arrayWith([
Match.objectLike({
Action: 'execute-api:Invoke',
Effect: 'Allow'
})
])
})
});
});
it('caches authorizer results', () => {
template.hasResourceProperties('AWS::ApiGateway::Authorizer', {
AuthorizerResultTtlInSeconds: 300,
Type: 'TOKEN'
});
});
});
describe('CORS configuration', () => {
it('allows specified origins', () => {
template.hasResourceProperties('AWS::ApiGateway::Method', {
HttpMethod: 'OPTIONS',
Integration: Match.objectLike({
IntegrationResponses: Match.arrayWith([
Match.objectLike({
ResponseParameters: Match.objectLike({
'method.response.header.Access-Control-Allow-Origin':
Match.stringLikeRegexp('.*example\\.com.*')
})
})
])
})
});
});
});
});End-to-End Integration Tests
After deployment, verify the full stack works:
// e2e/api-gateway.e2e.test.js
const axios = require('axios');
const jwt = require('jsonwebtoken');
const BASE_URL = process.env.API_URL;
const JWT_SECRET = process.env.JWT_SECRET;
// Generate auth tokens
const validToken = jwt.sign(
{ sub: 'test-user-123', role: 'user' },
JWT_SECRET,
{ expiresIn: '1h', issuer: 'test-suite' }
);
const adminToken = jwt.sign(
{ sub: 'admin-user-456', role: 'admin' },
JWT_SECRET,
{ expiresIn: '1h', issuer: 'test-suite' }
);
const headers = { Authorization: `Bearer ${validToken}` };
const adminHeaders = { Authorization: `Bearer ${adminToken}` };
describe('API Gateway E2E', () => {
it('rejects unauthenticated requests', async () => {
const response = await axios.get(`${BASE_URL}/api/users`, {
validateStatus: () => true
});
expect(response.status).toBe(401);
});
it('allows authenticated requests', async () => {
const response = await axios.get(`${BASE_URL}/api/users`, { headers });
expect(response.status).toBe(200);
expect(response.data).toBeInstanceOf(Array);
});
it('propagates user context from authorizer', async () => {
const response = await axios.get(`${BASE_URL}/api/me`, { headers });
expect(response.status).toBe(200);
expect(response.data.userId).toBe('test-user-123');
});
it('enforces role-based access control', async () => {
// Regular user cannot access admin endpoint
const userResponse = await axios.get(`${BASE_URL}/api/admin/users`, {
headers,
validateStatus: () => true
});
expect(userResponse.status).toBe(403);
// Admin can access
const adminResponse = await axios.get(`${BASE_URL}/api/admin/users`, {
headers: adminHeaders
});
expect(adminResponse.status).toBe(200);
});
it('handles throttling', async () => {
// Send requests rapidly to trigger throttling
const requests = Array(100).fill(null).map(() =>
axios.get(`${BASE_URL}/api/public`, { validateStatus: () => true })
);
const responses = await Promise.all(requests);
const throttled = responses.filter(r => r.status === 429);
// At least some should be throttled
expect(throttled.length).toBeGreaterThan(0);
// Throttled responses should include Retry-After header
expect(throttled[0].headers['retry-after']).toBeDefined();
});
it('includes request ID in error responses', async () => {
const response = await axios.get(`${BASE_URL}/api/nonexistent`, {
headers,
validateStatus: () => true
});
expect(response.status).toBe(404);
expect(response.data.requestId).toMatch(/^[a-z0-9-]+$/);
});
});Common API Gateway Test Gaps
Authorizer caching — when AuthorizerResultTtlInSeconds > 0, the same token gets the same cached policy for subsequent requests. Test that policy updates (like revoking a user) are respected after cache expiry.
Stage variable resolution — if you use ${stageVariables.backendUrl}, test each stage has the correct variable set. A missing stage variable silently routes to nothing.
API key usage plans — test that usage plan limits are enforced separately from Lambda throttling limits.
Binary media types — if your API accepts file uploads, test that binary passthrough is configured and that API Gateway doesn't corrupt the payload.
Mapping templates — VTL templates for request/response transformation are notoriously bug-prone. Test them with edge cases: null fields, arrays, special characters in strings.