Azure API Management Testing: Policies, Throttling, and Gateway Behavior
Azure API Management (APIM) sits in front of your backend APIs and handles cross-cutting concerns: authentication, rate limiting, request/response transformation, caching, and routing. Policies are XML rules that execute on every request, and bugs in them break all your APIs simultaneously. Testing APIM policies requires running them locally with the self-hosted gateway or validating behavior against a test APIM instance.
Testing Approach Options
| Approach | Pros | Cons | Use For |
|---|---|---|---|
| Self-hosted gateway locally | Offline, fast, free | Limited policy support | Unit-style policy tests |
| APIM test instance | Full fidelity | Costs money, requires Azure | Integration tests |
| Policy XML unit testing | Zero infrastructure | Doesn't test full pipeline | Policy syntax/logic validation |
| Mock backend + APIM passthrough | Tests APIM behavior against mock | Needs APIM instance | End-to-end policy testing |
Running the Self-Hosted Gateway Locally
The APIM self-hosted gateway is a Docker container that connects to your APIM instance:
# Pull gateway
docker pull mcr.microsoft.com/azure-api-management/gateway:latest
# Config — connect to your APIM instance
docker run -d \
--name apim-gateway \
-p 8080:8080 \
-p 8081:8081 \
-e config.service.endpoint="https://my-apim.configuration.azure-api.net" \
-e config.service.auth="GatewayKey <your-gateway-token>" \
mcr.microsoft.com/azure-api-management/gateway:latestFor local-only testing without an APIM instance, use the self-hosted gateway with a local configuration file (preview feature).
Testing Policy Behavior with Playwright/Curl
The most practical approach: write tests that exercise specific APIM behavior through HTTP:
// apim.test.js
const BASE_URL = process.env.APIM_URL || 'http://localhost:8080';
const API_KEY = process.env.APIM_SUBSCRIPTION_KEY || 'test-key';
const headers = {
'Ocp-Apim-Subscription-Key': API_KEY,
};
describe('APIM Authentication', () => {
test('rejects requests without subscription key', async () => {
const response = await fetch(`${BASE_URL}/api/orders`);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.statusCode).toBe(401);
expect(body.message).toMatch(/Access denied/i);
});
test('rejects invalid subscription key', async () => {
const response = await fetch(`${BASE_URL}/api/orders`, {
headers: { 'Ocp-Apim-Subscription-Key': 'invalid-key-xyz' },
});
expect(response.status).toBe(401);
});
test('accepts valid subscription key', async () => {
const response = await fetch(`${BASE_URL}/api/orders`, { headers });
// Should reach backend (or at least not be rejected by APIM)
expect(response.status).not.toBe(401);
expect(response.status).not.toBe(403);
});
});Testing Rate Limiting Policies
describe('Rate limiting', () => {
test('allows requests within rate limit', async () => {
const requests = Array.from({ length: 5 }, () =>
fetch(`${BASE_URL}/api/products`, { headers })
);
const responses = await Promise.all(requests);
const statuses = responses.map(r => r.status);
// All should succeed (within limit)
expect(statuses.every(s => s === 200)).toBe(true);
});
test('rate limits when threshold exceeded', async () => {
// Burst more requests than the rate limit allows
// This test depends on your APIM rate limit policy configuration
// Example: "10 calls per minute" policy
const requests = Array.from({ length: 15 }, () =>
fetch(`${BASE_URL}/api/products`, {
headers: {
...headers,
// Use a different subscription key to isolate this test
'Ocp-Apim-Subscription-Key': process.env.APIM_RATE_TEST_KEY,
},
})
);
const responses = await Promise.all(requests);
const statuses = responses.map(r => r.status);
// Some should be rate limited (429)
const rateLimited = statuses.filter(s => s === 429);
expect(rateLimited.length).toBeGreaterThan(0);
// Check Retry-After header on 429
const rateLimitedResponse = responses.find(r => r.status === 429);
if (rateLimitedResponse) {
expect(rateLimitedResponse.headers.get('Retry-After')).toBeTruthy();
}
});
test('rate limit resets after window', async () => {
// This test is slow but validates window reset behavior
// Exhaust rate limit
const burst = Array.from({ length: 20 }, () =>
fetch(`${BASE_URL}/api/products`, {
headers: { 'Ocp-Apim-Subscription-Key': process.env.APIM_RATE_TEST_KEY },
})
);
await Promise.all(burst);
// Wait for rate limit window to reset (depends on your policy)
await new Promise(resolve => setTimeout(resolve, 61000)); // 61 seconds
// Should succeed after reset
const response = await fetch(`${BASE_URL}/api/products`, {
headers: { 'Ocp-Apim-Subscription-Key': process.env.APIM_RATE_TEST_KEY },
});
expect(response.status).toBe(200);
}, 90000);
});Testing Request/Response Transformation Policies
APIM policies can transform requests and responses. Test that the transformations work correctly:
describe('Request/response transformation', () => {
test('APIM adds correlation ID header to requests', async () => {
// Set up a backend mock that echoes request headers
const response = await fetch(`${BASE_URL}/api/echo`, { headers });
const body = await response.json();
// APIM policy should inject correlation-id
expect(body.headers['x-correlation-id']).toBeTruthy();
expect(body.headers['x-correlation-id']).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
);
});
test('APIM strips internal headers from upstream response', async () => {
const response = await fetch(`${BASE_URL}/api/orders`, { headers });
// Internal headers added by backend should be stripped
expect(response.headers.get('x-internal-server')).toBeNull();
expect(response.headers.get('x-database-host')).toBeNull();
});
test('APIM transforms response format for v1 API', async () => {
// v1 API should wrap response in { data: ... }
const v1Response = await fetch(`${BASE_URL}/v1/api/orders`, { headers });
const v1Body = await v1Response.json();
expect(v1Body).toHaveProperty('data');
expect(Array.isArray(v1Body.data)).toBe(true);
// v2 API returns array directly
const v2Response = await fetch(`${BASE_URL}/v2/api/orders`, { headers });
const v2Body = await v2Response.json();
expect(Array.isArray(v2Body)).toBe(true);
});
test('APIM caches GET responses', async () => {
// First request
const first = await fetch(`${BASE_URL}/api/products`, { headers });
const firstDate = first.headers.get('date');
// Second request immediately after
const second = await fetch(`${BASE_URL}/api/products`, { headers });
// Cached responses have the same Date header (or Age header)
const age = second.headers.get('age');
if (age) {
expect(parseInt(age)).toBeGreaterThanOrEqual(0);
} else {
// Some implementations return same date for cached response
expect(second.headers.get('date')).toBe(firstDate);
}
});
});Testing Backend Circuit Breaker
APIM can implement circuit breaker patterns in policies:
describe('Backend resilience', () => {
test('APIM returns 503 when backend is unavailable', async () => {
// This test assumes your APIM is configured with a backend that is down
// In practice, point to a test backend URL that returns 503
const response = await fetch(`${BASE_URL}/api/unavailable`, { headers });
// APIM should return 503 or configured error response
expect([503, 504]).toContain(response.status);
});
test('APIM retry policy retries on 500', async () => {
// Backend mock returns 500 first two times, then 200
// APIM retry policy should retry automatically
let callCount = 0;
// Use a mock server (e.g., nock or msw) if testing against self-hosted gateway
const response = await fetch(`${BASE_URL}/api/flaky`, { headers });
// Should eventually succeed after retries
expect(response.status).toBe(200);
});
});Validating Policy XML
Test policy XML for syntax errors and common mistakes before applying to APIM:
// policy-lint.test.js
const { parseStringPromise } = require('xml2js');
const fs = require('fs');
const path = require('path');
const POLICY_DIR = path.join(__dirname, '../apim/policies');
async function parsePolicyXML(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
return parseStringPromise(content, { explicitArray: false });
}
describe('Policy XML validation', () => {
const policyFiles = fs.readdirSync(POLICY_DIR)
.filter(f => f.endsWith('.xml'));
for (const policyFile of policyFiles) {
const filePath = path.join(POLICY_DIR, policyFile);
test(`${policyFile} is valid XML`, async () => {
await expect(parsePolicyXML(filePath)).resolves.toBeTruthy();
});
test(`${policyFile} has required policy sections`, async () => {
const policy = await parsePolicyXML(filePath);
expect(policy.policies).toBeTruthy();
expect(policy.policies.inbound).toBeTruthy();
expect(policy.policies.backend).toBeTruthy();
expect(policy.policies.outbound).toBeTruthy();
});
test(`${policyFile} does not use placeholder values`, async () => {
const content = fs.readFileSync(filePath, 'utf8');
// Check for common placeholder patterns
expect(content).not.toContain('{{YOUR_VALUE}}');
expect(content).not.toContain('REPLACE_ME');
expect(content).not.toContain('TODO:');
});
test(`${policyFile} rate limit has numeric values`, async () => {
const content = fs.readFileSync(filePath, 'utf8');
const rateLimitMatch = content.match(/rate-limit[^>]*calls="(\d+)"/);
if (rateLimitMatch) {
const calls = parseInt(rateLimitMatch[1]);
expect(calls).toBeGreaterThan(0);
expect(calls).toBeLessThan(100000);
}
});
}
});CI Integration
# .github/workflows/apim-tests.yml
name: APIM Policy Tests
on: [push, pull_request]
jobs:
policy-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Validate policy XML
run: npm test -- --testPathPattern="policy-lint"
integration:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # Only on main
needs: policy-lint
environment: apim-test
steps:
- uses: actions/checkout@v4
- name: Azure Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy policies to test APIM
run: |
az apim api policy create \
--resource-group rg-test \
--service-name my-test-apim \
--api-id orders-api \
--xml-file apim/policies/orders-api.xml
- run: npm ci
- name: Run APIM integration tests
run: npm run test:apim
env:
APIM_URL: https://my-test-apim.azure-api.net
APIM_SUBSCRIPTION_KEY: ${{ secrets.APIM_TEST_SUBSCRIPTION_KEY }}
APIM_RATE_TEST_KEY: ${{ secrets.APIM_RATE_TEST_KEY }}Common Policy Testing Mistakes
Testing in production APIM: Policy changes applied to production break all users. Always use a test APIM instance or self-hosted gateway for policy validation.
Not testing edge cases: Rate limiting policies are often tested at the happy path (under the limit) but not at the boundary or above it. Test all three: well under, exactly at, and over the limit.
Assuming policy order: APIM processes inbound policies top-to-bottom, then outbound bottom-to-top. Write tests that verify the final observable behavior, not the internal policy execution order.
Missing error response format tests: When APIM rejects a request (auth failure, rate limit), the error response format matters to your clients. Test that 401 and 429 responses contain the expected JSON structure, not just the status code.