ApexSensei

Blog

How to Write Apex Test Classes That Actually Pass (And Maintain 85%+ Code Coverage)

By Firus Hanov ยท ยท 9 min read

Learn how to write meaningful Apex test classes with the Arrange-Act-Assert pattern, Test Data Factory, bulk testing, negative scenarios, and callout mocking.

How to Write Apex Test Classes That Actually Pass (And Maintain 85%+ Code Coverage)

Salesforce requires 75% code coverage to deploy to production. That's the minimum. But the real standard you should aim for is 85%+ โ€” and more importantly, tests that actually mean something.

Here's the dirty secret of Salesforce development: a lot of developers write test classes that hit 75% coverage but test absolutely nothing. They insert records, call methods, and assert... nothing. They get the green checkmark at deployment but their code is no more reliable than before.

This guide teaches you how to write Apex tests that actually catch bugs โ€” before production does. ๐Ÿ”ฅ


Why Apex Tests Matter (Beyond Deployment)

Yes, Salesforce requires 75% code coverage to deploy. But tests serve a deeper purpose:

  • Regression protection: When someone else changes your code six months from now, tests catch what broke
  • Documentation: Good tests show exactly how a method is supposed to behave
  • Refactoring confidence: You can restructure code aggressively when tests have your back
  • Bug finding: Tests often catch bugs during development โ€” before they ever reach testing or production

A test class that just achieves coverage without assertions is like a smoke detector with dead batteries. It looks fine. It does nothing.


The Anatomy of a Good Test Class

@IsTest
private class AccountServiceTest {

    @TestSetup
    static void setup() {
        Account acc = new Account(Name = 'Test Corp', Type = 'Prospect');
        insert acc;

        Contact c = new Contact(
            FirstName = 'Jane',
            LastName = 'Test',
            AccountId = acc.Id,
            Email = '[email protected]'
        );
        insert c;
    }

    @IsTest
    static void testSetDefaults_whenCountryBlank_setsUnitedStates() {
        Account acc = new Account(Name = 'No Country Corp');

        Test.startTest();
        AccountService.setDefaults(new List<Account>{ acc });
        Test.stopTest();

        System.assertEquals(
            'United States',
            acc.BillingCountry,
            'Default country should be set to United States when blank'
        );
    }

    @IsTest
    static void testSetDefaults_whenCountrySet_doesNotOverride() {
        Account acc = new Account(Name = 'Canadian Corp', BillingCountry = 'Canada');

        Test.startTest();
        AccountService.setDefaults(new List<Account>{ acc });
        Test.stopTest();

        System.assertEquals(
            'Canada',
            acc.BillingCountry,
            'Existing country should not be overwritten'
        );
    }
}

The structure every test method should follow:

  1. Arrange โ€” set up your test data
  2. Act โ€” call the method being tested
  3. Assert โ€” verify the outcome with a meaningful message

Build a Test Data Factory

Copy-pasting record creation across 20 test classes is a maintenance nightmare. When your required field changes, you update 20 files. Instead, build a centralized Test Data Factory:

@IsTest
public class TestDataFactory {

    public static Account createAccount(String name, Boolean doInsert) {
        Account acc = new Account(
            Name = name,
            Type = 'Prospect',
            BillingCountry = 'United States',
            Industry = 'Technology'
        );
        if (doInsert) insert acc;
        return acc;
    }

    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,
                Type = 'Prospect',
                BillingCountry = 'United States'
            ));
        }
        if (doInsert) insert accounts;
        return accounts;
    }

    public static Contact createContact(Id accountId, Boolean doInsert) {
        Contact c = new Contact(
            FirstName = 'Test',
            LastName = 'Contact',
            AccountId = accountId,
            Email = '[email protected]',
            Phone = '555-1234'
        );
        if (doInsert) insert c;
        return c;
    }

    // Builder pattern for complex scenarios
    public static AccountBuilder anAccount() {
        return new AccountBuilder();
    }

    public class AccountBuilder {
        private Account acc = new Account(
            Name = 'Default Test Account',
            Type = 'Prospect',
            BillingCountry = 'United States'
        );

        public AccountBuilder withName(String name) {
            acc.Name = name;
            return this;
        }

        public AccountBuilder asCustomer() {
            acc.Type = 'Customer';
            return this;
        }

        public AccountBuilder withRevenue(Decimal revenue) {
            acc.AnnualRevenue = revenue;
            return this;
        }

        public Account build() {
            return acc;
        }

        public Account buildAndInsert() {
            insert acc;
            return acc;
        }
    }
}

Usage is clean and readable:

// Simple
Account acc = TestDataFactory.createAccount('Acme', true);

// Builder pattern for complex setup
Account richCustomer = TestDataFactory.anAccount()
    .withName('BigCorp')
    .asCustomer()
    .withRevenue(5000000)
    .buildAndInsert();

Always Test Bulk Scenarios

This is the rule most developers forget. If your code runs in a trigger, it must handle 200 records.

