Skip to Content
Integration tests in CI

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
  1. The job starts only once the HEALTHCHECK reports healthy — no wait-for-port loop.
  2. Tags are never latest, so the workflow pins fidelity by construction.
  3. No key is baked into the image; it arrives via RYSTIC_TOKEN at run time.
  4. Several simulators means several services: entries on different host ports.
  5. 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"`)
}
  1. The first two are the real Stripe API — the same assertions run unchanged against test mode.
  2. /_rystic/egress is 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.
  3. One test per named scenario. GET /_rystic/scenarios lists every scenario a simulator ships with a one-line summary. Stripe ships design_studio, Website Design Contractors’ account; GitHub and Kalshi ship several each. POST /_rystic/seed builds anything a scenario lacks.
  4. Determinism knobs on every simulator: POST /_rystic/seed-rng and POST /_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.

Last updated on