Kong API Gateway Testing Guide: kong-pongo, Rate Limiting & Plugin Tests

Kong API Gateway Testing Guide: kong-pongo, Rate Limiting & Plugin Tests

Kong API Gateway sits between your clients and your services. It handles authentication, rate limiting, routing, transformations, and more — all through plugins. Testing Kong configurations catches issues before they reach production: misconfigured rate limits, broken auth chains, routing errors, and plugin interactions that work in isolation but conflict when combined.

Kong Testing Architecture

Kong tests operate at three levels:

Plugin unit tests — testing a single plugin's logic in isolation, using Kong's test helpers. Fast, runs without a running Kong instance for some scenarios.

Integration tests — spinning up Kong with a test configuration and sending real HTTP requests through it. Catches plugin interactions, routing issues, and configuration errors.

Deck configuration tests — validating that deck sync applies your declarative config correctly and that the resulting state matches expectations.

kong-pongo

kong-pongo is Kong's official test runner for plugin development. It spins up Kong + its dependencies in Docker and runs your tests against a real Kong instance.

Installation

git clone https://github.com/Kong/kong-pongo.git
<span class="hljs-built_in">export PATH=<span class="hljs-variable">$PATH:/path/to/kong-pongo

<span class="hljs-comment"># Or use the shell script
curl https://raw.githubusercontent.com/Kong/kong-pongo/master/pongo.sh -o pongo.sh
<span class="hljs-built_in">chmod +x pongo.sh

Project Structure

my-kong-plugin/
├── kong/
│   └── plugins/
│       └── my-plugin/
│           ├── handler.lua
│           ├── schema.lua
│           └── ...
├── spec/
│   ├── 01-unit_spec.lua
│   └── 02-integration_spec.lua
└── .pongo/
    └── kong.yml   # test Kong config

Unit Testing a Plugin

-- spec/01-unit_spec.lua
local PLUGIN_NAME = "my-plugin"

describe(PLUGIN_NAME .. ": (unit)", function()
  local handler

  setup(function()
    handler = require("kong.plugins." .. PLUGIN_NAME .. ".handler")
  end)

  describe("rate limit calculation", function()
    it("allows requests within limit", function()
      local config = {
        minute = 60,
        hour = 1000
      }
      -- Test your plugin's internal logic
      local should_allow = handler.check_rate_limit(config, {minute = 30, hour = 500})
      assert.is_true(should_allow)
    end)

    it("blocks requests exceeding limit", function()
      local config = { minute = 60 }
      local should_allow = handler.check_rate_limit(config, {minute = 61})
      assert.is_false(should_allow)
    end)
  end)
end)

Integration Testing

-- spec/02-integration_spec.lua
local PLUGIN_NAME = "my-plugin"
local helpers = require "spec.helpers"

for _, strategy in helpers.each_strategy() do
  describe(PLUGIN_NAME .. ": (integration) [#" .. strategy .. "]", function()
    local proxy_client
    local admin_client

    lazy_setup(function()
      local bp = helpers.get_db_utils(strategy, {
        "routes",
        "services",
        "plugins",
      })

      -- Create test service
      local service = bp.services:insert {
        name = "test-service",
        url = "http://httpbin.org"
      }

      -- Create route
      local route = bp.routes:insert {
        hosts = { "test.example.com" },
        service = { id = service.id }
      }

      -- Enable plugin
      bp.plugins:insert {
        name = PLUGIN_NAME,
        route = { id = route.id },
        config = {
          minute = 5,
          policy = "local"
        }
      }

      -- Start Kong
      assert(helpers.start_kong {
        database = strategy,
        plugins = "bundled," .. PLUGIN_NAME,
      })
    end)

    lazy_teardown(function()
      helpers.stop_kong()
    end)

    before_each(function()
      proxy_client = helpers.proxy_client()
    end)

    after_each(function()
      proxy_client:close()
    end)

    it("allows requests within rate limit", function()
      local res = proxy_client:send {
        method = "GET",
        path = "/get",
        headers = { host = "test.example.com" }
      }
      assert.res_status(200, res)
      assert.equal("5", res.headers["X-RateLimit-Limit-Minute"])
    end)

    it("blocks requests exceeding rate limit", function()
      -- Exhaust the limit
      for i = 1, 5 do
        local res = proxy_client:send {
          method = "GET",
          path = "/get",
          headers = { host = "test.example.com" }
        }
        assert.res_status(200, res)
      end

      -- Next request should be blocked
      local res = proxy_client:send {
        method = "GET",
        path = "/get",
        headers = { host = "test.example.com" }
      }
      assert.res_status(429, res)
      assert.equal("Rate limit exceeded", res.headers["X-Kong-Limit-Reason"])
    end)
  end)
