Guides
Integration tests in CI
For an RL agent using Slack MCP, follow Slack MCP in Harbor for task staging, tool discovery, grading and reset. This guide covers HTTP clients using a simulator as a CI service.
Run the simulator as a GitHub Actions service container . The runner pulls the image, waits on its built-in HEALTHCHECK, and exposes the port to your test job. No Compose file, no CLI in CI. The example is the Stripe simulator with go test; any simulator image and any test runner take the same shape.
One secret
Create a Team (for CI) key at Account → API keys, so CI keeps working when a person leaves, then store it:
gh secret set RYSTIC_TOKEN # paste the rys_… key at the prompt
The registry takes the key as the password under the user token; the simulator reads the same value and checks in with Rystic while the job runs.
The workflow
# .github/workflows/integration.yml
jobs:
integration:
runs-on: ubuntu-latest
services:
stripe-twin:
image: registry.rystic.ai/stripe-twin:0.0.18 # or pin by digest for repro
credentials:
username: token
password: ${{ secrets.RYSTIC_TOKEN }}
env:
RYSTIC_TOKEN: ${{ secrets.RYSTIC_TOKEN }}
ports: ["8080:8080"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- run: go test -tags integration ./...
env:
STRIPE_API_BASE: http://localhost:8080
TWIN_URL: http://localhost:8080
- The job starts only once the
HEALTHCHECKreports healthy — no wait-for-port loop. - Tags are never
latest, so the workflow pins fidelity by construction. - No key is baked into the image; it arrives via
RYSTIC_TOKENat run time. - Several simulators means several
services:entries on different host ports. - The tests below run on every plan. A Free key pulls every simulator but Anthropic, Dropbox, S3 and Shopify Storefront, and fault injection (
/_rystic/network) is Pro and Enterprise.
Enterprise, on a license file: gh secret set RYSTIC_LICENSE --body "$(base64 < rystic-license.json | tr -d '\n')", then username: license, password: ${{ secrets.RYSTIC_LICENSE }} and RYSTIC_LICENSE: ${{ secrets.RYSTIC_LICENSE }} in place of the token lines.
The tests
One helper does the work — POST /_rystic/scenario wipes state then applies the named scenario, so tests are order-independent:
//go:build integration
var base = cmp.Or(os.Getenv("TWIN_URL"), "http://localhost:8080")
func scenario(t *testing.T, name string) {
t.Helper()
http.Post(base+"/_rystic/scenario", "application/json",
strings.NewReader(`{"name":"`+name+`"}`))
}
func TestCheckoutAgainstTheStudio(t *testing.T) {
scenario(t, "design_studio")
runCheckout(t, 30*time.Second)
}
Check a refund went through, on the same surfaces the real API has plus the simulator’s egress log:
func TestRefundMarksCharge(t *testing.T) {
scenario(t, "design_studio")
// Pickford Law's $12,000.00 deposit on invoice WDC-1189
resp := post(t, base+"/v1/refunds", "charge=ch_MUlzZjUU01Wx5Q&amount=150000")
want(t, resp, `"amount":150000`)
want(t, get(t, base+"/v1/charges/ch_MUlzZjUU01Wx5Q"), `"amount_refunded":150000`)
want(t, get(t, base+"/_rystic/egress?tail=10"), `"type":"charge.refunded"`)
}
- The first two are the real Stripe API — the same assertions run unchanged against test mode.
/_rystic/egressis simulator-only: it holds the webhook events your requests would have sent, so a test can assert the event was emitted without standing up a receiver.- One test per named scenario.
GET /_rystic/scenarioslists every scenario a simulator ships with a one-line summary. Stripe shipsdesign_studio, Website Design Contractors’ account; GitHub and Kalshi ship several each.POST /_rystic/seedbuilds anything a scenario lacks. - Determinism knobs on every simulator:
POST /_rystic/seed-rngandPOST /_rystic/clock. Control plane and plans lists every route.
If your app is an image
Use the Compose route: rystic export compose locally, commit deploy/docker-compose.yaml, docker compose up in the job with the same secret. See Docker.