Quickstart — run the emulator on your own org
Run your real Apex triggers against a fake Salesforce org on localhost, inject failures a real org never lets you reproduce, and test unapproved changes before they ship.
Run your real Apex triggers against a fake Salesforce org on localhost, and inject
failures a real org will never let you reproduce (row locks, API limits). One Docker
image, nothing to install, nothing to compile.
docker pull fidelic/emulator
That image contains two binaries, both stamped with the same build SHA:
| binary | what it is | how you run it |
|---|---|---|
/sfemu | the emulator server | the default entrypoint |
/sfapex | the analyzer — tells you what will run before you run it | --entrypoint /sfapex |
Run it with no arguments and it prints this cheat-sheet and exits; it does not start an empty org.
You need the Salesforce CLI
(sf) — you almost certainly already have it — and your org checked out as an SFDX
project (force-app/…).
1. Convert your source once (REQUIRED for a full org)
The emulator knows two objects out of the box (Account, Contact). Every other
object — standard or custom — it learns from your metadata. Your SFDX checkout is
decomposed; the emulator reads MDAPI .object files. One convert produces both the
Apex and the object metadata:
cd /path/to/your/sfdx-project
sf project convert source --root-dir force-app --output-dir mdapi
# -> mdapi/classes/*.cls mdapi/triggers/*.trigger mdapi/objects/*.object
Objects not in your source won’t exist in the emulator — if a trigger targets one, add it to your project and re-convert.
2. See what will run — before running it
docker run --rm -v "$PWD/mdapi":/mdapi:ro \
--entrypoint /sfapex fidelic/emulator coverage /mdapi
Per-trigger verdict, no surprises: EXECUTE (no blocker found — the interpreter will
attempt it), HOOKS (approximated), UNRESOLVED (refused, with file:line + reason),
RECORDED-ONLY (the effect is captured, never really sent). An EXECUTE* row carries a
SIMULATED: note naming which effects are captured rather than performed. Handler-instance
/ inheritance style triggers are the ones that classify EXECUTE most often; config-driven
dispatch (NPSP TDTM-style) shows UNRESOLVED today — an honest gap, not a crash. --json
for machine-readable.
EXECUTE is a static verdict, not a guarantee. It means the classifier walked the reachable code and found nothing it knows it cannot run — it does not mean we have run this trigger. On our own 14-repo reference corpus, 46 triggers classify EXECUTE and 8 enter their body in every context they declare when actually driven over REST (5 more enter some). The rest fail on constructs the classifier admitted; each one is a bug we hold. Treat EXECUTE as “worth trying”, run it, and read what actually happens.
For a pasteable answer, add --report. Same data, shorter face: the headline plus the gaps
grouped by reason, with no per-trigger wall.
docker run --rm -v "$PWD/mdapi":/mdapi:ro \
--entrypoint /sfapex fidelic/emulator coverage --report /mdapi
CAN I TRUST THIS TEST?
28% of this org classifies EXECUTE — 51 of 177 trigger-context pairs, across 8 of 26 triggers.
EXECUTE means no blocker was found in the reachable code. It does NOT mean we ran it —
run your tests and read /__emulator__/fired to see what actually fired.
7 of those pairs (1 trigger(s)) run real code with some effects CAPTURED, not performed.
WHAT WOULD NOT RUN — 133 pair(s) across 19 trigger(s), by reason:
49 pair(s) managed-package reference: ApexPages [OUT]
TDTM_Account, TDTM_Affiliation, TDTM_Allocation, TDTM_Campaign, +3 more
7 pair(s) dispatch: interface Evaluator [C3]
TDTM_HouseholdObject
The percentage counts trigger-context PAIRS, not triggers. A trigger declaring seven timings and a one-line before-insert trigger are not the same amount of your org, so counting triggers makes the number swing on which size of trigger happens to be blocked. On NPSP the two framings differ by three points in our favour, which is why the harder one is the one printed.
This step runs entirely offline — --network none works — and its report is the one
artifact worth sending back to us. See release/WHAT-LEAVES-YOUR-MACHINE.md for exactly
what that report does and does not contain.
3. Run the emulator on your org
# one flag — point --apex-src at your converted mdapi dir; it supplies both the
# triggers/classes AND the object schema (from mdapi/objects):
docker run -p 8080:8080 -v "$PWD/mdapi":/mdapi:ro fidelic/emulator --apex-src /mdapi
# the explicit equivalent (env var + metadata dir), if you prefer:
docker run -p 8080:8080 -v "$PWD/mdapi":/mdapi:ro \
-e SFEMU_APEX_SRC=/mdapi fidelic/emulator --metadata-dir /mdapi/objects
It logs its version and how many triggers loaded/EXECUTE-classified, then answers the
Salesforce REST API at http://localhost:8080. Point your integration or tests there
instead of a sandbox.
Boot is proportional to how much Apex you have. A handful of triggers is well under a second; a large real org takes longer — the Nonprofit Success Pack’s 26 triggers and ~1,000 classes take about 15 s on a small cloud VM. It is parsing every class you gave it.
4. Talk to it — your triggers fire
curl -sX POST localhost:8080/services/data/v62.0/sobjects/Opportunity \
-H 'Content-Type: application/json' \
-d '{"Name":"Big Deal","StageName":"Prospecting","Amount":50000}'
curl -s localhost:8080/services/data/v62.0/sobjects/Opportunity/<id>
curl -s --get localhost:8080/services/data/v62.0/query \
--data-urlencode "q=SELECT Id, Name FROM Opportunity WHERE Amount > 0"
For EXECUTE-classified objects the REST layer is permissive about fields, so triggers reading/writing standard fields not present in your metadata still work.
5. Break it on command (the whole point)
| env var | effect |
|---|---|
SFEMU_FAULT_LOCK_ON_UPDATE=true | every PATCH returns UNABLE_TO_LOCK_ROW |
SFEMU_FAULT_LIMIT_AFTER=N | after N writes, return REQUEST_LIMIT_EXCEEDED |
SFEMU_LATENCY_MS=N | add N ms latency to every /services/data/ response (token + /__emulator__/ calls are unaffected) |
The boolean flags accept any truthy value — 1, true, t, TRUE. Faults fire on
every object identically, whether it runs through the interpreter or the hooks path.
docker run -p 8080:8080 -v "$PWD/mdapi":/mdapi:ro \
-e SFEMU_FAULT_LOCK_ON_UPDATE=true fidelic/emulator --apex-src /mdapi
A PATCH then returns HTTP 400 with the real Salesforce error shape — an array:
[{"message":"unable to obtain exclusive access to this record or 1 records",
"errorCode":"UNABLE_TO_LOCK_ROW","fields":[]}]
You can also arm and clear faults at runtime, without restarting, which is what you want from inside a test suite:
# arm
curl -sX POST localhost:8080/__emulator__/faults \
-H 'Content-Type: application/json' -d '{"errorCode":"UNABLE_TO_LOCK_ROW"}'
# -> {"success":true}
curl -sX PATCH localhost:8080/services/data/v62.0/sobjects/Account/001000000000001AAA \
-H 'Content-Type: application/json' -d '{"Site":"HQ"}'
# -> HTTP 400
# [{"message":"unable to obtain exclusive access to this record or 1 records",
# "errorCode":"UNABLE_TO_LOCK_ROW","fields":[]}]
# clear — the same PATCH then returns HTTP 204
curl -sX DELETE localhost:8080/__emulator__/faults
Unknown keys are ignored, so a body that arms nothing is refused with a 400 naming the
accepted keys — a misspelt errorCode cannot buy you a silent green.
Intermittent faults
Add probability to make a fault fire on some matching operations instead of all of them —
for exercising retry logic against a failure that comes and goes:
curl -sX POST localhost:8080/__emulator__/faults \
-H 'Content-Type: application/json' \
-d '{"errorCode":"UNABLE_TO_LOCK_ROW","trigger":"update","probability":0.25,"once":false}'
# -> {"success":true,"seed":1,"note":"probabilistic fault armed; draws are seeded and reproducible…"}
| field | meaning |
|---|---|
| omitted | deterministic — fires on every matching operation. This is the default and the existing behaviour. |
1 | fires every time, exactly. No draw is taken, so it cannot miss. |
0 | never fires. A legitimate control arm, and not the same as omitting the field. |
0.0 – 1.0 | fires with that chance per matching operation. |
Outside 0–1 is refused with a 400 rather than clamped: a caller sending 1.5 has a bug in
whatever computed it, and silently reading that as 1 would hide the bug behind plausible
behaviour.
The draws are seeded, and that is deliberate. An intermittent fault that fired on a coin
flip nobody recorded would make this emulator a source of flaky CI — the exact thing you are
using it to escape. The same seed always produces the same sequence of fire/no-fire decisions,
the arming response tells you the seed, and POST /__emulator__/reset restarts the stream so
one test’s draws cannot change another’s outcome.
Replaying an intermittent failure
This is the workflow the seed exists for. Your suite goes red, one operation in six failed, and you need it to fail again on demand.
1. The arming response told you the seed. Every probabilistic arm returns it:
{"success":true,"seed":6,
"note":"probabilistic fault armed; draws are seeded and reproducible. Re-run with the same
seed for the same sequence; /__emulator__/reset restarts it."}
2. The run that failed. Six updates against a fault armed at probability: 0.3:
update 1 -> 204
update 2 -> 204
update 3 -> 204
update 4 -> 400 [{"message":"unable to obtain exclusive access to this record or 1 records",
"errorCode":"UNABLE_TO_LOCK_ROW","fields":[]}]
update 5 -> 204
update 6 -> 204
3. Replay it. Start the emulator with that seed and run the same sequence:
docker run -p 8080:8080 -v "$PWD/mdapi":/mdapi:ro \
-e SFEMU_FAULT_SEED=6 fidelic/emulator --apex-src /mdapi
The failure lands on update 4 again, and on every run after that. It is the fourth call that fails, not a random one — so you can attach a debugger, add logging, or bisect your retry logic against a failure that now happens on purpose.
Two things make that hold. The seed is fixed by default (1) rather than taken from the clock,
so a run is reproducible even before you set anything. And reset restarts the stream between
tests, so the draw your fourth update sees does not depend on how many draws the tests above it
happened to make — without that, adding one test at the top of a suite would change the outcome
of every test below it.
Your retry/error-handling path — normally impossible to test — now has something real to catch.
6. Ask what actually happened
The emulator keeps a record of what it did, on control endpoints under /__emulator__/.
These are the emulator’s own surface, not Salesforce’s — nothing here exists in a real org.
| endpoint | answers |
|---|---|
GET /__emulator__/fired | which triggers fired, in which phase, and whether they entered |
GET /__emulator__/effects | effects captured rather than performed (recorded-only) |
GET /__emulator__/limits | modeled governor counts — APPROXIMATE, it says so itself |
GET /__emulator__/async | queued async jobs |
GET /__emulator__/callouts | HTTP callouts captured instead of sent |
POST /__emulator__/reset | wipe all records and clear the logs |
POST / DELETE /__emulator__/faults | arm / clear faults at runtime |
The fired log is the one you will use most — it is how you check a trigger ran, rather than inferring it from a field value:
curl -sX POST localhost:8080/services/data/v62.0/sobjects/Account \
-H 'Content-Type: application/json' -d '{"Name":"Acme"}'
# -> {"id":"001000000000002AAA","success":true,"errors":[]}
curl -s localhost:8080/__emulator__/fired
{
"count": 5,
"fired": [
{"object":"Account","op":"insert","outcome":"entered-clean",
"phase":"before","trigger":"AccountDefaultsTrigger"},
{"object":"Account","op":"insert","outcome":"entered-clean",
"phase":"before","trigger":"AccountNestedTrigger"}
]
}
outcome is the field that matters. entered-clean means the handler body actually ran. A
trigger that was classified EXECUTE but never entered will not appear here — which is the
whole point of having the log rather than trusting the classification.
7. Seed test data against your own schema
Hand-assembling a record that satisfies every required field — and a parent for every required lookup, and a parent for that — is the least interesting code in a test suite and the first thing to rot when the schema changes. The emulator already holds your schema, so it can build them:
curl -sX POST localhost:8080/__emulator__/seed \
-H 'Content-Type: application/json' \
-d '{"object":"Invoice__c","count":2}'
{"success":true,
"seeded":{"object":"Invoice__c",
"created":[
{"Id":"a06000000000002AAA","Name":"Invoice__c 1","Amount__c":1,
"Status__c":"Draft","Account__c":"001000000000001AAA"},
{"Id":"a06000000000004AAA","Name":"Invoice__c 2","Amount__c":2,
"Status__c":"Sent","Account__c":"001000000000003AAA"}],
"parents":[
"Invoice__c.Account__c → Account 001000000000001AAA",
"Invoice__c.Account__c → Account 001000000000003AAA"]}}
Account__c holds a real Account that was created for it — the parents list records every
lookup that was wired, so nothing is invisible. Add "fields" to pin any value you care about;
the rest is generated around it, and the parent chain still gets built:
-d '{"object":"Invoice__c","count":1,"fields":{"Name":"Q3 Invoice","Status__c":"Sent"}}'
Records go in through the same path a REST POST takes — before-insert triggers, required-field checks, validation rules, the lot. A seeder that wrote straight to the store would hand you records your own org rules reject, and your test would pass here and fail against the real org with the seeded data being the last place you look.
Generation is deterministic: the same request produces the same values, so a test that seeds is reproducible.
What it refuses, and why that matters more
A seeded record looks right by construction — a plausible value in every field is exactly what a wrong one looks like. So it refuses rather than invents, and names the field:
| situation | what happens |
|---|---|
| a required picklist whose values were never retrieved | refused — inventing one produces a record a real org rejects |
| a required lookup with no recorded target | refused — the target is not guessed from the field name |
| an object you declared but retrieved no fields for | refused — the record would satisfy a shape the emulator invented |
| a required-lookup cycle (A needs B, B needs A) | refused, naming the cycle |
| a field type it cannot satisfy | refused, naming the field and the type |
Refusals come back as "success": false with the reason, and records created before a refusal
still exist — call reset if you need a clean org.
v1 scope: one object, plus the parents its required lookups demand. It walks up, not down: no children, no full object graphs. That is a deliberate stopping point — generating a graph means deciding how many children with what cardinality, and every one of those is a guess about the test you are writing.
8. Trace one request end to end
The fired log tells you which triggers ran. The trace tells you what they did, in order, and what caused what — which is what you need when a field ends up with a value and you cannot see who set it.
Tracing is off by default and has two gates. The switch arms the machinery; the header picks which request pays for it.
docker run -p 8080:8080 -v "$PWD/mdapi":/mdapi:ro \
-e SFEMU_TRACE=true fidelic/emulator --apex-src /mdapi
# sfemu: tracing ON — send X-Fidelic-Trace on a request to capture it, then read
# /__emulator__/trace (in memory only, never written to disk)
Send the header on the one request you care about:
curl -sD- -o /dev/null -X POST localhost:8080/services/data/v62.0/sobjects/Account \
-H 'Content-Type: application/json' -H 'X-Fidelic-Trace: 1' -d '{"Name":"Acme"}'
# HTTP/1.1 201 Created
# X-Fidelic-Trace-Id: req-1
The response carries an id, not the document. The body of a Salesforce response is measured against the real thing for conformance, so nothing may be added to it. Fetch the document separately:
curl -s localhost:8080/__emulator__/trace
Real output from that run, summarised (the raw form is JSON):
request POST /services/data/v62.0/sobjects/Account outcome 201, committed
seq depth kind caused_by what
0 0 request - POST /services/data/v62.0/sobjects/Account
1 0 transaction -
2 1 field_change - Account.Description: null -> "Created via automation"
3 0 trigger - AccountDefaultsTrigger before insert -> entered-clean
4 1 field_change 3 Account.Description: "Created via automation" -> "nested"
5 0 trigger 3 AccountNestedTrigger before insert -> entered-clean
6 1 field_change 5 Account.Description: "nested" -> "Created via routed trigger"
7 0 trigger 5 AccountRoutedTrigger before insert -> entered-clean
8 1 field_change 7 Account.Site: null -> "Acme - imported"
9 0 trigger 7 LeadImportTrigger before insert -> entered-clean
10 0 dml 9 insert Account rows=1 ids=[001000000000001AAA]
11 0 trigger 10 AccountRoutedTrigger after insert -> entered-clean
12 0 commit 1
Read the caused_by column. Three separate triggers overwrote Description before the record
was ever saved, each one triggered by the last. The final stored value came from the third.
That is the class of problem the trace exists for: the record shows you the answer, and only
the trace shows you the argument.
rolled_back marks work that happened and was then undone — it stays in the trace, because
what the machinery did is the point, not a tidied version of it.
Traces are never written to disk. They hold real field values, including whatever your code put there. They live in memory, the last 16 requests only, and die with the container.
9. Run it in CI
Point your existing integration tests at a Fidelic org on every push — no login, no sandbox, no shared state:
- GitHub Actions — executed as a real workflow run before it was written down; the page quotes that run’s output.
- GitLab CI — including the one difference that bites if you translate
the GitHub version literally: with
docker:dindthe daemon cannot see your job’s filesystem, so a-vmount of your metadata silently gives you an empty org.
One lesson from both, worth repeating here: wait for the org to be LOADED, not merely
answering. Account describes fine on a completely empty org because it is built in, so a
readiness probe that only checks for a 200 goes green when your metadata failed to mount — and
then every test fails with a message about your code. Gate on the emulator’s own census line
(8 triggers loaded) instead.
10. Reset between tests, and run orgs side by side
Reset wipes every record and clears the logs, in milliseconds, without a restart:
curl -sX POST localhost:8080/__emulator__/reset # -> HTTP 200
Verified: records go to totalSize: 0 and the fired log to count: 0. Call it between test
cases and every case starts from a clean org.
Parallel is just more containers. Each has its own store and shares nothing:
docker run -d -p 8080:8080 -v "$PWD/mdapi":/mdapi:ro fidelic/emulator --apex-src /mdapi
docker run -d -p 8081:8080 -v "$PWD/mdapi":/mdapi:ro fidelic/emulator --apex-src /mdapi
Verified: a record created on :8080 leaves :8081 at totalSize: 0. Run one per CI worker.
Boot cost is per container, so for a large org prefer resetting a long-lived container over
booting a fresh one per test.
Capacity — read this before you load a lot of data
The store is in-memory SQLite. Its ceiling is the container’s RAM, and there is no graceful limit today. When memory runs out the container is OOM-killed and you get no error at all — the socket closes mid-request and your client reports a connection reset, not a capacity problem.
Measured on bc0f8a3: a --memory=192m container accepted 17,224 records of ~5 KB (about
86 MB of payload) and was then killed with exit 137.
Practically this is fine for a test suite — hundreds of records per case with a reset between —
and it is a real hazard for bulk-loading a production data extract.
If you need volume, put the store on disk. Same 192 MB container, same records, --db on a
mounted volume: 30,000 records accepted, 157.5 MB on disk, container still running.
docker run -p 8080:8080 -v "$PWD/data":/data -v "$PWD/mdapi":/mdapi:ro \
fidelic/emulator --apex-src /mdapi --db /data/org.sqlite
Giving the container more memory (--memory=4g) and calling reset often both work too; the
on-disk store is the one that removes the ceiling rather than raising it.
A graceful refusal (a real STORAGE_LIMIT_EXCEEDED-style envelope instead of a dropped socket)
is on the backlog, gated on someone actually hitting this. Tell us if that is you.
Testing unapproved changes — boot the future org
You don’t have to wait for a change to be approved and deployed to see what it does. The emulator learns the org from metadata on disk, so you can run proposed Apex against the org as it would be once the change lands — locally, before review. The only variable is which source tree you convert:
-
Base snapshot. Start from the source that represents the current, approved org (a clean checkout /
main). This is your baseline.cd /path/to/sfdx-project sf project convert source --root-dir force-app --output-dir mdapi -
Overlay your unapproved change. Drop the new/edited
.clsand.triggerfiles into the source tree (check out your feature branch, or copy them in):cp AccountDedupe.trigger force-app/main/default/triggers/ cp AccountDedupeHandler.cls force-app/main/default/classes/ -
Re-convert.
mdapi/now describes the org with your change applied — the future org:sf project convert source --root-dir force-app --output-dir mdapi -
See what it’ll do, then boot it:
# is the new trigger EXECUTE / HOOKS / UNRESOLVED? docker run --rm -v "$PWD/mdapi":/mdapi:ro \ --entrypoint /sfapex fidelic/emulator coverage /mdapi docker run -p 8080:8080 -v "$PWD/mdapi":/mdapi:ro fidelic/emulator --apex-src /mdapi
The emulator is now the org as it would be after your change deploys. Exercise it with the REST calls from steps 4–5 above (including faults) — you’re testing the future org without touching a sandbox or waiting on approval.
Want present-vs-future side by side? Convert the approved tree to
mdapi-base/and the overlaid tree tomdapi-next/, boot each on a different host port, and compare the two orgs directly.
What talks to the network
- The emulator makes no outbound calls. No telemetry, no phone-home, no licence check.
It reads the
mdapi/folder you mounted and serves REST onlocalhost. Run it with--network noneand it behaves identically. sf project convert sourceis local — it just reshapes files on disk.- The only thing that ever reaches Salesforce is your own
sfCLI, using your credentials against your org — never ours, never us. --demo npspis the one exception, and it is opt-in: it clones a public corpus from GitHub at a pinned commit, once, and caches it. Nothing of yours leaves. Without that flag the image never opens a socket outbound.
Notes
-
Versions: both binaries print the same build SHA — quote it when you report anything, so it’s traceable to an exact build:
docker run --rm fidelic/emulator version # sfemu <sha> docker run --rm --entrypoint /sfapex fidelic/emulator version # sfapex <sha> -
Persistence:
--db :memory:(default) is wiped on restart. To keep data across runs, mount a directory and point at a file inside it:-v "$PWD/data":/data --db /data/org.sqlite. -
Auth: off by default.
SFEMU_CLIENT_SECRET=...requires a client secret on the token endpoint. -
What’s UNRESOLVED won’t run — by design.
sfapex coverage(step 2) tells you exactly what, up front, so there are no lies at runtime. -
Try it without any of this: https://play.fidelic.dev boots a real NPSP org in your browser, no install and no signup.
-
fidelic sync(scripts/fidelic) is a small shell wrapper aroundsf project retrievefor pulling metadata straight from a live org with your credentials. It is a host-side script, not part of the image, because it drives your localsfCLI.