Apex Test Patterns: How to Write Tests That Actually Catch Bugs
Apex code coverage is one of the most measured, least useful metrics on the platform. A class can have 95% coverage and zero tests that would catch a real bug. I've inherited orgs where every class met the 75% threshold and the test suite was effectively useless — the tests asserted that code ran, not that it did the right thing.
The fix isn't more tests. It's better tests. A small number of tests that actually validate behaviour will save you more debugging time than 50 tests that touch every line and assert nothing. Here's the patterns I use, the antipatterns I refactor away, and the specific things I check when I'm reviewing test code.
Coverage Lies; Behaviour Tells the Truth
The default thing most developers write looks like this:
@IsTest
static void testDoSomething() {
Account a = new Account(Name = 'Test');
insert a;
Test.startTest();
AccountService.doSomething(a.Id);
Test.stopTest();
System.assertNotEquals(null, a.Id);
}
This passes. It hits the lines in doSomething. It asserts something — that an inserted record has an id, which is always true. It tells you nothing about whether doSomething did the right thing.
The version that actually tests behaviour:
@IsTest
static void testDoSomething_setsAccountTypeToPremium() {
Account a = TestDataFactory.createAccount(new Map<String, Object>{
'AnnualRevenue' => 5000000
});
Test.startTest();
AccountService.doSomething(a.Id);
Test.stopTest();
Account updated = [SELECT Type FROM Account WHERE Id = :a.Id];
System.assertEquals('Premium', updated.Type, 'High-revenue account should be tagged Premium');
}
The difference is what's asserted. The first version asserts the test compiled. The second asserts the actual contract — that doSomething updates Account.Type to 'Premium' for high-revenue accounts. If a future change breaks that behaviour, the test fails. The first version would still pass.
Every test I write asks: if this method's contract changes silently, does this test catch it? If the answer is no, the test isn't earning its keep.
Test Data Factories Are Non-Negotiable
The most common smell I see: test methods that build data inline, with hardcoded values, copy-pasted across 30 test classes. When the validation rule changes and Account now requires a new field, all 30 classes have to be updated.
The fix is a test data factory — a single class that constructs valid records with sensible defaults:
@IsTest
public class TestDataFactory {
public static Account createAccount(Map<String, Object> overrides) {
Account a = new Account(
Name = 'Test Account',
BillingCountry = 'Australia',
AnnualRevenue = 100000
);
applyOverrides(a, overrides);
insert a;
return a;
}
public static Account createAccount() {
return createAccount(new Map<String, Object>());
}
public static Contact createContact(Id accountId, Map<String, Object> overrides) {
Contact c = new Contact(
FirstName = 'Test',
LastName = 'Contact',
AccountId = accountId,
Email = 'test@example.com'
);
applyOverrides(c, overrides);
insert c;
return c;
}
private static void applyOverrides(SObject record, Map<String, Object> overrides) {
for (String fieldName : overrides.keySet()) {
record.put(fieldName, overrides.get(fieldName));
}
}
}
The override pattern is what makes this practical. A test that needs an Account with specific revenue doesn't have to redefine all the fields — just override the one that matters:
Account a = TestDataFactory.createAccount(new Map<String, Object>{
'AnnualRevenue' => 5000000,
'Type' => 'Customer'
});
When the org adds a new required field on Account, you update the factory once. All tests adapt automatically.
The factory should not include record types, ownership, or anything else that varies meaningfully across test scenarios — those go in the override map. The factory's job is to produce a valid record by default; the test's job is to make it interesting.
Bulk Cases Are Where Real Bugs Live
The pattern most tests skip:
@IsTest
static void testTrigger_bulk() {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 200; i++) {
accounts.add(new Account(Name = 'Test ' + i, AnnualRevenue = 5000000));
}
Test.startTest();
insert accounts;
Test.stopTest();
Integer queriesUsed = Limits.getQueries();
System.assert(queriesUsed < 50, 'Trigger used too many SOQL queries: ' + queriesUsed);
List<Account> updated = [SELECT Type FROM Account WHERE Id IN :accounts];
for (Account a : updated) {
System.assertEquals('Premium', a.Type);
}
}
This is the test that catches SOQL queries inside loops, DML inside loops, and the dozen other governor-limit violations that hide in single-record tests.
The bulk test is mandatory for any handler, service, or trigger code that runs on data the user could conceivably bulk-insert. 200 records is the default I use because it matches the default chunk size for Bulk API operations. If your code processes data from integrations that can send larger batches, test with the realistic upper bound.
The governor limit assertion is the part most teams forget. Limits.getQueries() shows the SOQL count at the assertion point. If it's higher than expected, that's a bug — even if the test passes functionally, it'll fail in production when the data scales.
Negative Cases Earn Their Keep
For every positive test that asserts the happy path, write at least one negative test that asserts the error case:
@IsTest
static void testDoSomething_throwsForInvalidAccount() {
Account a = TestDataFactory.createAccount(new Map<String, Object>{
'AnnualRevenue' => null // missing required field for the contract
});
Test.startTest();
try {
AccountService.doSomething(a.Id);
System.assert(false, 'Expected exception was not thrown');
} catch (AccountServiceException e) {
System.assertEquals('AnnualRevenue is required', e.getMessage());
}
Test.stopTest();
}
Notice the assertion isn't just "an exception was thrown." It's "this specific exception with this specific message was thrown." If the code changes to throw a different exception type or change the error message, the test fails — and that's what you want, because the contract has changed.
System.assert(false, 'Expected exception was not thrown') is the trick. If execution reaches that line (no exception was thrown), the test fails with a clear message. Without that line, an unthrown exception silently passes the test, which is a footgun.
Tests That Run In Real Context
Tests that mock everything are tests of the mocks. The most useful tests run in genuine Salesforce runtime context:
- Real DML — actually insert/update records. The platform's validation rules, triggers, and flows run during the test, which is what you want.
- Real SOQL — actually query the inserted records. Catches FLS issues, sharing rule problems, and field availability bugs.
- Real automation — let your triggers and flows fire. If the test path bypasses them, you're not testing what production sees.
The exception is callouts and integrations. Those are mocked because the tests need to be deterministic. Use Test.setMock(HttpCalloutMock.class, ...) for callouts. The mocked response should match the real API's actual response shape, including edge cases (404, timeout, malformed JSON).
I avoid mock libraries that abstract too much (Apex Mocks, fflib's stub provider) for the simple cases. They're powerful but add a layer of abstraction that obscures what's actually being tested. The platform's built-in Test.setMock handles 90% of cases cleanly.
The Anti-Patterns I Refactor Out
A few patterns I find regularly that need replacing:
@TestVisible to test private methods. Don't test private methods directly. Test the public method that calls them. If a private method is doing too much to test through the public API, it should be its own class.
SeeAllData=true. Almost never the right answer. The exceptions are tests for things that genuinely can't be created in tests (Knowledge articles in some configurations, Reports). For everything else, build the data in the test.
Blanket assertions that all records were processed. "200 records inserted, 200 records have a value, test passes." That's coverage, not validation. Assert on the transformation — what changed, what was added, what was rejected.
Testing the framework rather than the logic. Lots of trigger handler tests assert "the trigger fired." That's testing that Apex triggers work, not that your handler did the right thing. Skip the meta-tests.
Dependence on record IDs. Tests that hardcode record IDs (or worse, depend on a specific user being in the org) break when the test runs in a different sandbox. Build everything in the test.
What Coverage Reports Don't Tell You
Coverage tells you which lines ran. It doesn't tell you:
- Whether the lines that ran did the right thing
- Whether the edge cases were covered
- Whether the assertions actually validate behaviour
- Whether the test would fail if the contract changed
A team I worked with got 92% coverage on a critical service class and considered it tested. A bug shipped because the bulk case was 50% covered (a single Account in tests, never 200). Coverage was a lie.
The metric I care about more: can I refactor this class with confidence the tests will catch breakage? If the answer is yes, the tests are doing their job. If the answer is no, it doesn't matter what the percentage says.
The Test I Always Write First
When I'm writing a new class, I write one test before the implementation:
@IsTest
static void testTheContract() {
// Set up the input that exercises the main use case
// Call the public method
// Assert the expected outcome
System.assert(false, 'Implement the test then the method');
}
It fails immediately. The failing test forces me to think about what the method does from the caller's perspective before I think about how to implement it. Once the test compiles and articulates the contract, I write the implementation. The test passes. Then I add edge cases, bulk cases, and negative cases.
This is TDD-lite. I don't follow it dogmatically. But starting with one failing test that asserts the contract has saved me more refactoring work than any other practice on the platform.
If you're rolling out a trigger framework or restructuring a service layer, the test patterns are what give you confidence to actually ship the change — and the review work I do for clients almost always finds test suites where coverage is high and behaviour validation is low.
The shortest summary: tests that pass coverage and catch nothing are worse than no tests, because they create false confidence. Test the contract, test the bulk case, test the failure case. That's the bar.
Liked this?
New essays in your inbox when I publish. No cadence, no spam.
You might also like
SOQL Patterns That Won't Kill Your Governor Limits
The SOQL antipatterns I keep finding in code review, the patterns that replace them, and the trade-offs nobody explains — from years of production Salesforce.
Read →Async Apex Patterns: Queueable vs Batch vs Future — The Decision Tree
Choosing between Queueable, Batch Apex, and Future methods is the asynchronous decision most Salesforce devs get wrong. Here's the framework I use, and the cases where the obvious choice is wrong.
Read →Error Handling in Apex: Beyond Try-Catch
Try-catch is where Apex error handling starts, not where it ends. Custom exceptions, persisted logs, partial-success DML, and errors that survive a rollback.
Read →