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.shProject 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 configUnit 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)
endRunning 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 -- -vRate 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_consumerAuth 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 syncCI 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 1Plugin 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)