@IsTest
static void testAfterInsert_bulkCreatesTasks_200Records() {
    List<Account> accounts = TestDataFactory.createAccounts(200, true);

    Test.startTest();
    AccountService.createWelcomeTasks(accounts);
    Test.stopTest();

    Integer taskCount = [SELECT COUNT() FROM Task WHERE Subject LIKE 'Welcome%'];
    System.assertEquals(200, taskCount, 'One task should be created for each of 200 accounts');
}

Bulk tests catch:

  • SOQL queries in loops (hits 101 limit with 200 records)
  • DML in loops (hits 151 limit with 200 records)
  • CPU time issues with expensive algorithms

Test Negative Scenarios

Testing that your code works is half the job. Testing that it fails correctly is the other half.

@IsTest
static void testValidateNewAccounts_throwsError_whenNameBlank() {
    Account acc = new Account(Name = '');

    try {
        Test.startTest();
        insert acc;
        Test.stopTest();
        System.assert(false, 'Expected a DmlException to be thrown');
    } catch (DmlException e) {
        System.assert(
            e.getMessage().contains('Name'),
            'Error should reference the Name field'
        );
    }
}

Using Test.startTest() and Test.stopTest()

Test.startTest() and Test.stopTest() serve two important purposes:

  1. Resets governor limits: Code after Test.startTest() gets fresh limits
  2. Runs async code: Any @Future, Queueable, or Batch code executes synchronously when Test.stopTest() is called
@IsTest
static void testBatchJob_processesAllAccounts() {
    List<Account> accounts = TestDataFactory.createAccounts(50, true);

    Test.startTest();
    Database.executeBatch(new AccountBatchJob(), 50);
    Test.stopTest();

    List<Account> processed = [SELECT Status__c FROM Account WHERE Id IN :accounts];
    for (Account acc : processed) {
        System.assertEquals('Processed', acc.Status__c, 'All accounts should be processed');
    }
}

Mocking External Callouts

Tests that make real HTTP callouts will fail. You must mock them:

public class MockHttpResponse implements HttpCalloutMock {
    private Integer statusCode;
    private String body;

    public MockHttpResponse(Integer statusCode, String body) {
        this.statusCode = statusCode;
        this.body = body;
    }

    public HTTPResponse respond(HTTPRequest request) {
        HttpResponse response = new HttpResponse();
        response.setStatusCode(this.statusCode);
        response.setBody(this.body);
        return response;
    }
}

@IsTest
static void testExternalSync_successfulCallout() {
    Test.setMock(HttpCalloutMock.class, new MockHttpResponse(200, '{"success": true}'));

    Test.startTest();
    ExternalAPIService.syncAccount('0012300000AbCdEFAA');
    Test.stopTest();

    Integration_Log__c log = [
        SELECT Status__c FROM Integration_Log__c
        ORDER BY CreatedDate DESC LIMIT 1
    ];
    System.assertEquals('Success', log.Status__c);
}

The 5 Laws of Apex Testing

Law 1: Every test method must have at least one System.assert or System.assertEquals. Coverage without assertions is meaningless.

Law 2: Test method names must describe what they test. testMethod1() tells you nothing. testCreateTask_whenOpportunityClosed_assignsToOwner() tells you everything.

Law 3: Always test bulk scenarios with 200 records. 1 record passes. 200 records proves your code is production-ready.

Law 4: Always test negative/error scenarios. If your method has error handling, test that the error actually fires.

Law 5: Use @TestSetup for shared data, not individual setup in each method. It runs once and is shared across all methods, saving DML and time.


What 85%+ Coverage Actually Means

Getting to 85%+ coverage is about testing every execution path, not just the happy path:

  • โœ… Happy path (everything works as expected)
  • โœ… Empty input (empty list, null values)
  • โœ… Boundary conditions (max records, zero records)
  • โœ… Error paths (exceptions, validation failures, DML errors)
  • โœ… Bulk scenarios (200 records)
  • โœ… Different record states (different field combinations)

When you cover all these paths, 85%+ coverage happens naturally โ€” and more importantly, your code is actually protected.


Summary

Writing good Apex tests is a skill. Here's the quick checklist:

  • โœ… @IsTest annotation on the class
  • โœ… private class modifier
  • โœ… @TestSetup for shared data creation
  • โœ… Arrange โ†’ Act โ†’ Assert pattern in each method
  • โœ… Descriptive method names
  • โœ… System.assertEquals with failure messages
  • โœ… Bulk test with 200 records
  • โœ… Negative/error scenario tests
  • โœ… Test.startTest() and Test.stopTest() wrapping the action
  • โœ… HttpCalloutMock for external callout tests
  • โœ… Centralized TestDataFactory

Want your test classes reviewed? Book a code review with ApexSensei and I'll assess your coverage, identify gaps, and help you write tests that actually protect your org. Start practicing on ApexSensei โ†’

What's the worst test class you've inherited? Tell me in the comments โ€” I guarantee I've seen worse. ๐Ÿ’ฌ

Practice Apex free on ApexSensei