The CLI part is fine: sf apex run test --tests MyTest --result-format human --wait 20 does what it says. The cost is everything around it — you must deploy changed code before every run, the org queues your tests behind whatever else it's doing, and the whole loop runs at org speed, not machine speed. Know the flags, script the polling, and move the per-save iteration loop somewhere that doesn't involve an org round-trip.
The workflow, end to end
Running a test against an org is never one command. The org executes the code it has, not the code in your editor — so every iteration is deploy first, then run:
# 1. Push your changed source to the org
sf project deploy start --source-dir force-app --wait 10
# 2. Run the tests
sf apex run test --tests InvoiceServiceTest --result-format human --wait 20Forgetting step 1 is the classic sf CLI trap: the run succeeds, the results look wrong, and ten minutes later you realize you've been testing last Tuesday's version of the class. If your test results ever make no sense, check what's deployed before you check your logic.
Selecting what runs
Three flags control scope, and they compose differently than you'd guess:
--teststakes class names or individual methods:--tests InvoiceServiceTest.testProrationruns one method. This is the flag for iteration.--class-namestakes whole classes only. Equivalent to--testsat class granularity.--test-leveltakesRunSpecifiedTests,RunLocalTests(everything except managed packages — what deployments validate against), orRunAllTestsInOrg(managed package tests too; you almost never want this).
# One method, human-readable output
sf apex run test --tests InvoiceServiceTest.testProration --result-format human --wait 20
# The full local suite with coverage, like a deployment validation would run
sf apex run test --test-level RunLocalTests --code-coverage --result-format human --wait 60--code-coverage adds per-class coverage to the output, and --result-format also accepts json, junit, and tap for CI. Add --output-dir to write result files to disk.
Async by default, and what --wait actually does
Test runs are asynchronous on the platform: the CLI enqueues a run, the org executes it when it gets around to it, and --wait just controls how long the CLI polls before giving up and handing you a run ID. A timeout doesn't cancel anything — the run continues in the org, and you pick the results up later:
# Fire and forget — returns a test run ID immediately
sf apex run test --test-level RunLocalTests
# Come back for the results
sf apex get test --test-run-id 707XXXXXXXXXXXX --result-format human --code-coverageThere's also --synchronous, which runs the tests in a single synchronous transaction and returns results directly. It's noticeably snappier for small runs, but it only works for tests from one class at a time — the moment you're testing more than one class, you're back in the async queue.
The queue matters more than people expect. Your run shares org resources with deployments, other developers' runs, and scheduled jobs. The same suite can take two minutes at 9am and ten minutes after lunch. None of that time is your code executing.
Where the time actually goes
A single iteration on one failing test, measured honestly:
- Deploy the change — tens of seconds on a good day; more if the deploy touches metadata that triggers recompilation.
- Queue — zero to minutes, entirely outside your control.
- Execute — usually the smallest slice.
- Fetch and read results — seconds, plus the log-spelunking if the failure message isn't enough.
Call it two to five minutes per attempt. A test that takes four tries to fix costs a quarter hour of elapsed time for maybe ninety seconds of actual thinking — the rest is waiting shaped like work. We wrote about the compounding cost of that loop in a separate post; the short version is that it doesn't just cost time, it changes which tests you're willing to write at all.
Making the org loop as fast as it can be
If the org is in your loop, these keep the overhead at the floor:
- Deploy only what changed. Source tracking on scratch orgs and sandboxes means
sf project deploy startwithout--source-dirpushes just the delta. - Run one method, not the class.
--tests Class.methodwhile iterating; the full class before you push. - Use
--synchronousfor single-class runs. It skips the queue's worst behavior. - Script the pair. A shell alias that deploys then tests saves you from the stale-deploy trap permanently.
Or take the org out of the loop
Every step above optimizes a round-trip that exists only because the code executes in the org. Nimbus removes the round-trip: it runs your Apex tests locally against your source directory, on an embedded PostgreSQL, with no deploy step at all — the code in your editor is the code that runs.
# Same tests, no org, no deploy — runs your working tree as-is
nimbus test "InvoiceServiceTest.testProration"
# The whole suite, in parallel
nimbus test "*"The deploy step disappears, the queue disappears, and what's left is execution — which was always the small slice. A single method comes back in well under a second; suites that take twenty minutes through sf apex run test finish locally in seconds. The sf CLI stays in the picture where it belongs: deploying validated code and running the final pre-merge check against a real org. See the setup guide — it's a single binary, no Docker.
Checklist
- Deploy before you run. The org tests what it has, not what you see.
--tests Class.methodfor iteration;RunLocalTeststo rehearse a deployment validation.--waittimeouts don't cancel runs — recover results withsf apex get test --test-run-id.--synchronousis faster but single-class only.- Keep the org for verification, not per-save iteration — that loop belongs on your machine.
Run the same tests without the round-trip
Nimbus executes your Apex test suite locally — no org, no deploy, no queue. One binary, your existing source tree, results in seconds.