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
- Parallel jobs: each needs its own container and host port. Run one emulator per matrix leg rather than sharing — sharing reintroduces exactly the shared-state problem you left the sandbox to escape.
- Between tests,
POST /__emulator__/resetreturns the org to empty in milliseconds. That is cheaper than a fresh container, and it also restarts the fault RNG stream so one test’s draws cannot change another’s outcome. - Seeding data:
POST /__emulator__/seedbuilds records that satisfy your required fields and wires required lookups to parents it creates. See the quickstart. - Nothing leaves the runner. The emulator makes no outbound calls.
--network noneworks if your tests do not need the network either.