Platform
Apex tests that assert: coverage is a floor, not a finish line
A deploy stalls at 74% at 5pm, so someone writes three assertion-free tests to clear the gate, and now the org has coverage that proves nothing. This is how to write Apex tests that verify behavior: build your own data, control limits with startTest, assert real values, mock the outside, and test in bulk.
A production deploy stalls at 5pm because org-wide Apex coverage sits at 74%, one point under the gate. Someone writes three quick tests that call the methods and assert nothing. Coverage ticks to 76%, the deploy goes through, and the org now has more coverage and exactly as much confidence as before, which is none.
That trade is the central confusion about Apex testing. The 75% number is a deploy gate, not evidence your code works. A test earns its place by asserting a behavior, not by executing a line.
So write tests that build their own data, run the code inside Test.startTest(), assert an exact expected value, and mock anything outside the transaction. Coverage then follows as a side effect of testing the real cases, which is the only order that produces both a green gate and code you trust.
Salesforce’s own testing guidance is blunt about it: “code coverage shouldn’t be the only goal of your tests. Tests should assert your app’s behavior.”
Coverage is a gate, not a grade
The rule for deploying Apex to production is 75% org-wide coverage, and every trigger must have some coverage above zero. The 75% is an aggregate across all classes and triggers, so a single class can sit well below it as long as the whole org clears the line.
Managed packages on the AppExchange face the same 75% bar before a version is promoted. Calls to System.debug and the test methods themselves don’t count toward the percentage.
Read what that measures, and what it doesn’t. Coverage tells you a line ran during a test. It says nothing about whether the test checked that the line did the right thing.
You can hit 100% coverage with zero assertions, and the only thing you’ve proven is that the code doesn’t throw on the happy path. The gate exists so you can’t ship entirely untested code; it was never a quality score, and treating it as one is how teams end up with high coverage and recurring production bugs.
The useful target is not a percentage. It’s that every behavior the code promises, the positive path, the error path, and the bulk case, has a test that asserts the result. Coverage above 75% is what that looks like from the outside.
Build your own data
Since API 24.0, test methods don’t see your org’s existing records by default, and that isolation is a feature. A test that queries real data passes in the org where that data happens to exist and fails in the one where it doesn’t, which turns an unrelated deploy into a 5pm debugging session. Create every record the test needs inside the test.
A few objects are always visible without setup, including User, Profile, Organization, and RecordType, because they describe the org rather than its business data. For everything else, build it.
The clean way to build it once is @TestSetup: a method that runs before any test method in the class and creates records every method can use. Changes a method makes to those records roll back after that method finishes, so each test starts from the same clean state. There’s one setup method per class, and it can’t be combined with SeeAllData=true.
Centralize the record creation in a test data factory, an @isTest-annotated utility class whose public methods return the sObjects your tests need. The payoff is maintenance: when someone adds a required field to Account, you update one factory method, not the inline setup in forty test methods. Keep the factory and your sandbox seed data deliberate rather than accreted, and reach for masked production data when a realistic shape matters.
Control limits and time with startTest
Wrap the code under test in Test.startTest() and Test.stopTest(). The pair does two jobs that matter.
It gives the code between them a fresh set of governor limits, separate from the setup and assertion code around it, so your test’s own data creation doesn’t eat into the limit budget you’re trying to measure. And it forces asynchronous work to finish: any @future, Queueable, or Batch job queued inside the block runs to completion at stopTest(), so you can assert its results on the next line.
That second behavior is the one people miss. Test an async method without the block and the job never runs before your assertions, so the assertions pass against unchanged data and prove nothing. The shape is always the same:
@isTest
static void enqueuedJobUpdatesAccounts() {
List<Account> accounts = TestDataFactory.createAccounts(200);
Test.startTest();
System.enqueueJob(new AccountRollupJob()); // queued, not yet run
Test.stopTest(); // runs synchronously here
List<Account> updated = [SELECT Id, Rollup__c FROM Account WHERE Id IN :accounts];
for (Account a : updated) {
Assert.areEqual(0, a.Rollup__c, 'rollup should reset to zero for new accounts');
}
}
Assert the behavior, with real values
An assertion is where a test stops being coverage and starts being a test. Salesforce added the Assert class in Winter ‘23, and its methods read better than the old ones: Assert.areEqual, Assert.isNull, Assert.isTrue, Assert.fail, and the rest.
The legacy System.assertEquals and friends still work and aren’t going away. In both APIs the optional message is the last argument, and the convention is (expected, actual, message), so a mismatch report reads the right way round.
Assert exact values computed from your setup data, not ranges or existence checks. Assert.areEqual(3, results.size(), '...') catches a bug that Assert.isTrue(results.size() > 0, '...') sleeps through.
Assert the thing the code was supposed to change: the field it stamped, the related record it created, the exception it threw. A method that calls your code and checks nothing is the assertion-free test from the opening, and it’s worse than no test because it looks like protection.
Mock the outside world
Tests can’t make real web service callouts; a test that tries one fails by design, because a unit test must not depend on an external system being up. You supply the response instead.
For HTTP, implement HttpCalloutMock and register it with Test.setMock(HttpCalloutMock.class, new MyMock()); for SOAP, WebServiceMock does the same job. If you only need a canned body, StaticResourceCalloutMock and MultiStaticResourceCalloutMock save you writing the interface.
For mocking your own classes without touching the database, the Apex Stub API (Test.createStub with a System.StubProvider) generates a stand-in at runtime. A test can inject a fake selector or service without inserting records to satisfy it, which makes tests faster and more focused. The companion discipline, for the callouts you do want to exercise for real against a scratch org, is covered in integration testing.
Test as the user, and in bulk
Two realities of production need their own tests. The first is user context. runAs(user) switches the test to run as a specific user, so you can check that your code behaves under that user’s record sharing.
Be precise about what it enforces: it applies that user’s record-level sharing, and in current API versions object- and field-level permissions inside the block, but it has never enforced every system permission, so don’t lean on it as a full permission check. Its other everyday use is dodging the Mixed DML error, by wrapping setup-object DML like inserting a User inside a runAs block, separate from your Account and Contact DML.
The second is bulk. Triggers and the code they call process records in batches of up to 200, so test with 200 records in a single DML, not one. A one-record test passes against a SOQL-in-a-loop that a 200-record test fails immediately, and the bulk test is the only one that catches it before a data load does.
Add negative tests that assert the exception type and message on the error path, and reach for Test.isRunningTest() only when production code genuinely must branch for test context, such as skipping a real callout.
The agent angle
This isn’t legacy hygiene. A custom Agentforce action is an Apex @InvocableMethod, ordinary Apex subject to the same 75% deployment gate, so the action your agent depends on can’t reach production without tests. Salesforce’s guidance for agent actions is the same discipline in a new context: write clear invocable descriptions, run in user mode and honor sharing, bulkify, and cover it with real unit tests.
There’s a coverage nuance worth knowing. Salesforce now offers Apex integration tests for Agentforce and Data 360 that can invoke an agent and assert on the response, but integration tests don’t count toward your deployment coverage, while unit tests do.
So the unit tests on your invocable method are still what gets you deployed. A CI pipeline that gates on the 75% number, the DevOps discipline every serious org ends up with, enforces it on every merge.
Where this lands
The order is the whole lesson. Write tests that build their own data, control limits and async with startTest and stopTest, assert exact behavior, mock what’s outside the transaction, and exercise the bulk and negative paths.
Coverage climbs past 75% because you tested the real cases, not because you padded the number. A suite built that way does the job the gate only pretends to: it tells you, before the deploy and before the agent runs in front of a customer, that the code does what you said it would. The assertion-free test clears the gate and leaves you exactly where you started, which is the one outcome testing is supposed to prevent.
Understanding the basics
What is the Apex code coverage requirement to deploy to production?
At least 75% org-wide Apex coverage, with every trigger covered above zero. The 75% is an aggregate across all classes and triggers, so an individual class can be lower as long as the org total clears the line. System.debug calls and test code are excluded from the calculation, and managed packages face the same 75% bar before a version is promoted.
Why do Apex tests fail when they query existing org data?
Because since API 24.0 test methods can’t see existing org records by default. A test that relies on data already in the org passes where that data exists and fails where it doesn’t. Create the records the test needs inside the test, ideally through a @TestSetup method and a shared test data factory, and avoid @isTest(SeeAllData=true).
What do Test.startTest() and Test.stopTest() do?
They give the code between them a fresh set of governor limits, isolated from your setup and assertion code, and they force queued asynchronous work (@future, Queueable, Batch) to run to completion at stopTest(). Put the code under test inside the block and assert its results after stopTest(), otherwise async code never runs before your assertions.
How do I test Apex that makes a callout?
Mock it. Real callouts aren’t allowed in tests, so implement HttpCalloutMock (or WebServiceMock for SOAP) and register it with Test.setMock, or use StaticResourceCalloutMock for a canned response. To validate a real callout end to end, use Apex integration tests against a scratch org instead of a mock.
Inheriting an org with high coverage and low confidence, or wiring a CI pipeline that gates on tests that assert something real? Talk to us. Making a Salesforce codebase safe to change is what we do.