Run your tests in GitHub Actions

A GitHub Actions workflow that boots Fidelic, waits until your Apex has actually loaded, runs your integration tests and tears down. Executed as a real workflow run before publication.

Your integration tests run on every push, against your real schema and your real Apex triggers, in a container that is thrown away afterwards. No org, no login, no sandbox refresh, no waiting for a colleague to finish with the shared one.

This recipe has been run. The workflow below is executed on our own CI against a real corpus before it was written down here; the captured output at the bottom is from that run.

The workflow

name: integration tests

on: [push, pull_request]

jobs:
  integration:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v5

      - name: Start Fidelic
        run: |
          docker run -d --name fidelic -p 8080:8080 \
            -v "$PWD/mdapi:/mdapi:ro" \
            fidelic/emulator --apex-src /mdapi

      - name: Wait for the org to be ready
        run: |
          for i in $(seq 1 60); do
            if curl -sf -o /dev/null http://localhost:8080/services/data/v62.0/sobjects/Account/describe; then
              echo "emulator answering after ${i}s"
              break
            fi
            sleep 1
          done
          if ! docker logs fidelic 2>&1 | grep -q 'listening on'; then
            echo "::error::emulator never came up"; docker logs fidelic; exit 1
          fi
          CENSUS="$(docker logs fidelic 2>&1 | grep -o '[0-9]* triggers loaded' | head -1)"
          if [ -z "$CENSUS" ]; then
            echo "::error::the emulator started but loaded NO Apex"
            docker logs fidelic; exit 1
          fi
          echo "org ready: $CENSUS"

      - name: Run integration tests
        env:
          SF_BASE_URL: http://localhost:8080
        run: npm test          # or pytest, or go test, or whatever you already use

      - name: Emulator logs on failure
        if: failure()
        run: docker logs fidelic

      - name: Tear down
        if: always()
        run: docker rm -f fidelic

mdapi/ is your converted metadata — see the quickstart for the one sf project convert source command that produces it. Commit it, or generate it in an earlier step from your source tree.

Two traps in the readiness check

Both of these were found writing this recipe, and both would have produced a CI failure that points at the wrong thing.

/services/data/v62.0 returns 404. It looks like an API version root and is not one. Probe a describe instead — it is a real endpoint and returns 200.

“Answering” is not “loaded”, and this is the one that will waste your afternoon. The emulator answers a describe for Account whether or not your metadata mounted, because Account is built in. A readiness probe that only checks for a 200 goes green against an empty org, and then every test below fails with a message about your code — which is not where the problem is.

So the gate reads the emulator’s own census of what it loaded:

org ready: 8 triggers loaded

If that line is missing, the recipe fails immediately and says so, instead of handing you a green setup step and twelve confusing test failures. We learned this on our own playground, where six health checks stayed green through an org that had silently lost its Apex.

The example tests

Nothing about the tests is Fidelic-specific — they are HTTP calls against a base URL. Point your existing suite at SF_BASE_URL and it works unchanged.

const API = `${process.env.SF_BASE_URL}/services/data/v62.0`;

test('the org\'s real Apex triggers actually fired', async () => {
  await fetch(`${BASE}/__emulator__/reset`, { method: 'POST' });
  await post('/sobjects/Account', { Name: 'Trigger Probe' });

  // Assert on the FIRED LOG, not on a field value. A field can hold the right value for the
  // wrong reason — a default, a leftover record, a coincidence. The fired log is the org's own
  // statement about which trigger bodies it entered.
  const fired = await (await fetch(`${BASE}/__emulator__/fired`)).json();
  assert.ok(fired.count > 0, 'no trigger fired — the org booted without its Apex');
});

test('the row-lock failure can be staged on demand', async () => {
  const created = await (await post('/sobjects/Account', { Name: 'Lock Target' })).json();
  await fetch(`${BASE}/__emulator__/faults`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ errorCode: 'UNABLE_TO_LOCK_ROW', trigger: 'update' }),
  });
  const res = await fetch(`${API}/sobjects/Account/${created.id}`, { method: 'PATCH', /* … */ });

  // The test you cannot write against a sandbox at any price.
  assert.equal(res.status, 400);
});

The full file is example/integration.test.mjs. It uses node:test, which is built into Node, so it adds no dependency to audit.

What the real run printed

emulator answering after 1s
org ready: 8 triggers loaded
ok 1 - creating an Account returns a Salesforce-shaped 201
ok 2 - the org's real Apex triggers actually fired
ok 3 - a validation rule rejects a bad record the way the org would
ok 4 - the row-lock failure can be staged on demand
# pass 4
# fail 0

Whole job: 16 seconds, including pulling the image and booting the org.

Notes