Coverage Is the Floor, Not the Ceiling
Salesforce requires 75% Apex code coverage to deploy to production. That number gets chased because it’s the deployment gate, but it doesn’t actually prove your code works.
Coverage counts executed lines. A test class can hit 100% coverage and assert nothing. Passing the deployment gate is necessary; proving the code works is separate.
Good tests are measured by the bugs they catch, not the lines they touch.
The Test Data Factory Pattern
Every test needs records to work against. The wrong way: inline record creation in every test method. The right way: a centralized TestDataFactory class that builds records with sensible defaults.
@isTest
public class TestDataFactory {
public static Account createAccount() {
return createAccount('Test Account ' + Datetime.now().getTime());
}
public static Account createAccount(String name) {
Account a = new Account(Name = name, Industry = 'Technology', NumberOfEmployees = 100);
insert a;
return a;
}
public static Account buildAccount(String name) { // no insert
return new Account(Name = name, Industry = 'Technology', NumberOfEmployees = 100);
}
public static List<Contact> createContacts(Integer count, Id accountId) {
List<Contact> contacts = new List<Contact>();
for (Integer i = 0; i < count; i++) {
contacts.add(new Contact(FirstName = 'Test', LastName = 'User ' + i, AccountId = accountId));
}
insert contacts;
return contacts;
}
}
Test methods call the factory. When a required field gets added to an object, you update one line in the factory, not 40 tests.
Expose both create* (inserts) and build* (returns unsaved) variants — some tests want the record before DML, some want it after.
One Scenario Per Test Method
Test methods should be narrow. Name them after the scenario:
testCaseEscalationFiresOnHighPriority()testCaseEscalationDoesNotFireOnLowPriority()testCaseEscalationHandlesNullAccount()
A test method called testEscalation that asserts eight different things fails to tell you which scenario broke when a future change triggers one assertion.
Test Class Setup
Use @testSetup methods to create records used by every test in the class. Records created in @testSetup are persistent across test methods within the class (but reset between test classes).
@isTest
private class AccountTriggerTest {
@testSetup
static void setup() {
Account a = TestDataFactory.createAccount('Shared Test Account');
TestDataFactory.createContacts(3, a.Id);
}
@isTest
static void testSomething() {
Account a = [SELECT Id FROM Account LIMIT 1];
// use a
}
}
Saves time on DML setup. Keeps individual tests focused on behavior, not data.
Assert, Assert, Assert
A test without assertions is not a test. Every method should include at least one System.assertEquals, System.assertNotEquals, or modern Assert.areEqual call.
Prefer the Assert class (introduced in Winter ‘23) for clearer failure messages:
Assert.areEqual(expected, actual, 'Priority should be High for Enterprise accounts');
Assert.isTrue(record.Escalated__c, 'Record should be escalated');
Assert.isNotNull(result, 'Method should return a non-null result');
The third argument — the message — is what you’ll thank yourself for when a test fails at 2am.
Test.startTest / Test.stopTest
Wrap the code under test in Test.startTest() / Test.stopTest(). This gives you a fresh set of governor limits for the tested code, isolating it from setup overhead. It also forces async work to complete before assertions.
@isTest
static void testBulk() {
List<Account> accounts = TestDataFactory.buildAccounts(200);
Test.startTest();
insert accounts;
Test.stopTest();
// assertions
}
Mocking HTTP Callouts
Apex cannot make real HTTP calls in tests. Use HttpCalloutMock:
public class MockExternalApi implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setBody('{"status":"ok"}');
return res;
}
}
@isTest
static void testCalloutSuccess() {
Test.setMock(HttpCalloutMock.class, new MockExternalApi());
// invoke the code that makes the callout
// assert on the result
}
For multiple callouts in one test, use MultiStaticResourceCalloutMock or implement a mock that inspects the request and returns different responses per URL.
Mocking Apex Dependencies
For non-callout dependencies (services, factories), use dependency injection plus stub classes.
public interface IAccountService {
void sync(List<Account> accounts);
}
public class AccountSyncer {
private IAccountService service;
public AccountSyncer(IAccountService svc) { this.service = svc; }
public void run() { service.sync([SELECT Id FROM Account]); }
}
@isTest
private class AccountSyncerTest {
public class MockService implements IAccountService {
public Integer callCount = 0;
public void sync(List<Account> accounts) { callCount++; }
}
@isTest
static void testRun() {
MockService mock = new MockService();
new AccountSyncer(mock).run();
Assert.areEqual(1, mock.callCount);
}
}
Or use Stub.mock() (the newer stubbing API) for lighter-weight cases.
Test With Different Users
Logic that depends on the running user’s permissions must be tested as that user.
@isTest
static void testReadOnlyUserCannotDelete() {
User readOnlyUser = TestDataFactory.createUser('Read Only');
System.runAs(readOnlyUser) {
try {
// attempt the operation
Assert.fail('Should have thrown');
} catch (DmlException e) {
// expected
}
}
}
Skipping runAs in permission-sensitive tests is a common gap.
SeeAllData: Don’t
@isTest(SeeAllData=true) lets the test see all org data. It’s the lazy way to write tests that depend on records.
Problems:
- Tests become fragile — a data change breaks them.
- Tests don’t run in scratch orgs cleanly.
- CI pipelines with shared sandboxes get noisy results.
Use @testSetup and factory methods instead. SeeAllData is a last resort for rare framework limitations.
Test Naming Convention
Pick a convention and stick to it. Options:
test_should_<behavior>_when_<condition>()test<Method>_<scenario>()<scenario>_<expectedOutcome>()
Whichever — consistent naming makes scrolling through a 50-method test class navigable.
Coverage Reporting
Run coverage reporting via sf apex run test --code-coverage --result-format human. Review which lines lack coverage and ask: is this a real code path that can execute in production? If yes, write a test. If no (defensive code for “impossible” conditions), either remove the dead code or accept the gap.
Never chase coverage by adding assertion-less tests that just exercise lines.
Common Mistakes
Asserting on what was already done. insert a; Assert.areEqual('Foo', a.Name); doesn’t test anything — you just set Name to ‘Foo’. Assert on what the code produced.
Only testing happy paths. Production data has nulls, typos, and bad parents. Test them.
Tests that share state. @testSetup is shared within a class but reset between classes. Don’t rely on data from another test class.
Excessive setup. If a test class needs 200 lines of setup to test 5 lines of logic, something is wrong — either in the tested code or in the test design.
Frequently Asked Questions
What coverage do I actually need?
75% for deployment. For production-critical code, aim for 90%+ with meaningful assertions.
Can I run tests in production?
Yes. sf apex run test works against any org. Be careful — tests should be isolated and not require production-specific data.
Should I test getters and setters?
No. Trivial accessors don’t need tests. Coverage from other tests that exercise them is sufficient.
What about testing Queueable chains?
Test.stopTest() forces one level of async to complete. For multi-level chains, assert on intermediate state after each chain link completes.