Azure API Management Testing: Policies, Throttling, and Gateway Behavior

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:latest

For 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.

Read more

Start now free