end

Running Tests

# Run all tests
pongo run

<span class="hljs-comment"># Run specific test file
pongo run spec/02-integration_spec.lua

<span class="hljs-comment"># Run against specific Kong version
KONG_VERSION=3.6.0 pongo run

<span class="hljs-comment"># Run with environment variables
pongo run -- -v

Rate Limiting Tests

Rate limiting is the most commonly misconfigured Kong plugin. Test all dimensions.

Rate Limiting Test Suite

#!/bin/bash
<span class="hljs-comment"># test-rate-limiting.sh

PROXY_URL=<span class="hljs-string">"http://kong-staging:8000"
ADMIN_URL=<span class="hljs-string">"http://kong-staging:8001"
HOST=<span class="hljs-string">"api.example.com"

<span class="hljs-comment"># Setup: create service, route, rate-limit plugin via Admin API
<span class="hljs-function">setup() {
  <span class="hljs-comment"># Create service
  curl -s -X POST <span class="hljs-string">"$ADMIN_URL/services" \
    -d <span class="hljs-string">"name=rate-test&url=http://httpbin.org"

  <span class="hljs-comment"># Create route  
  curl -s -X POST <span class="hljs-string">"$ADMIN_URL/services/rate-test/routes" \
    -d <span class="hljs-string">"hosts[]=$HOST"

  <span class="hljs-comment"># Add rate limiting plugin: 5 req/minute
  curl -s -X POST <span class="hljs-string">"$ADMIN_URL/services/rate-test/plugins" \
    -d <span class="hljs-string">"name=rate-limiting&config.minute=5&config.policy=local"
}

<span class="hljs-comment"># Test 1: Within limit — all should pass
<span class="hljs-function">test_within_limit() {
  <span class="hljs-built_in">echo <span class="hljs-string">"Test: within rate limit"
  <span class="hljs-built_in">local pass=0
  <span class="hljs-keyword">for i <span class="hljs-keyword">in {1..5}; <span class="hljs-keyword">do
    STATUS=$(curl -s -o /dev/null -w <span class="hljs-string">"%{http_code}" \
      -H <span class="hljs-string">"Host: $HOST" <span class="hljs-string">"$PROXY_URL/get")
    [ <span class="hljs-string">"$STATUS" = <span class="hljs-string">"200" ] && ((pass++))
  <span class="hljs-keyword">done
  <span class="hljs-built_in">echo <span class="hljs-string">"  $pass/5 requests passed (expected 5)"
  [ <span class="hljs-string">"$pass" -eq 5 ] && <span class="hljs-built_in">echo <span class="hljs-string">"  PASS" <span class="hljs-pipe">|| <span class="hljs-built_in">echo <span class="hljs-string">"  FAIL"
}

<span class="hljs-comment"># Test 2: Exceeding limit — 6th should be 429
<span class="hljs-function">test_exceeds_limit() {
  <span class="hljs-built_in">echo <span class="hljs-string">"Test: exceeds rate limit"
  <span class="hljs-comment"># Wait for rate limit window to reset
  <span class="hljs-built_in">sleep 61

  <span class="hljs-built_in">local blocked=<span class="hljs-literal">false
  <span class="hljs-keyword">for i <span class="hljs-keyword">in {1..6}; <span class="hljs-keyword">do
    STATUS=$(curl -s -o /dev/null -w <span class="hljs-string">"%{http_code}" \
      -H <span class="hljs-string">"Host: $HOST" <span class="hljs-string">"$PROXY_URL/get")
    [ <span class="hljs-string">"$STATUS" = <span class="hljs-string">"429" ] && blocked=<span class="hljs-literal">true && <span class="hljs-built_in">break
  <span class="hljs-keyword">done
  <span class="hljs-variable">$blocked && <span class="hljs-built_in">echo <span class="hljs-string">"  PASS: 6th request blocked" <span class="hljs-pipe">|| <span class="hljs-built_in">echo <span class="hljs-string">"  FAIL: rate limit not enforced"
}

