Skip to content
January 20, 2026·Updated ·13 min read

Apex Test Classes: 85%+ Coverage Without Cheating

Write meaningful Apex tests that actually catch bugs — TestDataFactory, async testing, callout mocks, and the anti-patterns that inflate coverage without value.

← Back

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: @TestSetup data 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 @TestSetup or TestDataFactory.
  • 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

PracticeWhy it matters
TestDataFactorySingle source of truth for test data
@TestSetupAvoids repeated DML, cleaner test methods
Test.startTest/stopTestForces async execution, resets limits
HttpCalloutMockTests callout logic without external calls
Meaningful assertionsActually catches regressions
Bulk test (200 records)Verifies bulkification

Resources

Aïcha Imène DAHOUMANE
About the author
Aïcha Imène DAHOUMANE
Salesforce Administrator & Developer

Salesforce developer with a background in IT infrastructure — building reliable, maintainable solutions with Apex, Flows, LWC, and CI/CD.

ShareShare on LinkedIn