TL;DR
- 75% coverage is the deployment gate, not a quality bar. Coverage only proves a line ran, not that it did the right thing.
- A useful test class builds its own data with a factory, runs 200 records, asserts specific outcomes with the Assert class and covers the failure paths.
- Use System.runAs with user-mode code to test permissions, Test.startTest/stopTest to run async Apex, and HttpCalloutMock so callouts never leave the org.
Code coverage is the deployment gate, not the quality bar. Salesforce won't deploy your Apex to production until tests execute at least 75% of it, but a test can run every line and still prove nothing. A test class worth keeping builds its own data with a factory, runs your code against 200 records, asserts specific outcomes with the Assert class, exercises the failure paths, runs as a realistic user with System.runAs, and mocks every callout. This post shows each of those with code you can deploy.
I spent years reading other people's Apex, first as an engineer at Salesforce.com and since then in the community. The orgs that struggle with deployments rarely have a coverage problem. They have a confidence problem: the tests are green, but nobody trusts them enough to refactor anything.
What code coverage measures (and what it doesn't)
Coverage is the percentage of executable lines that ran during your tests. To deploy to production:
- at least 75% of your Apex across the org must be covered
- every test must pass
- every trigger must have some coverage.
Lines inside classes annotated @IsTest don't count toward the total, so test utilities never drag your percentage down.
Here's a test that can get a class to 100% and verifies nothing:
@IsTest
private class AccountServiceUselessTest {
@IsTest
static void coverEverything() {
Account acc = new Account(Name = 'Acme', Rating = 'Cold', Phone = '555-0100');
insert acc;
acc.Rating = 'Hot';
update acc;
}
}
There's no assertion in it. If someone breaks the logic tomorrow, this test still passes, and the coverage report still looks perfect.
What coverage can't tell you:
- Whether the output is right. A line that runs and writes the wrong value counts exactly the same as a correct one.
- Whether the code survives bulk data. One record in a test proves nothing about a 200-record Data Loader job.
- Whether failure paths behave. Validation errors, empty inputs and HTTP 500s are where production bugs live.
- Whether a less-privileged user can run it. Your test runs as an admin unless you say otherwise.
The rest of this post fixes each of those gaps.
The code we're going to test
Every example below tests this small piece of logic. When an Account's Rating changes to Hot, the owner gets a follow-up task. A Hot account without a phone number is rejected.
trigger AccountTrigger on Account (before update, after update) {
if (Trigger.isBefore) {
AccountService.validateHotAccounts(Trigger.new, Trigger.oldMap);
} else {
AccountService.createFollowUpTasks(Trigger.new, Trigger.oldMap);
}
}
public with sharing class AccountService {
private static final String HOT = 'Hot';
public static void validateHotAccounts(List<Account> accounts, Map<Id, Account> oldMap) {
for (Account acc : accounts) {
if (becameHot(acc, oldMap.get(acc.Id)) && String.isBlank(acc.Phone)) {
acc.addError('A Hot account needs a phone number.');
}
}
}
public static void createFollowUpTasks(List<Account> accounts, Map<Id, Account> oldMap) {
List<Task> tasks = new List<Task>();
for (Account acc : accounts) {
if (becameHot(acc, oldMap.get(acc.Id))) {
tasks.add(new Task(
Subject = 'Call ' + acc.Name + ' within 2 days',
WhatId = acc.Id,
OwnerId = acc.OwnerId,
ActivityDate = Date.today().addDays(2),
Priority = 'High'
));
}
}
if (!tasks.isEmpty()) {
insert tasks;
}
}
private static Boolean becameHot(Account acc, Account oldAcc) {
return acc.Rating == HOT && oldAcc.Rating != HOT;
}
}
In a real org I'd route this through a trigger handler framework, but keeping it flat makes the tests easier to follow.
Build test data with a test data factory
Tests can't see your org's data by default (and you shouldn't turn SeeAllData=true on). Every test creates what it needs. If each test class builds its own Accounts by hand, the first new required field or validation rule breaks dozens of classes at once.
A test data factory puts record creation in one place:
@IsTest
public class TestDataFactory {
public static List<Account> createAccounts(Integer count, Boolean doInsert) {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < count; i++) {
accounts.add(new Account(
Name = 'Test Account ' + i,
Phone = '555-01' + String.valueOf(i).leftPad(3, '0'),
Rating = 'Cold'
));
}
if (doInsert) {
insert accounts;
}
return accounts;
}
public static User buildUser(String profileName) {
Profile profile = [SELECT Id FROM Profile WHERE Name = :profileName LIMIT 1];
String unique = String.valueOf(Crypto.getRandomInteger()).replace('-', '');
return new User(
ProfileId = profile.Id,
LastName = 'Tester',
Alias = ('t' + unique).left(8),
Email = 'tester' + unique + '@example.com',
Username = 'tester' + unique + '@example.com.test',
EmailEncodingKey = 'UTF-8',
LanguageLocaleKey = 'en_US',
LocaleSidKey = 'en_US',
TimeZoneSidKey = 'America/Los_Angeles'
);
}
}
Three design choices matter here:
- The
doInsertflag. Sometimes a test needs to tweak records before inserting them, or needs them unsaved to test a validation. Returning the list either way keeps the factory flexible. - One insert per call. The factory itself is bulkified, so asking for 200 records costs one DML statement.
@IsTeston the factory. It can only be called from tests and doesn't count toward your org's Apex code size limit.
Use @TestSetup for data every test needs
A method annotated @TestSetup runs once per test class. Its records are rolled back and restored before each test method, so every test starts from the same clean state even if the previous one updated or deleted that data. It's faster than inserting the same 200 Accounts in every method.
@IsTest
private class AccountServiceTest {
private static final Integer BULK_SIZE = 200;
@TestSetup
static void setup() {
TestDataFactory.createAccounts(BULK_SIZE, true);
}
// test methods below
}
Test methods then query what they need. Remember that variables set in @TestSetup don't carry over, only the records do.
Assert outcomes with the Assert class
The Assert class reads better than the older System.assertEquals and makes the argument order explicit. The ones I use most:
| Method | Use it to |
|---|---|
Assert.areEqual(expected, actual, message) | Check an exact value |
Assert.areNotEqual(notExpected, actual, message) | Check a value changed |
Assert.isTrue(condition, message) / Assert.isFalse(...) | Check a condition |
Assert.isNull(value, message) / Assert.isNotNull(...) | Check presence |
Assert.isInstanceOfType(value, Type, message) | Check the type of a result or exception |
Assert.fail(message) | Mark a line that should never be reached |
Always pass the message. When a test fails in a deployment at midnight, "Each Hot account should get exactly one task" tells you far more than "Expected 200, Actual 0".
Bulk tests: always run 200 records
Triggers receive records in chunks of up to 200, so a single-record test hides every SOQL-in-a-loop bug you've got. My default positive test uses the full @TestSetup volume:
@IsTest
static void createsOneTaskPerAccountThatBecomesHot() {
List<Account> accounts = [SELECT Id FROM Account];
for (Account acc : accounts) {
acc.Rating = 'Hot';
}
Test.startTest();
update accounts;
Test.stopTest();
Set<Id> accountIds = new Map<Id, Account>(accounts).keySet();
List<Task> tasks = [SELECT WhatId, Priority, ActivityDate FROM Task];
Assert.areEqual(BULK_SIZE, tasks.size(), 'Each Hot account should get exactly one task');
for (Task t : tasks) {
Assert.isTrue(accountIds.contains(t.WhatId), 'Task should be linked to an updated account');
Assert.areEqual('High', t.Priority, 'Follow-up tasks should be high priority');
Assert.areEqual(Date.today().addDays(2), t.ActivityDate, 'Task should be due in two days');
}
}
Test.startTest() gives the code between it and Test.stopTest() a fresh set of governor limits, so the setup queries don't eat into the budget of the code you're actually testing. If that update ever hits "Too many SOQL queries: 101", this test fails before production does. I walk through fixing those errors in Apex bulkification explained.
Negative tests: prove what should not happen
A negative test checks that the code does nothing when it shouldn't act. These catch the bugs that create duplicate tasks, send double emails or overwrite data.
@IsTest
static void ignoresRatingChangesThatAreNotHot() {
List<Account> accounts = [SELECT Id FROM Account];
for (Account acc : accounts) {
acc.Rating = 'Warm';
}
update accounts;
Assert.areEqual(0, [SELECT COUNT() FROM Task], 'Only a change to Hot should create a task');
}
@IsTest
static void doesNotCreateASecondTaskWhenAlreadyHot() {
Account acc = [SELECT Id FROM Account LIMIT 1];
acc.Rating = 'Hot';
update acc;
acc.Description = 'Edited again';
update acc;
Assert.areEqual(1, [SELECT COUNT() FROM Task WHERE WhatId = :acc.Id], 'Re-saving a Hot account should not add tasks');
}
The second test is the one people forget. It's the difference between "Rating is Hot" and "Rating became Hot", which is a real bug I see in code reviews all the time.
Testing exceptions the right way
To test that code throws, use the try, Assert.fail, catch pattern. Catch the specific exception type, never a bare Exception. Assert.fail throws an AssertException, and if you catch Exception you'll swallow your own failure and the test will pass for the wrong reason.
@IsTest
static void rejectsHotAccountWithoutPhone() {
Account acc = [SELECT Id FROM Account LIMIT 1];
acc.Phone = null;
acc.Rating = 'Hot';
try {
update acc;
Assert.fail('Expected a DmlException for a Hot account without a phone');
} catch (DmlException e) {
Assert.areEqual(StatusCode.FIELD_CUSTOM_VALIDATION_EXCEPTION, e.getDmlType(0), 'addError should surface as a validation error');
Assert.isTrue(e.getMessage().contains('needs a phone number'), 'Unexpected message: ' + e.getMessage());
}
}
For bulk failure paths, use partial success. Database.update(records, false) returns a result per record instead of rolling back the lot, which lets you prove that only the bad records failed:
@IsTest
static void bulkUpdateRejectsOnlyAccountsWithoutPhone() {
List<Account> accounts = [SELECT Id FROM Account];
for (Integer i = 0; i < accounts.size(); i++) {
accounts[i].Rating = 'Hot';
if (Math.mod(i, 2) == 0) {
accounts[i].Phone = null;
}
}
List<Database.SaveResult> results = Database.update(accounts, false);
Integer failures = 0;
for (Database.SaveResult result : results) {
if (!result.isSuccess()) {
failures++;
}
}
Assert.areEqual(BULK_SIZE / 2, failures, 'Every Hot account without a phone should fail');
Assert.areEqual(BULK_SIZE / 2, [SELECT COUNT() FROM Task], 'Only the valid accounts should get tasks');
}
System.runAs: test as a real user
By default, tests run as the user who started them, which is usually an admin. System.runAs switches the running user for a block of code. The detail most people miss is what it enforces:
- Record sharing: yes. Code in a
with sharingclass sees only the records therunAsuser can see. - Object and field permissions: only if your code checks them.
runAson its own doesn't enforce CRUD or field-level security. Code that usesWITH USER_MODE,insert as userorSecurity.stripInaccessibledoes, and insiderunAsit checks therunAsuser.
So the useful pattern is user-mode code plus a user built from a restricted profile. Here's a query service and a test proving that a user without Account access can't read it:
public with sharing class AccountQueryService {
public static List<Account> getHotAccounts() {
return [SELECT Id, Name, Phone FROM Account WHERE Rating = 'Hot' WITH USER_MODE];
}
}
@IsTest
private class AccountQueryServiceTest {
@IsTest
static void userWithoutAccountAccessIsBlocked() {
User restricted = TestDataFactory.buildUser('Minimum Access - Salesforce');
insert restricted;
System.runAs(restricted) {
try {
AccountQueryService.getHotAccounts();
Assert.fail('Expected a QueryException for a user without Account access');
} catch (QueryException e) {
Assert.isNotNull(e.getMessage(), 'User mode should block the query');
}
}
}
}
If your org doesn't have the Minimum Access - Salesforce profile, use any profile without Account read access. For the positive case, insert a PermissionSetAssignment for the user and assert that the query succeeds.
Two practical notes. Sharing tests depend on your org-wide defaults, which a test can't change, so a test that expects a Private OWD will fail in an org that's set to Public Read Only. And if you insert a User and then insert regular records in the same test, you can hit a mixed DML error. Wrapping the setup-object DML in System.runAs(new User(Id = UserInfo.getUserId())) avoids it.
Testing async Apex with Test.startTest and Test.stopTest
Queueable, future, batch and scheduled Apex don't run when you enqueue them in a test. They run when Test.stopTest() is called, which is why every async assertion goes after it.
Here's a Queueable that enriches Accounts from an external API. It sends every domain in one callout instead of one callout per record, for the same reason you don't query in a loop:
public with sharing class AccountEnrichmentJob implements Queueable, Database.AllowsCallouts {
private final Set<Id> accountIds;
public AccountEnrichmentJob(Set<Id> accountIds) {
this.accountIds = accountIds;
}
public void execute(QueueableContext context) {
List<Account> accounts = [
SELECT Id, Website FROM Account
WHERE Id IN :accountIds AND Website != null
WITH USER_MODE
];
Set<String> domains = new Set<String>();
for (Account acc : accounts) {
domains.add(acc.Website);
}
if (domains.isEmpty()) {
return;
}
Map<String, Integer> counts = CompanyDataClient.fetchEmployeeCounts(domains);
List<Account> toUpdate = new List<Account>();
for (Account acc : accounts) {
if (counts.containsKey(acc.Website)) {
toUpdate.add(new Account(Id = acc.Id, NumberOfEmployees = counts.get(acc.Website)));
}
}
update as user toUpdate;
}
}
public with sharing class CompanyDataClient {
public class CompanyDataException extends Exception {}
public static Map<String, Integer> fetchEmployeeCounts(Set<String> domains) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Company_Data/v1/employee-counts');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(new List<String>(domains)));
req.setTimeout(10000);
HttpResponse res = new Http().send(req);
if (res.getStatusCode() != 200) {
throw new CompanyDataException('Company Data API returned ' + res.getStatusCode());
}
return (Map<String, Integer>) JSON.deserialize(res.getBody(), Map<String, Integer>.class);
}
}
The endpoint uses a Named Credential called Company_Data, so no URL or secret lives in code.
Two async rules to remember. For Batch Apex, a test runs only one execute call, so keep the record count within the scope size you pass to Database.executeBatch. For platform events published from your code, Test.stopTest() delivers them, or you can call Test.getEventBus().deliver() to deliver them mid-test.
Mocking callouts with HttpCalloutMock
Tests can't make real HTTP callouts. You implement HttpCalloutMock and register it with Test.setMock, and every callout in that test gets your fake response. I make the mock configurable so one class covers the success and failure paths:
@IsTest
public class CompanyDataMock implements HttpCalloutMock {
private final Integer statusCode;
private final String body;
public CompanyDataMock(Integer statusCode, String body) {
this.statusCode = statusCode;
this.body = body;
}
public HttpResponse respond(HttpRequest req) {
Assert.areEqual('POST', req.getMethod(), 'Client should POST the domain list');
HttpResponse res = new HttpResponse();
res.setStatusCode(statusCode);
res.setHeader('Content-Type', 'application/json');
res.setBody(body);
return res;
}
}
Asserting on the request inside the mock is underrated. It catches a client that silently sends the wrong method, headers or payload.
Now the async test. The DML happens before Test.startTest() and Test.setMock comes after it. That order is what lets a test call out after inserting data without the "You have uncommitted work pending" error:
@IsTest
private class AccountEnrichmentJobTest {
@IsTest
static void copiesEmployeeCountFromApi() {
Account acc = TestDataFactory.createAccounts(1, false)[0];
acc.Website = 'acme.example';
insert acc;
Test.startTest();
Test.setMock(HttpCalloutMock.class, new CompanyDataMock(200, '{"acme.example": 1200}'));
System.enqueueJob(new AccountEnrichmentJob(new Set<Id>{ acc.Id }));
Test.stopTest();
Account result = [SELECT NumberOfEmployees FROM Account WHERE Id = :acc.Id];
Assert.areEqual(1200, result.NumberOfEmployees, 'Employee count should come from the API');
}
}
And the failure path, tested directly against the client:
@IsTest
private class CompanyDataClientTest {
@IsTest
static void throwsOnServerError() {
Test.setMock(HttpCalloutMock.class, new CompanyDataMock(500, '{"error": "unavailable"}'));
try {
CompanyDataClient.fetchEmployeeCounts(new Set<String>{ 'acme.example' });
Assert.fail('Expected a CompanyDataException on HTTP 500');
} catch (CompanyDataClient.CompanyDataException e) {
Assert.isTrue(e.getMessage().contains('500'), 'Message should include the status code');
}
}
}
For more than one endpoint in a transaction, make the mock look at req.getEndpoint() and return a different response for each. For static JSON files, StaticResourceCalloutMock and MultiStaticResourceCalloutMock save you from writing a class.
A checklist for every test class
Before I approve a pull request, the test class has to answer yes to these:
- Does it create its own data, through a factory, without
SeeAllData=true? - Does at least one test run 200 records through the trigger or service?
- Does every test method assert something specific, with a message?
- Is there a negative test for each "only when" rule?
- Is every
throwandaddErrorpath tested with the specific exception type? - If the code uses user mode, is there a
runAstest with a restricted user? - Is every callout mocked, including at least one error status?
- Are async assertions placed after
Test.stopTest()?
If you can say yes to all of them, coverage takes care of itself. In my experience, classes that pass this checklist sit well above 75% without anyone trying.
Where to go from here
The Apex testing guide maps the whole topic, and the Apex interview questions include the testing questions interviewers actually ask.
If you'd rather watch it built, my Apex Test Fest course covers exceptions, async coverage, runAs, the Assert class, REST callout tests, REST API class tests and SOAP tests. I write every test live in a real org, run it, watch it fail and fix it, with no slides and no pasted code. The Apex Test Framework module in Salesforce Development (Visualforce & Apex) covers the fundamentals if you're earlier in the journey.
Apex Test Fest
40 episodes · 5 hours · 9 modules
Salesforce Development (Visualforce & Apex)
47 episodes · 5.6 hours · 8 modules
Learn it by building it
Every course, every code repository, the discussion group and certificates of completion — one subscription.
Frequently asked questions
Is 75% code coverage enough to deploy Apex to production?
It's enough to pass the deployment check, which also requires every test to pass and every trigger to have some coverage. It isn't enough to trust the code. Aim to cover every branch that matters and to assert the result of each one.
What is the difference between @TestSetup and a test data factory?
A test data factory is a reusable class that builds records for any test class. @TestSetup is a method inside one test class that runs once and inserts data that every test method in that class starts with. Most good test classes use the factory inside the @TestSetup method.
Should I use System.assertEquals or the Assert class?
Use the Assert class in new code. Methods such as Assert.areEqual, Assert.isTrue and Assert.fail read clearly and make the expected-versus-actual order obvious. The old System.assert methods still work, so there's no need to rewrite passing tests just to switch.
Why does my test fail with 'You have uncommitted work pending' when I mock a callout?
Apex doesn't allow a callout after DML in the same transaction. In a test, do the DML first, then call Test.startTest, then Test.setMock, then run the code that makes the callout, and finish with Test.stopTest.
How do I test a batch class with more than 200 records?
In a test, only one execute call of a batch job runs, so insert no more records than the scope size you pass to Database.executeBatch. Test the per-record logic in a separate method if you need larger volumes.
Keep going
Topic guide
Learn Apex Testing: Write Test Classes That Prove Your Code Works
Interview questions
Apex Interview Questions and Answers (2026)
Interview questions
Async Apex Interview Questions and Answers (2026)
Blog
Apex Bulkification Explained: From 'Too Many SOQL Queries: 101' to Bulk-Safe Code
Career roadmap
Salesforce Developer Roadmap 2026
Blog
How to Crack a Salesforce Developer Interview: A 30-Day Plan