<span class="hljs-comment"># Test 3: Per-consumer rate limiting
<span class="hljs-function">test_per_consumer() {
  <span class="hljs-built_in">echo <span class="hljs-string">"Test: per-consumer rate limiting"
  
  <span class="hljs-comment"># Create two consumers
  curl -s -X POST <span class="hljs-string">"$ADMIN_URL/consumers" -d <span class="hljs-string">"username=consumer-a"
  curl -s -X POST <span class="hljs-string">"$ADMIN_URL/consumers" -d <span class="hljs-string">"username=consumer-b"
  
  <span class="hljs-comment"># Create API keys
  KEY_A=$(curl -s -X POST <span class="hljs-string">"$ADMIN_URL/consumers/consumer-a/key-auth" <span class="hljs-pipe">| jq -r <span class="hljs-string">'.key')
  KEY_B=$(curl -s -X POST <span class="hljs-string">"$ADMIN_URL/consumers/consumer-b/key-auth" <span class="hljs-pipe">| jq -r <span class="hljs-string">'.key')
  
  <span class="hljs-comment"># Use consumer-a to exhaustion
  <span class="hljs-keyword">for i <span class="hljs-keyword">in {1..5}; <span class="hljs-keyword">do
    curl -s -o /dev/null <span class="hljs-string">"$PROXY_URL/get" \
      -H <span class="hljs-string">"Host: $HOST" -H <span class="hljs-string">"apikey: $KEY_A"
  <span class="hljs-keyword">done
  
  <span class="hljs-comment"># Consumer-a should be blocked
  STATUS_A=$(curl -s -o /dev/null -w <span class="hljs-string">"%{http_code}" \
    <span class="hljs-string">"$PROXY_URL/get" -H <span class="hljs-string">"Host: $HOST" -H <span class="hljs-string">"apikey: $KEY_A")
  
  <span class="hljs-comment"># Consumer-b should still work (separate counter)
  STATUS_B=$(curl -s -o /dev/null -w <span class="hljs-string">"%{http_code}" \
    <span class="hljs-string">"$PROXY_URL/get" -H <span class="hljs-string">"Host: $HOST" -H <span class="hljs-string">"apikey: $KEY_B")
  
  [ <span class="hljs-string">"$STATUS_A" = <span class="hljs-string">"429" ] && [ <span class="hljs-string">"$STATUS_B" = <span class="hljs-string">"200" ] && \
    <span class="hljs-built_in">echo <span class="hljs-string">"  PASS: consumers have independent rate limits" <span class="hljs-pipe">|| \
    <span class="hljs-built_in">echo <span class="hljs-string">"  FAIL: A=$STATUS_A B=<span class="hljs-variable">$STATUS_B (expected 429/200)"
}

setup
test_within_limit
test_exceeds_limit
test_per_consumer

Auth Plugin Testing

Key Authentication

-- spec/key-auth_spec.lua
describe("key-auth plugin", function()
  local proxy_client

  lazy_setup(function()
    local bp = helpers.get_db_utils()

    local service = bp.services:insert { url = "http://httpbin.org" }
    local route = bp.routes:insert {
      hosts = { "auth.test.com" },
      service = { id = service.id }
    }

    -- Consumer and API key
    local consumer = bp.consumers:insert { username = "test-user" }
    bp.keyauth_credentials:insert {
      key = "test-api-key-12345",
      consumer = { id = consumer.id }
    }

    -- Enable key-auth plugin
    bp.plugins:insert {
      name = "key-auth",
      route = { id = route.id },
      config = { key_names = {"apikey", "X-Api-Key"} }
    }

    helpers.start_kong()
    proxy_client = helpers.proxy_client()
  end)

  it("blocks requests without API key", function()
    local res = proxy_client:send {
      method = "GET",
      path = "/get",
      headers = { host = "auth.test.com" }
    }
    assert.res_status(401, res)
    assert.equal("No API key found in request", res.body)
  end)

  it("blocks requests with invalid API key", function()
    local res = proxy_client:send {
      method = "GET",
      path = "/get",
      headers = {
        host = "auth.test.com",
        apikey = "wrong-key"
      }
    }
    assert.res_status(401, res)
  end)

  it("allows requests with valid API key in header", function()
    local res = proxy_client:send {
      method = "GET",
      path = "/get",
      headers = {
        host = "auth.test.com",
        apikey = "test-api-key-12345"
      }
    }
    assert.res_status(200, res)
  end)

  it("allows requests with API key in query string", function()
    local res = proxy_client:send {
      method = "GET",
      path = "/get?apikey=test-api-key-12345",
      headers = { host = "auth.test.com" }
    }
    assert.res_status(200, res)
  end)

  it("sets consumer headers on authenticated requests", function()
    local res = proxy_client:send {
      method = "GET",
      path = "/get",
      headers = {
        host = "auth.test.com",
        apikey = "test-api-key-12345"
      }
    }
    local body = cjson.decode(assert.res_status(200, res))
    assert.equal("test-user", body.headers["X-Consumer-Username"])
  end)
end)

