Writing Reliable Apex Tests

Tests that create their own data, prove behaviour in bulk and mock every callout. Patterns, code and a fix table for the usual causes of flaky Apex tests.

Type
Article
Topic
CRM & Engineering
Reading time
6 min
Updated
Author
GeekMidst Team
Reviewed by
Site owner
Banner for the GeekMidst article “Writing Reliable Apex Tests”, covering What a reliable test actually does and Before you start

A test class that passed last month fails in today's deployment, and nobody changed the code it covers. Usually an admin added a validation rule, a Flow started firing on insert, or the test quietly depended on data that only existed in one sandbox. Writing reliable Apex tests means writing tests that create their own world, prove behaviour rather than chase coverage, and fail only when the code is genuinely wrong.

Checklist of seven checks for reliable Apex tests: bulk tests, specific assertions, no SeeAllData, mocked callouts, async asserted after stopTest, runAs for sharing and a full sandbox run
Reliable tests create their own data, prove behaviour in bulk and on failure paths, and fail only when the code is genuinely wrong. View full size

What a reliable test actually does

Salesforce requires at least 75% overall Apex code coverage to deploy to production, and every trigger needs some coverage. That number is a gate, not a goal. A test that executes every line but asserts nothing will pass while the logic is broken. A reliable test does four things:

  • Arranges its own data. It never relies on records that happen to exist in the org. Tests run with SeeAllData=false by default, and you should keep it that way.
  • Acts on a realistic volume. Triggers receive records in chunks of up to 200, so at least one test per trigger path should insert or update records in bulk.
  • Asserts outcomes. It checks field values, record counts, thrown exceptions and side effects, with a message on each assertion that explains the expectation.
  • Is isolated and deterministic. It does not depend on the order tests run in, the current time or the running user's profile unless it controls them.

Before you start

The examples assume a custom text field Tier__c on Account and a service class AccountTierService, called from a before-insert trigger, that sets Tier__c to Gold when AnnualRevenue is at least 1,000,000. They use the Assert class available in current API versions; older code uses System.assertEquals, which still works. Treat the code as a pattern to adapt, not something to paste into production.

Start with a test data factory

A factory class keeps record creation in one place. When an admin adds a required field or a validation rule, you update the factory once instead of fixing forty test classes. Let callers ask for records without inserting them, so they can adjust fields first.

apex
@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));
        }
        if (doInsert) {
            insert accounts;
        }
        return accounts;
    }
}
A minimal test data factory. Add required fields here once, not in every test class.

Arrange once with @TestSetup, then assert behaviour

A method annotated with @TestSetup runs once per test class. Each test method then sees its own copy of that data, and changes made by one method are rolled back before the next. Setup gets faster without coupling test methods to each other.

The class below proves the bulk path with 200 records and a negative path where no tier should be set.

apex
@isTest
private class AccountTierServiceTest {

    @TestSetup
    static void makeData() {
        List<Account> accounts = TestDataFactory.createAccounts(200, false);
        for (Account a : accounts) {
            a.AnnualRevenue = 2000000;
        }
        insert accounts;
    }

    @isTest
    static void assignsGoldTierInBulk() {
        List<Account> accounts = [SELECT Id, Tier__c FROM Account];
        Assert.areEqual(200, accounts.size(), 'Setup should create 200 accounts');
        for (Account a : accounts) {
            Assert.areEqual('Gold', a.Tier__c, 'High revenue accounts should be Gold');
        }
    }

    @isTest
    static void leavesTierBlankWithoutRevenue() {
        Account a = TestDataFactory.createAccounts(1, false)[0];
        Test.startTest();
        insert a;
        Test.stopTest();
        a = [SELECT Tier__c FROM Account WHERE Id = :a.Id];
        Assert.isNull(a.Tier__c, 'Accounts without revenue should have no tier');
    }
}
Bulk and negative tests for the sample trigger logic. Field and class names are illustrative.

Test.startTest() and Test.stopTest() give the code under test a fresh set of governor limits, so setup DML does not eat into the limits you are trying to verify. Asynchronous work queued between them, such as future methods, queueable jobs and batch jobs, runs when stopTest() is called, so you can assert the results of async code straight afterwards.

