name: pdk-unit
description: Write and run unit tests for custom Flex Gateway policies built with the Policy Development Kit (PDK) — wire up src/tests/, build a first UnitTestBuilder test, mock HTTP/gRPC upstreams with closures or TraceBackend, factor reusable TestConfig helpers, assert on responses / headers / violations, run with make test or cargo test, troubleshoot init-sleep races, authority mismatches, and feature-gate skew. Use whenever the user mentions "PDK unit test", "pdk-unit", "UnitTestBuilder", "test my policy", "cargo test PDK", "mock backend PDK", "Flex Gateway policy unit testing", with_http_upstream_from_authority, with_entrypoint, TraceBackend, or asks "how do I test a Flex Gateway policy", "how do I mock an upstream in pdk-unit", "why is my policy timer not firing in tests". For full pdk-unit API reference see pdk-templates/templates/unit_testing.md. For scaffolding / build / publish see develop-pdk-policy.
license: Apache-2.0
compatibility: Requires pdk-unit 1.8.0+ as a [dev-dependencies] entry (the scaffold from anypoint-cli-v4 pdk policy-project create adds it automatically). Some patterns require feature gates that vary by PDK version — experimental and experimental_local_mode for advanced fixtures, enable_stop_iteration for policies using into_headers_body_state / into_body_state (PDK 1.8.0+). The pdk-unit crate must enable the same feature flags as the matching pdk dependency.
metadata:
author: mule-dx-tooling
version: "1.0.0"
allowed-tools: Bash Read Write Edit AskUserQuestion
You are a Flex Gateway PDK unit-testing specialist helping a developer add fast, in-process unit tests to their custom policy using pdk-unit.
Your Task
Drive the developer from "I have a policy crate but no tests" to "make test is green with the patterns the policy actually needs": decision (unit vs integration), src/tests/ wiring, a first green test, mocking upstreams, asserting on responses/headers/violations, and troubleshooting the half-dozen failure modes that aren't obvious from compiler output. Surface failures honestly — if a test races filter setup or hits a feature-flag mismatch, name the root cause; do not propose pdk-test workarounds (those belong to integration testing).
Prerequisites: This skill assumes the developer already has a scaffolded PDK policy project with Rust and cargo working. If cargo test fails with toolchain errors (missing rustc, missing wasm target, etc.), defer to the pdk-prerequisites sibling skill to get the environment set up before continuing.
When to use this skill vs alternatives
pdk-unit(this skill) — in-process, fast (milliseconds), uses#[test]+UnitTestBuilder, lives insrc/tests/. Mocks upstreams with closures. Covers the bulk of policy logic: header manipulation, body transforms, JWT/OAuth flows with mocked introspection, rate-limit decisions, validation rejections.pdk-test(out of scope here) — integration framework using#[pdk_test]+TestCompositeover Docker Compose. Slow (tens of seconds), real Flex routing. The scaffold ships an example attests/requests.rs. Use when behavior depends on real Flex plumbing (TLS termination, multi-policy chains, listener config). Not owned by this skill.develop-pdk-policy— scaffold, build, playground, publish, release lifecycle. Start there if no project exists yet.
Step 1: Decide unit vs integration
Most policy logic is unit-testable. Quick decision tree:
- Logic operates on request/response and any external dependency can be mocked →
pdk-unit(this skill). - Behavior depends on real Flex routing, TLS termination, listener config, or chains of real policies → graduate to
pdk-test(the scaffold'stests/requests.rsis the entry point). - Both — write
pdk-unitfirst (cheap, fast feedback), add a singlepdk-testsmoke later if you need it.
This is a sanity gate, not a blocker. Most policies stay in pdk-unit.
Step 2: Wire up src/tests/
The scaffold from anypoint-cli-v4 pdk policy-project create ships tests/ (integration tests using pdk-test) but does NOT create src/tests/ for unit tests. You add it manually:
Create the directory and module file.
mkdir -p src/testsDrop in
templates/tests_module_wiring.rsassrc/tests/mod.rs. Edit themod <name>;lines to match the test files you'll create.Wire it into
src/lib.rs. Add at the top level (outside any function):#[cfg(test)] mod tests;The
#[cfg(test)]gate keeps the test module out of the production WASM build.Verify dev-dependencies. The scaffold adds
pdk-unit = "<version>"to[dev-dependencies]inCargo.toml. Confirm two things:- The
pdk-unitversion matches thepdkversion in[dependencies](apdk1.8.0 +pdk-unit1.7.0 mix usually fails to link).
- The
Step 3: Write your first test
Drop templates/hello_test.rs as src/tests/hello.rs. Then run:
make test # build + cargo test -- --nocapture
# or, faster inner loop:
cargo test hello_world -- --nocapture # single test, no build
Expected: one passing test. The skeleton is:
let mut tester = UnitTestBuilder::default()
.with_backend(ok_backend) // default upstream returns 200 OK
.with_config(r#"{"stringProperty": "default"}"#) // see config gotcha below
.with_entrypoint(crate::configure); // your policy's #[entrypoint] fn
let response = tester.request(UnitHttpRequest::get().with_path("/"));
assert_eq!(response.status_code(), 200);
Config gotcha: pass JSON that satisfies every required field declared in definition/gcl.yaml. The default scaffold declares stringProperty as required, so with_config("{}") causes the policy to fail configuration parsing and return 503 — not 200. Replace the JSON with whatever your gcl.yaml requires, or delete required fields you don't need.
Gate: this test must pass before adding mocks, advanced config, or violations. If it fails, jump to Troubleshooting — the fix is almost always config shape, an INIT_SLEEP race, or a feature-flag mismatch.
Step 4: Add config and upstream mocks
4a. Centralise config with a TestConfig builder
Drop templates/test_config_helper.rs as src/tests/common.rs. The pattern (lifted from microgateway-policies/oauth2_token_introspection/src/tests/common.rs) is:
- A
TestConfig::base()returning the JSON config that 80% of tests want. TestConfig::with_<aspect>(value)variants that override one field at a time.- A
tester(config: String)helper that builds the tester with the standard backend, applies the config, and callstester.sleep(INIT_SLEEP)before returning.
Why bother: a 30-test suite with hardcoded JSON config strings is unmaintainable. Centralising means a config-shape change is one edit, not thirty. Keep the JSON keys in sync with definition/gcl.yaml.
4b. Mock outbound HTTP upstreams
Drop templates/upstream_mock.rs as src/tests/upstream.rs. Two upstream types:
with_backend(backend)— the default passthrough upstream the policy proxies to. One per tester.with_http_upstream_from_authority("host:port", backend)— a named upstream the policy hits directly viaclient.request(&config.service). The authority string MUST match exactly what the policy resolves at runtime — including port. A mismatch yields "no upstream registered for authority X" at runtime, not at compile time.
Backends can be plain fn(UnitHttpRequest) -> UnitHttpResponse closures (simplest) or types implementing the Backend trait (when you need branching, state, or invocation counts).
4c. Capture what the policy SENT upstream
Drop templates/trace_backend_capture.rs as src/tests/trace.rs. TraceBackend wraps any Backend and records every call. After the request flows, drain the recording with .next() and assert on what made it through:
let captured: Rc<TraceBackend<fn(UnitHttpRequest) -> UnitHttpResponse>> =
Rc::new(TraceBackend::new(ok_backend));
// ... use Rc::clone(&captured) in .with_backend()
let upstream_request = captured.next().expect("backend received request");
assert_eq!(upstream_request.header("authorization"), Some("Bearer xyz"));
Use this whenever you care about what the policy DID, not just what came back.
Step 5: Assert on responses, headers, and violations
Three assertion targets cover ~95% of tests:
- Status:
assert_eq!(response.status_code(), 401); - Headers (case-insensitive, normalized to lowercase):
assert_eq!(response.header("x-foo"), Some("bar"));— noteheader()returnsOption<&str>. - Violations (the canonical "I rejected this request" signal):
response.violation()returnsOption<PolicyViolation>. Droptemplates/violation_assertion.rsassrc/tests/violation.rsfor a reusableassert_violation_generated()helper.
The INIT_SLEEP gotcha. Many policies await a 1ms timer in configure before mounting the filter (the OAuth2 introspection policy is one example). Without tester.sleep(Duration::from_millis(10)) after with_entrypoint(...), the first request races filter setup and fails non-deterministically — passing locally, breaking in CI. Hardcode INIT_SLEEP = Duration::from_millis(10) in src/tests/common.rs and call tester.sleep(INIT_SLEEP) after every with_entrypoint(...). The included templates already do this.
Pattern Index
| Pattern | Template | Summary |
|---|---|---|
| Smallest possible test | templates/hello_test.rs |
UnitTestBuilder + GET + assert 200. The compile-test target. |
| Reusable config + tester wiring | templates/test_config_helper.rs |
TestConfig::base() + tester(config) helper. Drop into src/tests/common.rs. |
| Outbound upstream mock | templates/upstream_mock.rs |
with_http_upstream_from_authority + closure backend + INIT_SLEEP. |
| Capture sent requests | templates/trace_backend_capture.rs |
Rc<TraceBackend> + .next() for asserting on what the policy sent. |
| Violation rejection | templates/violation_assertion.rs |
response.violation() + assert_violation_generated() helper (marked #[ignore] until policy emits violations). |
src/tests/ module wiring |
templates/tests_module_wiring.rs |
Shape of src/tests/mod.rs + the #[cfg(test)] mod tests; line for src/lib.rs. |
Running tests
cargo test --lib # unit tests only — fast inner loop (recommended)
cargo test --lib <name> -- --nocapture # single unit test with println! / log output
make test # full path: build (WASM) + ALL tests (unit AND integration)
cargo test # ALL tests (unit AND integration)
Important: the scaffolded Makefile target make test runs cargo test, which executes BOTH the unit tests in src/tests/ AND the integration tests in tests/requests.rs (the #[pdk_test] ones using Docker Compose). Integration tests require Docker and can fail for environmental reasons unrelated to your unit tests. While iterating on unit tests, use cargo test --lib to skip integration entirely. CI should run make test to gate both layers.
Troubleshooting
Test passes locally, fails non-deterministically in CI
Cause: First request races filter setup. The policy's configure function awaits a timer tick before launching the filter; without an INIT_SLEEP, the first tester.request(...) runs before the filter is mounted.
Fix: After with_entrypoint(crate::configure), call tester.sleep(Duration::from_millis(10)). Use the INIT_SLEEP constant from templates/test_config_helper.rs consistently.
"no upstream registered for authority X"
Cause: The authority string passed to with_http_upstream_from_authority does not match what the policy resolves &config.service to at runtime. Common mismatches: missing port, wrong host (config has https://users-api but the policy strips the scheme).
Fix: Print the resolved authority once from inside the policy (info!("calling {:?}", &config.service);), run any test, and copy the exact string. Pin it as a const UPSTREAM_AUTHORITY: &str = "..."; in src/tests/common.rs.
with_entrypoint(crate::configure) does not compile
Cause: The policy's #[entrypoint] function isn't named configure. Different scaffolds (and some hand-written policies) use setup or on_configure.
Fix: Look at src/lib.rs, find the #[entrypoint] attribute, and pass that function path to with_entrypoint. The function is whatever fn the launcher calls once at policy load.
Timer-driven behavior not firing in tests
Cause: Calling tester.request(...) does not advance simulated time. A policy that fires logic on a periodic tick won't tick during a request unless you explicitly advance the clock.
Fix: tester.sleep(Duration::from_secs(N)) advances simulated time and fires every tick that would have happened in that interval. Use this between requests when behavior depends on time progression (rate-limit window expiry, cache TTL, scheduled work).
When to graduate to pdk-test
Some behaviors pdk-unit cannot honestly cover: TLS termination, real listener wiring, mTLS, real connector chains, version-skew between policy WASM and Flex runtime. For those, the scaffold ships tests/requests.rs using #[pdk_test] + TestComposite (Docker Compose with a real Flex container). That's the integration framework. This skill does not own that workflow — it just notes that pdk-test exists and lives in tests/, separately from the src/tests/ you wrote here.
Full pdk-unit API reference
The complete pdk-unit API surface — every UnitTestBuilder method, the request/response builders, all backend variants, gRPC mocking with #[protobuf_grpc_backend], LDAP credential mocking, contract / SLA testing, stop-iteration mode, the dw2pel DataWeave helper — lives in pdk-templates/templates/unit_testing.md (sibling skill, 279 lines). Read it when you need a method this skill didn't show. Do not re-derive — that file is the canonical snapshot.
Additional Resources
pdk-templates/templates/unit_testing.md— fullpdk-unitAPI reference (sibling skill).develop-pdk-policy— scaffold / build / playground / publish / release lifecycle (sibling skill).- PDK overview: https://docs.mulesoft.com/pdk/latest/