JWT Authentication

it("verifies JWT signature", function()
  -- Generate valid JWT
  local jwt = require "resty.jwt"
  local token = jwt:sign(
    "my-secret-key",
    {
      header = { typ = "JWT", alg = "HS256" },
      payload = { sub = "test-user", iss = "my-app" }
    }
  )

  local res = proxy_client:send {
    method = "GET",
    path = "/protected",
    headers = {
      host = "jwt.test.com",
      authorization = "Bearer " .. token
    }
  }
  assert.res_status(200, res)
end)

it("rejects tampered JWT", function()
  local tampered = "eyJhbGciOiJIUzI1NiJ9.TAMPERED.bad_signature"
  local res = proxy_client:send {
    method = "GET",
    path = "/protected",
    headers = {
      host = "jwt.test.com",
      authorization = "Bearer " .. tampered
    }
  }
  assert.res_status(401, res)
end)

Deck Configuration Testing

deck manages Kong's configuration declaratively. Test that your deck config applies correctly.

# Validate config syntax
deck validate --state kong.yaml

<span class="hljs-comment"># Dry run — shows what would change
deck <span class="hljs-built_in">sync --state kong.yaml --dry-run

<span class="hljs-comment"># Diff against running Kong
deck diff --state kong.yaml

<span class="hljs-comment"># Apply and verify
deck <span class="hljs-built_in">sync --state kong.yaml
deck diff --state kong.yaml  <span class="hljs-comment"># Should show no changes after sync

CI Deck Validation

# .github/workflows/kong-config.yml
name: Kong Configuration Tests

on:
  pull_request:
    paths:
      - 'kong/**'

jobs:
  validate-config:
    runs-on: ubuntu-latest

    services:
      kong-db:
        image: postgres:16
        env:
          POSTGRES_DB: kong
          POSTGRES_USER: kong
          POSTGRES_PASSWORD: kong
      
      kong:
        image: kong:3.6
        env:
          KONG_DATABASE: postgres
          KONG_PG_HOST: kong-db
          KONG_PG_DATABASE: kong
          KONG_PG_USER: kong
          KONG_PG_PASSWORD: kong
          KONG_ADMIN_LISTEN: "0.0.0.0:8001"
        ports:
          - 8000:8000
          - 8001:8001

    steps:
      - uses: actions/checkout@v4

      - name: Install deck
        run: |
          curl -sL https://github.com/Kong/deck/releases/download/v1.36.2/deck_1.36.2_linux_amd64.tar.gz | tar xz
          sudo mv deck /usr/local/bin/

      - name: Wait for Kong
        run: |
          timeout 60 bash -c 'until curl -f http://localhost:8001/status; do sleep 2; done'

      - name: Validate configuration
        run: deck validate --state kong/kong.yaml

      - name: Sync configuration
        run: deck sync --state kong/kong.yaml --kong-addr http://localhost:8001

      - name: Verify no drift
        run: |
          DIFF=$(deck diff --state kong/kong.yaml --kong-addr http://localhost:8001)
          if [ -n "$DIFF" ]; then
            echo "Configuration drift detected after sync:"
            echo "$DIFF"
            exit 1
          fi
          echo "Configuration matches — no drift"

      - name: Run integration tests
        run: |
          # Test key routes are responding correctly
          curl -f http://localhost:8000/health || exit 1

Plugin Interaction Testing

Test plugins in combination — conflicts are common:

describe("rate-limiting + key-auth interaction", function()
  it("rate limits are per-consumer not per-IP when auth is active", function()
    -- Consumer A: 5 requests (exhausts limit)
    for i = 1, 5 do
      proxy_client:send({
        method = "GET", path = "/api",
        headers = { host = "test.com", apikey = "consumer-a-key" }
      })
    end

    -- Consumer A blocked
    local res_a = proxy_client:send({
      method = "GET", path = "/api",
      headers = { host = "test.com", apikey = "consumer-a-key" }
    })
    assert.res_status(429, res_a)

    -- Consumer B still works (different consumer, different limit counter)
    local res_b = proxy_client:send({
      method = "GET", path = "/api",
      headers = { host = "test.com", apikey = "consumer-b-key" }
    })
    assert.res_status(200, res_b)
  end)
end)

Read more