Users, permissions and mixed DML

Code often behaves differently for different users. Use System.runAs() with a user created in the test to check sharing-dependent logic. Note that runAs changes record sharing for the block; it does not by itself enforce object or field permissions on code running in system mode. Code that explicitly runs in user mode, for example queries using WITH USER_MODE, will respect the run-as user's permissions.

System.runAs() is also the usual way around mixed DML errors. If a test needs to insert a User or assign a permission set and also insert business records, do the setup-object DML inside a runAs block.

Mock every callout

Tests cannot make real HTTP callouts. Implement HttpCalloutMock (or WebServiceMock for generated SOAP classes) and register it with Test.setMock(). Write one mock per scenario you care about: success, an error status, a malformed body. Failure paths are where integration code usually breaks. If the test also needs DML, do it before Test.startTest() and make the callout inside the start and stop block.

apex
@isTest
private class InvoiceClientTest {

    private class PaidInvoiceMock implements HttpCalloutMock {
        public HttpResponse respond(HttpRequest req) {
            HttpResponse res = new HttpResponse();
            res.setStatusCode(200);
            res.setHeader('Content-Type', 'application/json');
            res.setBody('{"invoiceId":"INV-001","status":"PAID"}');
            return res;
        }
    }

    @isTest
    static void parsesPaidInvoice() {
        Test.setMock(HttpCalloutMock.class, new PaidInvoiceMock());
        Test.startTest();
        String status = InvoiceClient.getStatus('INV-001');
        Test.stopTest();
        Assert.areEqual('PAID', status, 'Status should be read from the response body');
    }
}
A success-path callout mock. InvoiceClient is a hypothetical class; add a second mock for error responses.

Why tests turn flaky, and the fix

Likely causeFix
Fails after an admin changeA new required field, validation rule or duplicate rule rejects factory dataUpdate the factory and run all tests in a sandbox before each release
Fails only in some orgsThe test reads existing records, hard-coded IDs or org-specific settingsCreate all data in the test and query record types by DeveloperName
Passes alone, fails in a full runShared static state or reliance on run orderReset statics and keep each method independent
Fails at month end or near midnightDate logic uses the real current timePass dates in, or compute expected values in the test
Asserts against the wrong recordA query without ORDER BY returns rows in no guaranteed orderFilter precisely or add ORDER BY
Duplicate value errorsHard-coded external IDs or usernames collideGenerate unique values in the factory
Hits governor limits in the testSetup DML fires automation inside the measured blockDo setup before Test.startTest()

Mistakes worth catching in code review

  • @isTest(SeeAllData=true) added to make a test pass. It hides a missing factory and ties the test to one org.
  • Test.isRunningTest() branches in production code that skip real logic. Occasionally necessary, but every use is code that is not tested.
  • Assertions only on size(). Check the field values the requirement actually cares about.
  • No negative tests. If the code should reject bad input, assert that it does: call it in a try block, put Assert.fail() after the call, and assert on the caught exception.
  • Catching every exception inside the test and ignoring it.

When a test fails during deployment, read the full stack trace first, then rerun that single class in a sandbox with debug logs on. Most failures point to data setup rather than the class under test. The Apex Developer Guide on Salesforce Developers documents each testing method in detail.

Checklist before you merge

  1. Every trigger path has a bulk test with 200 records.
  2. Each test method asserts specific outcomes with clear messages.
  3. No SeeAllData=true and no hard-coded record IDs.
  4. Callouts are mocked for success and failure.
  5. Async work is asserted after Test.stopTest().
  6. Sharing-sensitive logic is tested with System.runAs().
  7. The full suite passes in a sandbox that mirrors production configuration.

Get your test suite reviewed

If deployments keep failing on tests nobody trusts, we can review your Apex test suite, fix the flaky ones and set up a data factory your developers can build on.

Request a code review →

Tell us what you are trying to fix.

Share your Salesforce setup, goals and constraints. A practitioner reads every message and replies with questions, first thoughts or an honest view of whether you need outside help at all.

Talk to a Salesforce Expert
Book a Consultation