# Integration tests in CI

Run your integration tests against a simulator in GitHub Actions — one service container, one secret, your existing test runner.

For an RL agent using Slack MCP, follow [Slack MCP in Harbor](https://www.rystic.ai/docs/slack-harbor.md) for task staging, tool discovery, grading and reset. This guide covers HTTP clients using a [simulator](https://www.rystic.ai/docs/concepts/glossary.md#simulator) as a CI service.

Run the simulator as a [GitHub Actions service container](https://docs.github.com/en/actions/using-containerized-services/about-service-containers). 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](https://www.rystic.ai/account/api-keys), so CI keeps working when a person leaves, then store it:

```bash
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

```yaml
# .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](https://www.rystic.ai/docs/concepts/glossary.md#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](https://www.rystic.ai/docs/concepts/glossary.md#named-scenario), so tests are order-independent:

```go
//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](https://www.rystic.ai/docs/concepts/glossary.md#egress-log):

```go
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](https://www.rystic.ai/docs/concepts/glossary.md#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](https://www.rystic.ai/docs/control-plane.md#routes-by-plan) 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](https://www.rystic.ai/docs/docker.md#generate-the-compose-file).

---

Source: https://www.rystic.ai/docs/integration-testing · Markdown: https://www.rystic.ai/docs/integration-testing.md
