Salesforce requires 75% code coverage to deploy. But there's a difference between coverage and tests that catch bugs. Most Apex projects have plenty of the first and very little of the second. Here's how to write tests that actually matter.
Prerequisites
- Basic Apex knowledge
- Understanding of Apex trigger/class structure
- Familiarity with Salesforce DML operations
The TestDataFactory Pattern
Centralise test data creation in a TestDataFactory utility class instead of repeating it in every method — including data sets specific to one test, such as the 200 records of a bulk test:
@isTest
public class TestDataFactory {
public static Account createAccount(String name, Boolean doInsert) {
Account acc = new Account(
Name = name,
Phone = '0123456789',
BillingCountry = 'France'
);
if (doInsert) insert acc;
return acc;
}
public static List<Account> createAccounts(String namePrefix, Integer count, Boolean doInsert) {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < count; i++) {
accounts.add(createAccount(namePrefix + ' ' + i, false));
}
if (doInsert) insert accounts;
return accounts;
}
public static List<Contact> createContacts(Id accountId, Integer count, Boolean doInsert) {
List<Contact> contacts = new List<Contact>();
for (Integer i = 0; i < count; i++) {
contacts.add(new Contact(
FirstName = 'Test',
LastName = 'Contact ' + i,
AccountId = accountId,
Email = 'test' + i + '@example.com'
));
}
if (doInsert) insert contacts;
return contacts;
}
}Benefits: One place to update when validation rules change, reusable across all test classes, forces you to think about required fields.
@TestSetup — Shared Setup Without Overhead
Use @TestSetup to create data once per class instead of repeating setup in every method:
@isTest
private class AccountServiceTest {
@TestSetup
static void setup() {
Account acc = TestDataFactory.createAccount('Test Corp', true);
TestDataFactory.createContacts(acc.Id, 5, true);
}
@isTest
static void testGetActiveContacts() {
Account acc = [SELECT Id FROM Account LIMIT 1];
Test.startTest();
List<Contact> result = AccountService.getActiveContacts(acc.Id);
Test.stopTest();
System.assertEquals(5, result.size(), 'Should return 5 contacts');
}
@isTest
static void testDeactivateContacts() {
Account acc = [SELECT Id FROM Account LIMIT 1];
Test.startTest();
AccountService.deactivateContacts(acc.Id);
Test.stopTest();
List<Contact> active = [SELECT Id FROM Contact WHERE AccountId = :acc.Id AND IsActive__c = true];
System.assertEquals(0, active.size(), 'All contacts should be deactivated');
}
}Important:
@TestSetupdata is created only once per class. Each test method starts from its initial state: the DML changes one method makes to it are rolled back before the next one.
Testing Async Code with Test.startTest() / stopTest()
Test.startTest() resets governor limits and forces async execution (future methods, queueable, batch) to run synchronously when Test.stopTest() is called:
@isTest
static void testFutureMethod() {
Account acc = TestDataFactory.createAccount('Async Test', true);
Test.startTest();
AccountService.recalculateMetricsAsync(acc.Id); // @future method
Test.stopTest(); // Forces async execution here
// Now query results — the @future has run
Account updated = [SELECT Metric_Score__c FROM Account WHERE Id = :acc.Id];
System.assertNotEquals(null, updated.Metric_Score__c, 'Score should be calculated');
}Mocking HTTP Callouts
Tests that make real HTTP callouts will fail. Use HttpCalloutMock:
// Mock class
@isTest
global class SalesforceApiMock implements HttpCalloutMock {
global HTTPResponse respond(HTTPRequest req) {
HTTPResponse res = new HTTPResponse();
res.setHeader('Content-Type', 'application/json');
res.setBody('{"id": "12345", "status": "success"}');
res.setStatusCode(200);
return res;
}
}
// Test method
@isTest
static void testCalloutSuccess() {
Test.setMock(HttpCalloutMock.class, new SalesforceApiMock());
Test.startTest();
String result = ExternalApiService.createRecord('Test Data');
Test.stopTest();
System.assertEquals('12345', result, 'Should return the created ID');
}For error scenarios, create a second mock that returns 4xx/5xx and test your error handling path too.
Writing Meaningful Assertions
Bad assertions inflate coverage without catching bugs:
// ❌ Always passes
System.assert(true);
// ❌ Too weak — only checks that a result exists, not what it contains
System.assertNotEquals(null, result);
// ✅ Meaningful — will catch regressions
System.assertEquals(3, result.size(),
'Expected 3 records after filtering by active status');
System.assertEquals('ESCALATED', ticket.Status__c,
'Ticket should be escalated when SLA is breached');
System.assert(result.contains(expectedId),
'Result set must include the account we just created');Always use the third parameter (message) — it tells you what failed when the assertion breaks.
Since Winter '23, the Assert class (Assert.areEqual, Assert.isTrue, Assert.fail…) is the recommended API for new tests; System.assertEquals still works, with the same parameter order (expected, actual, message).
Anti-Patterns to Avoid
SeeAllData=true: Exposes your tests to real org data, making them environment-dependent and unreliable. Avoid it by default, and keep it for the rare cases where the data you need can't be created in a test. You don't need it for the standard price book:Test.getStandardPricebookId()works without it.- Inserting in every test method: Duplicates code and slows runs (each test method gets its own limits, but rebuilds all its data). Use
@TestSetuporTestDataFactory. - Testing only the happy path: Write at least one negative test — what happens with null input, missing fields, or invalid data?
- Ignoring bulk tests: Add a test with 200 records to verify your code is properly bulkified.
@isTest
static void testBulk200Records() {
// Volume specific to this test, created through the factory
List<Account> accounts = TestDataFactory.createAccounts('Bulk', 200, true);
Test.startTest();
AccountService.processAll(accounts);
Test.stopTest();
System.assertEquals(200, [SELECT COUNT() FROM Account WHERE Status__c = 'Processed'],
'All 200 accounts should be processed in a single transaction');
}Summary
| Practice | Why it matters |
|---|---|
TestDataFactory | Single source of truth for test data |
@TestSetup | Avoids repeated DML, cleaner test methods |
Test.startTest/stopTest | Forces async execution, resets limits |
HttpCalloutMock | Tests callout logic without external calls |
| Meaningful assertions | Actually catches regressions |
| Bulk test (200 records) | Verifies bulkification |