Language-agnostic test harness for validating PostHog SDK compliance.
This test harness validates that PostHog SDKs correctly implement the PostHog API by:
- Running a mock PostHog server
- Exercising your SDK through a simple HTTP adapter
- Verifying behavior matches the contract defined in CONTRACT.yaml
# Run tests against your SDK adapter
docker run --rm \
--network host \
ghcr.io/posthog/sdk-test-harness:latest \
run --adapter-url http://localhost:8080Add to your SDK's .github/workflows/:
jobs:
sdk-compliance:
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@v1
with:
adapter-dockerfile: "tests/adapter/Dockerfile"
adapter-context: "."
sdk-type: "server" # or "client"
suite: "capture" # optional; comma/space/newline separated values are supported
continue-on-error: true # set false when compliance should block CIThe action will run tests, generate reports, and comment on PRs with results.
sdk-type describes the SDK's capture wire format, not where the SDK runs. Do not choose it based on frontend/backend, mobile/server, or browser/native labels alone.
- Use
clientfor SDKs that send client-style capture payloads to/e/, with event data such astokenanddistinct_idcarried in the event/properties payload. - Use
serverfor SDKs that send server-style capture payloads to/batch, with a body shaped like{ "api_key": "...", "batch": [...] }.
For example, a mobile SDK that posts { "api_key": "...", "batch": [...] } to /batch should use sdk-type: "server" for harness filtering.
┌─────────────────────────────────────────────────────────────────┐
│ Test Harness │
│ Reads CONTRACT.yaml and executes tests │
└─────────────────────────────────────────────────────────────────┘
│ │
│ HTTP │ HTTP
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────────────┐
│ Mock PostHog Server │ │ SDK Adapter │
│ Simulates API │◄───│ Wraps your SDK │
│ Records requests │ │ Exposes REST API │
└─────────────────────────┘ └─────────────────────────────────┘
Your adapter is a simple HTTP service that wraps your SDK. It needs these endpoints:
GET /health - Return SDK name/version and capabilities
POST /init - Initialize SDK with config
POST /capture - Capture an event
POST /get_feature_flag - Evaluate a feature flag
POST /flush - Flush pending events
GET /state - Return internal state
POST /reset - Reset SDK state
If your SDK can evaluate flags locally, make /get_feature_flag honor force_remote=true so the harness can verify remote /flags payloads without depending on adapter defaults. distinct_id is always a top-level adapter parameter.
from flask import Flask, request, jsonify
import posthog
app = Flask(__name__)
client = None
@app.route("/init", methods=["POST"])
def init():
global client
data = request.json
client = posthog.Client(
api_key=data["api_key"],
host=data["host"]
)
return jsonify({"success": True})
@app.route("/capture", methods=["POST"])
def capture():
data = request.json
uuid = client.capture(
distinct_id=data["distinct_id"],
event=data["event"]
)
return jsonify({"success": True, "uuid": uuid})
# ... implement other endpointsSee ADAPTER_GUIDE.md for complete implementation details and examples/minimal_adapter/ for a full example.
Tests are defined in CONTRACT.yaml and organized into suites. Which suites run depends on the capabilities your adapter declares in /health:
| Suite | Requires | Protocol |
|---|---|---|
capture |
capture_v0 |
POST /batch |
capture_v1 |
capture_v1 |
POST /i/v1/analytics/events |
capture_ai |
capture_ai_v0 |
POST /i/v0/ai/batch/ |
Some individual tests have additional requirements (e.g., encoding_gzip, encoding_zstd).
Your adapter declares capabilities in its /health response:
{
"sdk_name": "posthog-python",
"sdk_version": "3.0.0",
"adapter_version": "1.0.0",
"capabilities": ["capture_v0", "capture_v1", "capture_ai_v0", "encoding_gzip"]
}The harness skips any suite or test whose requires field isn't satisfied by the adapter's capabilities. If capabilities is omitted, only tests with no requires field will run.
See CONTRACT.yaml for the complete test specification.
# Run all tests
uv run posthog-test-harness run --adapter-url http://localhost:8080
# Run specific suite
uv run posthog-test-harness run --adapter-url http://localhost:8080 --suite capture
# Generate report
uv run posthog-test-harness run --adapter-url http://localhost:8080 --report report.md
# Run tests in parallel (requires adapter support)
uv run posthog-test-harness run --adapter-url http://localhost:8080 --concurrency 4
# JSON output
uv run posthog-test-harness run --adapter-url http://localhost:8080 --output json
# Run mock server standalone
uv run posthog-test-harness mock-server --port 8081
# Check adapter health
uv run posthog-test-harness health --adapter-url http://localhost:8080Tests are defined in CONTRACT.yaml. To add a test:
test_suites:
capture:
categories:
my_new_category:
tests:
- name: my_new_test
steps:
- action: init
- action: capture
params:
distinct_id: user1
event: test_event
- action: assert_event_has_field
params:
field: uuidNo Python code needed! See EXTENDING.md for details on adding custom actions.
# Clone and install
git clone https://github.com/PostHog/posthog-sdk-test-harness.git
cd posthog-sdk-test-harness
bin/install
# Test the example adapter
bin/test
# Format code
bin/fmt
# Run tests
uv run pytestWhen making changes to the test harness, add a Sampo changeset describing the release before opening your PR:
sampo addThis prompts for the bump type and a release note, and writes a file under .sampo/changesets/. Commit that file with your PR.
- patch — bug fixes, documentation, internal refactors
- minor — new tests, new actions (backwards compatible)
- major — breaking changes to
CONTRACT.yamlor the adapter interface
CI will fail (Changeset hygiene check) if you change releasable code without including a changeset.
The actual version bump, changelog entry, tag, and Docker image publish happen in a single gated workflow after a maintainer approves the release in Slack. See RELEASING.md for the full flow.
Docker images are published with semantic versioning:
latest— most recently approved release1— latestv1.x.xrelease (only published once the major is non-zero)1.0— latestv1.0.xrelease1.0.0— specific version
All tags only move when a maintainer approves a release through the gated workflow — there is no automatic publish on every push to main.
Pin to a specific version in your CI for stability:
test-harness-version: "1.0" # Recommended: pin to major.minor- ADAPTER_GUIDE.md - Complete guide to implementing adapters
- EXTENDING.md - How to add new tests and actions
- CONTRACT.yaml - Main contract (references modular contracts)
- Feature Flag Rules v2 - Versioned config, definitions, response and event schemas, fixtures, and evaluation corpus
- contracts/ - Modular contract definitions:
adapter_actions.yaml- Actions that call the adaptertest_actions.yaml- Test harness actions (assertions, etc.)capture_tests.yaml- Capture V0 test suitecapture_analytics_v1_tests.yaml- Capture V1 test suitecapture_ai_tests.yaml- Dedicated AI capture endpoint test suite
- examples/minimal_adapter/ - Working example
MIT - see LICENSE
Adapters may explicitly advertise feature_flags_local_evaluation_v1 to enable
local-rule compliance tests for both property matching versions 1 and 2.
No existing adapter or default health response opts in automatically; remote
fixtures and requests remain unchanged. See the optional adapter protocol
for privileged definitions loading, bounded readiness/reload and local-only
result requirements.