API & options reference
Every endpoint, flag, environment variable, and fault the Fidelic emulator supports.
The emulator answers a subset of the Salesforce REST API at http://localhost:<port>
(default :8080). Point any client that speaks Salesforce REST at it — curl, your
integration code, a test suite — instead of a live org or sandbox.
Everything documented here is implemented today. Where behaviour intentionally differs from a real org, it’s called out.
- Starting the server
- CLI flags
- Environment variables
- Authentication
- Data API — records, query, describe
- Fault injection — break it on command
- Control endpoints
- Running your Apex test classes
- Error envelopes
- Common workflows
Starting the server
# minimal — built-in Account/Contact only, empty in-memory store
./sfemu --addr :8080
# with your org's metadata (objects) and Apex (triggers/classes)
SFEMU_APEX_SRC=mdapi ./sfemu --addr :8080 --metadata-dir mdapi/objects
On startup it logs its version, store, and how many triggers loaded / were EXECUTE-classified:
sfemu 4734cdd listening on :8080 (store: sqlite ":memory:", client-secret: false, metadata: "mdapi/objects", hooks-file: "")
./sfemu version prints the build SHA on its own — quote it in any bug report so it’s
traceable to an exact build.
Two builds ship in the bundle. The default
sfemuruns your triggers through approximated hooks. Theapexexecbuild additionally runs EXECUTE-classified triggers through the real interpreter (SFEMU_APEX_SRCis only read there). Both answer the identical REST surface below — the execution path is invisible to callers, and faults fire the same way regardless.
CLI flags
| flag | default | meaning |
|---|---|---|
--addr | :8080 | listen address |
--metadata-dir | $SFEMU_METADATA_DIR | directory of Salesforce .object XML — teaches the emulator your objects/fields |
--hooks-file | $SFEMU_HOOKS_FILE | declarative hooks JSON loaded at startup |
--db | :memory: | SQLite DSN — :memory: (wiped on restart) or a file path (persists) |
version | — | subcommand: print build SHA and exit |
Environment variables
| var | effect |
|---|---|
SFEMU_METADATA_DIR | same as --metadata-dir |
SFEMU_HOOKS_FILE | same as --hooks-file |
SFEMU_APEX_SRC | (apexexec build) directory of Apex to classify + run for EXECUTE objects |
SFEMU_CLIENT_SECRET | if set, the token endpoint requires this client_secret; unset ⇒ accept any credentials |
SFEMU_LICENSE_KEY | license key checked at startup (stub) |
SFEMU_LATENCY_MS | add N ms latency to every /services/data/ response |
SFEMU_FAULT_LOCK_ON_UPDATE | arm UNABLE_TO_LOCK_ROW on updates — see Fault injection |
SFEMU_FAULT_LIMIT_AFTER | after N calls, return REQUEST_LIMIT_EXCEEDED (<0 = off, the default) |
Boolean flags accept any value Go’s strconv.ParseBool understands — 1, t, true,
TRUE. Anything unset or unrecognized is off.
SFEMU_TRACE
Arms execution-trace capture. Off by default, and still per-request: a traced request must also
carry the X-Fidelic-Trace header, so an untraced request on a traced server costs one nil
check. See Execution trace.
docker run -p 8080:8080 -v ./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)
SFEMU_FAULT_SEED
Seeds the random stream behind probabilistic faults. Defaults to 1 —
fixed, never taken from the clock, so a run is reproducible before you set anything. Same seed,
same sequence of fire/no-fire decisions.
Authentication
Auth realism is deliberately light in v0 — enough to test a client’s token flow, not to enforce security.
POST /services/oauth2/token
Only grant_type=client_credentials is supported.
curl -sX POST localhost:8080/services/oauth2/token \
-d grant_type=client_credentials -d client_id=x -d client_secret=y
{
"access_token": "00Demu.00D...",
"instance_url": "http://localhost:8080",
"token_type": "Bearer",
"issued_at": 1753166400000
}
- If
SFEMU_CLIENT_SECRETis set, a mismatchedclient_secret→400 invalid_client. - Any other
grant_type→400 unsupported_grant_type.
Bearer tokens on data requests
- No
Authorizationheader → allowed. You can hit the data API without a token. - If you do send
Authorization: Bearer <t>, it must be a token this instance issued, otherwise401 INVALID_SESSION_ID. (So a token from a restarted instance is rejected.)
Data API (/services/data/)
Path shape: /services/data/{version}/{sobjects|query}/...
{version} must be v<major>.<minor> with major ≥ 20 (e.g. v62.0). Anything else
→ 404. Examples below use v62.0.
Create — POST /sobjects/{object}
curl -sX POST localhost:8080/services/data/v62.0/sobjects/Account \
-H 'Content-Type: application/json' -d '{"Name":"Acme"}'
{ "id": "001...", "success": true, "errors": [] }
Runs your before/after-insert logic (hooks or interpreter), required-field and field-type validation, then persists.
Retrieve — GET /sobjects/{object}/{id}
curl -s localhost:8080/services/data/v62.0/sobjects/Account/001...
# projection: only the fields you ask for (+ attributes)
curl -s 'localhost:8080/services/data/v62.0/sobjects/Account/001...?fields=Id,Name'
Unknown record → 404. Unknown field in ?fields= → 400 INVALID_FIELD.
Update — PATCH /sobjects/{object}/{id}
curl -sX PATCH localhost:8080/services/data/v62.0/sobjects/Account/001... \
-H 'Content-Type: application/json' -d '{"Rating":"Hot"}'
204 No Content on success. Updating a missing id → 404 INVALID_CROSS_REFERENCE_KEY
(distinct from GET’s NOT_FOUND — a real-org quirk the emulator reproduces).
Delete — DELETE /sobjects/{object}/{id}
204 on success; missing id → 404 INVALID_CROSS_REFERENCE_KEY.
Upsert — PATCH /sobjects/{object}/{externalIdField}/{value}
Matches on the external-id field: updates if found (200), creates if not (201).
Unknown external-id field → 400.
Query — GET /query?q=<SOQL>
curl -s --get localhost:8080/services/data/v62.0/query \
--data-urlencode "q=SELECT Id, Name FROM Account WHERE Name != null"
{ "totalSize": 1, "done": true, "records": [ { "attributes": {...}, "Id": "001...", "Name": "Acme" } ] }
Unknown sObject → 400 INVALID_TYPE; unknown column → 400 INVALID_FIELD; unparseable
SOQL → 400 MALFORMED_QUERY.
The supported clauses are SELECT (flat fields, Parent.Field paths, and
parent-to-child subqueries), FROM, WHERE (= != < > <= >= IN NOT IN LIKE, combined
with AND/OR), ORDER BY (with ASC/DESC and NULLS FIRST/NULLS LAST), LIMIT,
and OFFSET. WITH SECURITY_ENFORCED parses and is ignored, because field-level security
is not enforced anywhere in the emulator — honouring it would claim a check that is not
made.
A clause we do not implement is refused by name, with UNSUPPORTED_SOQL:
curl -s --get localhost:8080/services/data/v62.0/query \
--data-urlencode "q=SELECT COUNT() FROM Account"
# 400 [{"errorCode":"UNSUPPORTED_SOQL",
# "message":"unsupported SOQL: COUNT() — aggregate results are a different
# response shape, not a filtered row set"}]
UNSUPPORTED_SOQL is not a Salesforce error code, deliberately: a real org answers that
query, so a code no org emits is the signal that the limit is ours. The refused set is
aggregates (COUNT(), SUM, MIN, MAX, AVG), GROUP BY, HAVING, TYPEOF,
FOR UPDATE/FOR VIEW, ALL ROWS, relative date literals (TODAY, LAST_N_DAYS:30),
toLabel(), FORMAT(), convertCurrency(), and semi-joins (IN (SELECT …)).
A genuine syntax error still returns MALFORMED_QUERY. The two are kept apart because
telling someone their correct SOQL is malformed sends them to re-read their own query.
In Apex, Database.query runs the same engine. Binds resolve by name against the
enclosing scope, as they do in a real org:
Id wanted = a.Id;
List<Account> r = Database.query('SELECT Id FROM Account WHERE Id = :wanted');
A bind naming nothing in scope is refused and named, rather than left in the string to
surface later as a syntax error. Database.countQuery, Database.merge and
Database.convertLead are refused by name — they are not implemented.
Describe
| request | returns |
|---|---|
GET /sobjects | global describe — every known object (name, label, keyPrefix) |
GET /sobjects/{object} | basic info + empty recentItems |
GET /sobjects/{object}/describe | field list (name, label, type), keyPrefix, queryable |
Objects come from your metadata. Only
AccountandContactexist out of the box; every other object — standard or custom — is learned from--metadata-dir. A trigger targeting an object not in your metadata won’t have anything to run against.
Fault injection
The whole point: make a real org’s un-reproducible failures happen on demand, so your retry/error-handling paths finally have something to catch. Faults are transport-level — checked before any trigger runs — so they fire identically whether an object is EXECUTE-classified (interpreter) or hooks-approximated.
Fault catalog
| errorCode | HTTP | fires on |
|---|---|---|
UNABLE_TO_LOCK_ROW | 400 | armed op (default: update) |
REQUEST_LIMIT_EXCEEDED | 403 | any data call, after N |
Declarative (env vars, re-applied on reset)
| var | behaviour |
|---|---|
SFEMU_FAULT_LOCK_ON_UPDATE=1 | next update returns UNABLE_TO_LOCK_ROW (armed Once) |
SFEMU_FAULT_LIMIT_AFTER=N | first N data calls succeed, then every call returns REQUEST_LIMIT_EXCEEDED |
SFEMU_LATENCY_MS=N | N ms added to every data response |
SFEMU_FAULT_LOCK_ON_UPDATE=1 SFEMU_APEX_SRC=mdapi \
./sfemu --addr :8080 --metadata-dir mdapi/objects
# every PATCH -> [{"errorCode":"UNABLE_TO_LOCK_ROW", ...}]
Runtime (arm/clear without restart)
POST /__emulator__/faults arms one fault; DELETE clears all. Body:
| field | type | default | meaning |
|---|---|---|---|
errorCode | string | — | one of the catalog above |
trigger | string | "*" | op to fire on: insert / update / delete, or "*" for any data call |
afterN | int | 0 | let N calls through first |
once | bool | true | fire once then disarm, vs. every matching call |
latencyMs | int | — | set global latency on /services/data/ requests (can be sent alone) |
# fire UNABLE_TO_LOCK_ROW on the next delete, once
curl -sX POST localhost:8080/__emulator__/faults \
-d '{"errorCode":"UNABLE_TO_LOCK_ROW","trigger":"delete"}'
# fail every call after the 5th, until cleared
curl -sX POST localhost:8080/__emulator__/faults \
-d '{"errorCode":"REQUEST_LIMIT_EXCEEDED","afterN":5,"once":false}'
# clear everything
curl -sX DELETE localhost:8080/__emulator__/faults
Check your field names. Unknown JSON keys are ignored, so a misspelt key is invisible —
{"fault":"UNABLE_TO_LOCK_ROW"} sets no errorCode at all. A body that ends up arming nothing
is therefore refused, and the refusal names the keys that would have worked:
curl -sX POST localhost:8080/__emulator__/faults -d '{"fault":"UNABLE_TO_LOCK_ROW"}'
# 400 {"success":false,"error":"this body arms nothing: send errorCode to arm a fault,
# or latencyMs to inject latency. Accepted keys: errorCode, trigger, afterN, once,
# probability, latencyMs"}
This used to answer 200 {"success":true} having armed nothing, which is the worst available
reply: the operation then succeeded, and the caller concluded their retry path was exercised
when no fault had ever existed. There is no read-back to catch it either — GET on this
endpoint is 405 — so the arming response is the only feedback there is, and it has to be
honest.
latencyMs on its own stays legal (it arms something real); errorCode on its own stays legal.
Only a body with neither is refused.
Probabilistic faults
Add probability to make a fault intermittent — for exercising retry logic against a failure
that comes and goes.
| value | meaning |
|---|---|
| omitted | deterministic — fires on every matching call. The default, and the pre-existing behaviour. |
1 | fires every time, exactly. No draw is taken, so it cannot miss. |
0 | never fires. A control arm, and not the same as omitting the field. |
0.0–1.0 | fires with that chance per matching call. |
curl -sX POST localhost:8080/__emulator__/faults \
-H 'Content-Type: application/json' \
-d '{"errorCode":"UNABLE_TO_LOCK_ROW","trigger":"update","probability":0.5,"once":false}'
# {"success":true,"seed":1,
# "note":"probabilistic fault armed; draws are seeded and reproducible. Re-run with the same
# seed for the same sequence; /__emulator__/reset restarts it."}
Ten updates against that fault, on the default seed:
...XX.XXXX (X = the fault fired)
The draws are seeded, deliberately. A fault firing on a coin flip nobody recorded would make
this emulator a source of flaky CI — the thing you are using it to escape. The same seed always
produces the same sequence; the arming response returns the seed so a failure can be replayed;
SFEMU_FAULT_SEED picks a different one; and reset restarts the stream so one test’s draws
cannot change another test’s outcome.
Out of range is refused, not 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:
curl -sX POST localhost:8080/__emulator__/faults \
-d '{"errorCode":"UNABLE_TO_LOCK_ROW","probability":1.5}'
# 400 {"success":false,"error":"probability must be between 0 and 1 inclusive, got 1.5"}
Control endpoints (/__emulator__/)
Admin-style, fenced under a double-underscore prefix — zero collision with
/services/*. Not part of the Salesforce API; specific to the emulator.
| endpoint | method | effect |
|---|---|---|
/__emulator__/reset | POST | wipe all data + runtime faults/hooks, re-apply the startup baseline (env-var faults, hooks file), and restart the probabilistic-fault random stream |
/__emulator__/faults | POST / DELETE | arm one fault (see above) / clear all |
/__emulator__/hooks | POST / DELETE | register one declarative hook / clear all |
/__emulator__/license | GET | report license-stub status |
/__emulator__/async | GET | list the async work captured this session (Queueable / @future / Schedulable / Batchable) |
/__emulator__/callouts | GET / POST / DELETE | list the HTTP callouts that fired this session (method, endpoint, status); register a mock response; clear all mocks |
/__emulator__/effects | GET | list recorded effects (EventBus.publish / Messaging.sendEmail) captured this session |
/__emulator__/fired | GET | which trigger bodies actually ran, and in which context — how you assert a trigger fired |
/__emulator__/limits | GET | modeled governor counters; off by default and approximate, and says so |
/__emulator__/trace | GET | the execution trace for a captured request (needs SFEMU_TRACE and the X-Fidelic-Trace header) |
/__emulator__/seed | POST | generate valid records against your schema, wiring required lookups to generated parents |
/__emulator__/testrun | GET / POST | report which of your @isTest methods would run / run them and report pass, fail or refusal per method |
reset returns to your configured baseline, not to empty — so a lock fault armed via
SFEMU_FAULT_LOCK_ON_UPDATE comes back after a reset. Use it between test cases for a
clean, reproducible starting point.
It also restarts the random stream behind probabilistic faults. That is what makes tests independent of each other: without it, a probabilistic fault’s outcome would depend on how many draws every earlier test happened to make, so adding one test at the top of a suite would silently change the result of every test below it.
Fired log (/__emulator__/fired)
Which trigger bodies the org actually entered, and in which context. This is how you assert a trigger ran — a field value can hold the right thing for the wrong reason, but the fired log is the org’s own statement about what executed.
curl -sX POST localhost:8080/services/data/v62.0/sobjects/Account \
-H 'Content-Type: application/json' -d '{"Name":"Acme"}'
# {"id":"001000000000001AAA","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"},
{"object":"Account","op":"insert","outcome":"entered-clean","phase":"before","trigger":"AccountRoutedTrigger"}
]}
outcome is the field that matters. entered-clean means the body ran. A trigger that was
classified EXECUTE but never entered does not appear here at all — which is the point of having
the log rather than trusting the classification.
Cleared by reset.
Governor counters (/__emulator__/limits)
Modeled DML and query counts against Salesforce’s limits.
curl -s localhost:8080/__emulator__/limits
{"enabled": false, "dmlStatements": 0, "dmlRows": 0, "queries": 0,
"limits": {"dmlStatements": 150, "dmlRows": 10000, "queries": 100},
"note": "APPROXIMATE — counts modeled operations only; not Salesforce-exact (SIMULATED)"}
Off by default, and approximate on purpose — the endpoint says so in its own response. The real limits are numerous, interact, and are not fully documented; a number that was close but wrong would let code pass here and fail in production, which is the failure this product exists to prevent.
Execution trace (/__emulator__/trace)
What happened inside one request: which triggers fired, in what order, what they changed, what
was captured, and what was rolled back. Two gates — SFEMU_TRACE arms the
machinery, and the X-Fidelic-Trace header selects which request pays for it.
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":"Traced"}'
# HTTP/1.1 201 Created
# X-Fidelic-Trace-Id: req-1
curl -s localhost:8080/__emulator__/trace
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 and match on the id.
{"id":"req-1","trace":{"entries":[
{"seq":0,"kind":"request","depth":0},
{"seq":1,"kind":"transaction","depth":0},
{"seq":2,"kind":"field_change","depth":1},
{"seq":3,"kind":"trigger","depth":0}
], "outcome":{"status":201,"committed":true}}}
13 entries for that one insert. caused_by links each entry to the one that triggered it, and
rolled_back marks work that happened and was then undone — it stays in the document, because
what the machinery did is the point rather than a tidied version of it.
Never written to disk. Traces carry real field values, so they live in a bounded in-memory ring — the last 16 requests — and die with the container. With tracing off, the endpoint says so rather than returning an empty document.
Seed test data (/__emulator__/seed)
Generates records that satisfy every required field, wiring required lookups to parents it creates first.
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 created for it; parents records every lookup that was wired.
Add "fields" to pin any value you care about — the rest is generated around it and the parent
chain is still built.
Records go in through the same path a REST POST takes: before-insert triggers, required checks, validation rules. A seeder that wrote straight to the store would hand you records your own org rules reject.
It refuses rather than invents, naming the field — a picklist whose values were never retrieved, a lookup with no recorded target, an object you retrieved no fields for, a required-lookup cycle:
curl -sX POST localhost:8080/__emulator__/seed -d '{"object":"Ghost__c"}'
# {"success":false,"seeded":{"object":"Ghost__c","created":[],
# "refused":["unknown object \"Ghost__c\" — the emulator holds no schema for it"]}}
Refusing to invent is a 200, not a 4xx. The status code answers “was your request
well-formed”, not “did you get records” — so the two failures look different on the wire:
| Outcome | Status | Field to read |
|---|---|---|
| refused to invent a value (unknown object, unretrieved picklist, lookup with no target) | 200 | refused[], with success:false |
the request itself was wrong (count over the cap, missing object, unparseable body) | 400 | error |
A test that branches on the HTTP status alone therefore reads a Ghost__c refusal as a success
and seeds nothing. Branch on success, and print refused when it is false — it names the
field that stopped the seeder.
A seeded record looks right by construction — a plausible value in every field is exactly what a wrong one looks like — so refusing is the only safe default.
v1 scope: one object plus the parents its required lookups demand. It walks up, not down: no children, no full object graphs.
count is capped at 200 per call, and above it the request is refused rather than quietly
truncated — a silent cap would have you believe you seeded 500 records when you seeded 200:
curl -sX POST localhost:8080/__emulator__/seed -d '{"object":"Invoice__c","count":500}'
# 400 {"success":false,"error":"count 500 exceeds the 200-record limit for one call"}
The bound exists because this endpoint inserts: an unbounded count is an out-of-memory button on a shared sandbox. Call it repeatedly for more.
Async log (/__emulator__/async)
Apex async is real but has no honest “later” in a synchronous emulator, so the emulator runs it deterministically at a defined moment and makes the outcome assertable rather than fire-and-forget:
- Queueable (
System.enqueueJob) and@futurecalls are transactional — enqueued during the triggering DML, run after that transaction commits, each in its own transaction (fresh statics, isolated rollback). A parent rollback un-enqueues them. This is the default (inline) mode;record-onlycaptures them without running. - Schedulable (
System.schedule) is record-only — cron timing isn’t simulated, so it is captured as scheduled but never run. - Calling a
@futurefrom inside a@future/Queueable body raises the real platform error (“Future method cannot be called from a future or batch method”), isolated to that job. - Batchable (
Database.executeBatch) is transactional like Queueable. Its lifecycle is collapsed to a single chunk —start()→execute()once over the whole scope →finish().start()’sDatabase.getQueryLocatoris lazy: the query runs at drain against the committed store, so a rolled-back write is invisible to the scope and a committed one is included. The collapse does not model per-chunk transaction boundaries orDatabase.Stateful; a batch’sexecute()may enqueue a Queueable child (allowed within the chain budget).Database.executeBatchfrom an already-async context is refused.
Running your Apex test classes (/__emulator__/testrun)
Fidelic runs simple test classes. It does not run a real Salesforce test suite, and the
honest size of the gap is published rather than described: measured over five open-source
Salesforce codebases at pinned commits — apex-recipes, DLRS, EDA, fflib sample code and
NPSP — 125 of their 793 test classes (15%) ran every method with nothing refused, and
760 of their 7,518 @isTest methods (10%) ran and reported a result (617 passed, 143
failed). Everything else was refused by name, with a file and a line.
An ambiguous error counts against us, not against your test. Only a message carrying
the runner’s own Assertion Failed prefix is scored as a test failure. Anything else — a
QueryException because we carry no schema for ApexClass, a too few arguments because
method overloading is not modelled — is counted as our refusal even though it surfaced
inside your test. An earlier draft of this page scored those the other way and reported 410
failures against 143 real ones; the figures above are the stricter count.
Those are whole-suite denominators, not the part we admitted. The full per-codebase table and the command that produced it are on https://fidelic.dev/receipts#tests.
GET tells you what would run, and costs nothing. Ask before you spend a run:
curl -s localhost:8080/__emulator__/testrun
# {"classes":{"ACCT_IndividualAccounts_TEST":14,"UTIL_UnitTestData_TEST":3},
# "testClasses":2,"testMethods":17}
POST runs them. An empty body runs everything; name classes to run a subset:
curl -sX POST localhost:8080/__emulator__/testrun \
-H 'Content-Type: application/json' \
-d '{"classes":["ACCT_IndividualAccounts_TEST"]}'
{
"total": 3, "passed": 1, "failed": 2,
"results": [
{"class":"ACCT_IndividualAccounts_TEST","method":"createOneToOne","line":31,
"passed":true},
{"class":"ACCT_IndividualAccounts_TEST","method":"detectDupes","line":58,
"passed":false,"message":"Assertion Failed: Expected: 2, Actual: 1"},
{"class":"ACCT_IndividualAccounts_TEST","method":"bulkRecordTypes","line":96,
"passed":false,
"message":"[88:21] Schema.RecordTypeInfo is not in the shipped describe surface"}
],
"simulated": ["Test.stopTest drained async work inline — each job runs in its own transaction immediately, not on a real queue with real timing (SIMULATED)"]
}
A run with any failure returns HTTP 417, so a CI step sees it from the status code without parsing the body. A build with no Apex interpreter returns 400 and says so.
Reading the result
Three outcomes, and the difference matters:
| outcome | meaning |
|---|---|
"passed": true | the method ran and its assertions held |
"passed": false with an Assertion Failed message | the method ran and an assertion compared two values that differed |
"passed": false with any other message | we refused, naming the construct, the file and the line. The method did not run |
An assertion failure is not an accusation. It may be this emulator diverging from a real org, or a test that depends on org state Fidelic does not model — profiles, record types, org-wide defaults. We have not split those and do not claim the split. A refusal, by contrast, is always ours.
line is the method’s declaration line, so a failure is clickable in your editor.
The simulated array is present on every run — empty when nothing was approximated — and
lists what was modelled rather than performed. A pass list with no such field would read as a
statement of exactness, which these results are not.
What the runner does and does not do
@testSetupruns once per class, and its data is visible to every method in that class.- Each method is isolated: static state, governor counters, the async queue, the callout mock registry and every DML write are rolled back before the next method starts. A method cannot see another method’s records.
Test.startTest()/Test.stopTest()are honoured;stopTest()drains pending async work.Test.setMock(HttpCalloutMock.class, ...)is honoured and answers before the callout registry.System.AssertExceptionis not catchable bycatch (Exception e), as in a real org — a method cannot swallow its own failed assertion and report a pass.System.runAs(block)is implemented, but you currently cannot reach it: it requires a realUservalue, andUsercarries no schema —new User(...)is refused and a[SELECT Id FROM User]returns no rows. Even once that is fixed, there is one user context and no profile or permission-set model, so arunAsblock would prove nothing about permissions; permission checks answer as a system user (seeschema-permissionon the capabilities page). Tracked as sfemu#11.Test.loadData,Test.getStandardPricebookIdandTest.createStubare refused with a message naming them and saying why, not stubbed.Test.setFixedSearchResultsis refused too, with the generic unsupported-call message that names the call and its tier.Assert.isInstanceOfTypeandisNotInstanceOfTypeare refused: they need runtime type identity, which is not modelled, and an approximation would pass for any argument.- There is no code-coverage figure. Fidelic does not compute one, and will not print a number it cannot stand behind.
The execution trace (/__emulator__/trace)
GET /__emulator__/trace returns the documents captured for recent requests, newest last.
Tracing is off unless SFEMU_TRACE is set, and a request is only captured when it carries
X-Fidelic-Trace.
trace_version is now 2. The schema did not change; the noise did. A field_change
entry whose before equals its after is no longer emitted. The DML path used to report
every field it wrote, including ones rewritten with the value already there — most visibly
Id: a04…AAA → a04…AAA on every update, a row’s primary key handed back to itself. Rendered,
those lines are indistinguishable at a glance from a real mutation, so a reader had to compare
two long ids character by character to discover that nothing happened. The one entry kind whose
job is to say what changed should not be the one padding the story.
A consumer written against version 1 still parses a version 2 document — no field was added, removed or renamed — but one that counted entries will see fewer, which is why the version moved rather than letting it find out.
To read a trace as a diagram instead of JSON, paste it into https://www.fidelic.dev/trace. That page renders entirely in your browser and uploads nothing; traces carry record field values, which is also why the emulator keeps them in memory and never writes them to disk.
Answering a callout
Callouts are served only from a mock registry and never from the network. Register one before the code under test runs:
curl -sX POST localhost:8080/__emulator__/callouts \
-H 'Content-Type: application/json' \
-d '{"method":"POST","url":"https://netsuite.example.com/*","status":201,"body":"{\"internalId\":\"4815\"}"}'
# {"mocks":1,"success":true}
| key | meaning |
|---|---|
method | matched case-insensitively; omit it to match any method |
url | exact match, or a prefix when it ends in * |
status | defaults to 200; refused outside 100–599 rather than clamped |
body | returned verbatim as the response body |
headers | optional map, readable with res.getHeader(...) |
First registered wins, so a later rule never silently shadows an earlier one. DELETE the
same path to clear every rule; POST /__emulator__/reset clears them too.
A callout with no matching rule throws System.CalloutException — it does not open a
socket and does not return an empty response. A rule armed for the wrong method or a
different host does not match, which is deliberate: a mock that answered anything would let
a test pass against code that called the wrong endpoint.
The other two gates are unchanged. Only a @future(callout=true) or
Database.AllowsCallouts context may call out at all, and no callout may run while
uncommitted DML is pending.
- The callout log is history, not state. Every callout that fires is recorded in
GET /__emulator__/callouts, and a DML rollback (statement savepoint or whole transaction) does not erase it — an HTTP callout escapes the transaction, so the external system already saw it. This is deliberate: it makes the classic callout+rollback+retry double-fire bug (e.g. a payment charged twice) observable instead of hidden. - Recorded effects are the mirror — they DO roll back.
EventBus.publishandMessaging.sendEmailare captured inGET /__emulator__/effects(payloads / recipients, never delivered). Unlike a callout, a platform event on the standard bus and an email are delivered on commit, so a rollback un-does them: the effect log holds only committed effects. HTTP stays the lone effect that survives rollback because it’s the only one that escapes the transaction mid-flight. A platform-event (__e) subscriber trigger does fire: the publish is delivered at commit and the subscriber runs in its own transaction, the way Salesforce runs it. What is not modelled is the identity — there is no Automated Process user here, so the subscriber runs as the same user — and there is no way to publish an event from outside Apex. A change-data-capture subscriber (ChangeEvent,__chn) is the one that never fires: nothing here generates change events, so those triggers are reported RECORDED-ONLY rather than executed.
curl -s localhost:8080/__emulator__/async
# { "count": 2, "jobs": [
# { "type": "MakeContact", "method": "execute", "kind": "queueable", "ran": true },
# { "type": "Nightly", "method": "execute", "kind": "schedulable", "ran": false,
# "note": "scheduled; cron timing not simulated (record-only)" } ] }
Each captured job carries its kind (queueable / future / schedulable / batch), so
the same log records the whole async surface.
Error envelopes
DML/query errors use the documented Salesforce array shape:
[ { "message": "unable to obtain exclusive access to this record or 1 records",
"errorCode": "UNABLE_TO_LOCK_ROW",
"fields": [] } ]
OAuth errors use the token-endpoint shape:
{ "error": "invalid_client", "error_description": "invalid client credentials" }
Common codes you’ll see: INVALID_CROSS_REFERENCE_KEY (patch/delete missing id),
INVALID_TYPE (unknown sObject), INVALID_FIELD (unknown column),
MALFORMED_QUERY (bad SOQL), REQUIRED_FIELD_MISSING, JSON_PARSER_ERROR,
INVALID_SESSION_ID (unrecognized bearer).
Common workflows
Point real integration code at the emulator
- Start with your metadata + triggers loaded.
- In your client, swap the instance URL for
http://localhost:8080(auth optional — the client-credentials flow above works if your client insists on a token). - Run your suite. Records persist within the run (
--db file.sqliteto persist across restarts).
Test a lock-failure retry path
# terminal 1 — server with locks armed
SFEMU_FAULT_LOCK_ON_UPDATE=1 SFEMU_APEX_SRC=mdapi \
./sfemu --addr :8080 --metadata-dir mdapi/objects
# terminal 2
ID=$(curl -sX POST localhost:8080/services/data/v62.0/sobjects/Account \
-H 'Content-Type: application/json' -d '{"Name":"Locke"}' | jq -r .id)
curl -sX PATCH localhost:8080/services/data/v62.0/sobjects/Account/$ID \
-H 'Content-Type: application/json' -d '{"Rating":"Hot"}'
# -> UNABLE_TO_LOCK_ROW ; your retry logic now has a real failure to handle
To let updates succeed again: curl -X DELETE localhost:8080/__emulator__/faults
(or restart without the env var).
Simulate hitting an API limit mid-batch
curl -sX POST localhost:8080/__emulator__/faults \
-d '{"errorCode":"REQUEST_LIMIT_EXCEEDED","afterN":10,"once":false}'
# 11th call onward -> 403 REQUEST_LIMIT_EXCEEDED, until you DELETE it
See also: QUICKSTART.md for first-run setup (converting an SFDX project, the coverage report, macOS quarantine).