home Home build Tools bug_report Errors menu_book Guides lightbulb Tips smart_toy Prompts extension Extensions folder_open Resources info About
search

Salesforce requires 75% code coverage to deploy to production, but coverage alone is a poor quality metric. A test that doesn't assert anything can hit 100% coverage while providing zero protection. This guide focuses on tests that actually validate behavior.

Test Philosophy

Good Apex tests follow the Arrange–Act–Assert pattern. Each test method should cover exactly one scenario, have a descriptive name that explains what it's testing, and include meaningful assertions with failure messages.

rule

Coverage vs Quality: The goal is not 75% coverage — it's zero regressions. Write tests for: positive paths, negative/error paths, bulk operations (200+ records), boundary conditions, and permission contexts.

Apex — Arrange-Act-Assert Pattern
@isTest
private class OpportunityServiceTest {

    @isTest
    static void calculateDiscount_returnsCorrectPercentage_forPlatinumTier() {
        // ARRANGE — set up all test data
        Account acc = TestDataFactory.createAccount('Platinum');
        Opportunity opp = new Opportunity(
            Name = 'Test Opp',
            AccountId = acc.Id,
            Amount = 100000,
            CloseDate = Date.today().addDays(30),
            StageName = 'Prospecting'
        );
        insert opp;

        // ACT — call the method under test
        Test.startTest();
        Decimal discount = OpportunityService.calculateDiscount(opp.Id);
        Test.stopTest();

        // ASSERT — verify the exact expected outcome
        System.assertEquals(
            0.15, 
            discount, 
            'Platinum tier accounts should receive 15% discount'
        );
    }
}

Test Data Factory Pattern

Duplicating new Account(Name='Test') across hundreds of test methods is a maintenance nightmare. Centralize all test data creation in a factory class. When your schema changes, you update one place.

Apex — TestDataFactory
@isTest
public class TestDataFactory {

    // Returns a persisted Account
    public static Account createAccount(String tier) {
        Account acc = new Account(
            Name = 'Test Account — ' + tier,
            Industry = 'Technology',
            AnnualRevenue = tier == 'Platinum' ? 10000000 : 500000,
            Customer_Tier__c = tier,
            BillingCity = 'San Francisco',
            BillingCountry = 'USA'
        );
        insert acc;
        return acc;
    }

    // Returns a list of Contacts (without inserting)
    public static List<Contact> buildContacts(Id accountId, Integer count) {
        List<Contact> contacts = new List<Contact>();
        for (Integer i = 0; i < count; i++) {
            contacts.add(new Contact(
                FirstName = 'Test',
                LastName = 'Contact ' + i,
                Email = 'test' + i + '@example.com',
                AccountId = accountId,
                Title = 'QA Contact'
            ));
        }
        return contacts;
    }

    // Bulk insert helper
    public static List<Contact> createContacts(Id accountId, Integer count) {
        List<Contact> contacts = buildContacts(accountId, count);
        insert contacts;
        return contacts;
    }

    // Closed won opportunity with full required fields
    public static Opportunity createClosedWonOpportunity(Id accountId, Decimal amount) {
        Opportunity opp = new Opportunity(
            Name = 'Test Opportunity',
            AccountId = accountId,
            StageName = 'Closed Won',
            CloseDate = Date.today(),
            Amount = amount,
            Probability = 100
        );
        insert opp;
        return opp;
    }
}

@TestSetup vs Per-Method Data

Use @TestSetup for data that's needed by most tests in the class. It runs once before all test methods and each method gets a clean, rolled-back copy. This significantly reduces test execution time.

Apex — @TestSetup Pattern
@isTest
private class AccountTriggerTest {

    // Runs once — each test method gets its own isolated copy
    @TestSetup
    static void makeData() {
        // Create accounts in bulk to test bulkification
        List<Account> accounts = new List<Account>();
        for (Integer i = 0; i < 200; i++) {
            accounts.add(new Account(
                Name = 'Bulk Test Account ' + i,
                Industry = Math.mod(i, 2) == 0 ? 'Technology' : 'Healthcare'
            ));
        }
        insert accounts;
    }

    @isTest
    static void setDefaultRating_bulk_200Records() {
        List<Account> accounts = [SELECT Id, Rating FROM Account];

        System.assertEquals(200, accounts.size(), 'Should have 200 test accounts');
        for (Account acc : accounts) {
            System.assertEquals('Cold', acc.Rating, 'Default rating must be Cold');
        }
    }

    @isTest
    static void industryCount_technologyAndHealthcare_splitEvenly() {
        Integer techCount = [SELECT COUNT() FROM Account WHERE Industry = 'Technology'];
        Integer healthCount = [SELECT COUNT() FROM Account WHERE Industry = 'Healthcare'];

        System.assertEquals(100, techCount, 'Should be 100 Tech accounts');
        System.assertEquals(100, healthCount, 'Should be 100 Healthcare accounts');
    }
}

Testing Error & Negative Paths

A test suite without error-path coverage is incomplete. Always test that your validation logic correctly rejects bad input.

Apex — Testing Exceptions & Validation
@isTest
static void negativeRevenue_throwsDmlException() {
    Account acc = [SELECT Id FROM Account LIMIT 1];
    acc.AnnualRevenue = -999;

    Test.startTest();
    Database.SaveResult result = Database.update(acc, false); // allOrNone = false
    Test.stopTest();

    System.assert(!result.isSuccess(), 'Negative revenue should be rejected');
    System.assert(
        result.getErrors()[0].getMessage().contains('cannot be negative'),
        'Error message should mention negative revenue'
    );
}

// Test that a custom exception is thrown
@isTest
static void getCustomer_invalidId_throwsCalloutException() {
    Test.setMock(HttpCalloutMock.class, new CustomerApiMock(404, '{"error":"Not found"}'));
    Boolean exceptionThrown = false;

    Test.startTest();
    try {
        CustomerApiClient.getCustomer('INVALID_ID');
    } catch (CalloutException e) {
        exceptionThrown = true;
        System.assert(e.getMessage().contains('404'), 'Should mention HTTP 404');
    }
    Test.stopTest();

    System.assert(exceptionThrown, 'CalloutException must be thrown for invalid ID');
}

Testing with Different User Contexts

Use System.runAs() to test behavior under specific user profiles and permission sets — critical for sharing rules, field-level security, and permission-gated functionality.

Apex — runAs User Context
@isTest
static void standardUser_cannotDeleteAccount() {
    // Create a standard user (no System Admin)
    Profile p = [SELECT Id FROM Profile WHERE Name = 'Standard User'];
    User stdUser = new User(
        ProfileId = p.Id,
        LastName = 'TestUser',
        Email = 'testuser@example.com',
        Username = 'testuser' + DateTime.now().getTime() + '@example.com',
        Alias = 'tstUsr',
        TimeZoneSidKey = 'America/Los_Angeles',
        LocaleSidKey = 'en_US',
        EmailEncodingKey = 'UTF-8',
        LanguageLocaleKey = 'en_US'
    );
    insert stdUser;

    Account acc = TestDataFactory.createAccount('Standard');

    Test.startTest();
    System.runAs(stdUser) {
        try {
            delete acc;
            System.assert(false, 'Standard user should not delete accounts');
        } catch (DmlException e) {
            System.assert(
                e.getMessage().contains('insufficient access'),
                'Should be an access error'
            );
        }
    }
    Test.stopTest();
}

// Test permission set grants access
@isTest
static void userWithPermSet_canAccessPremiumFeature() {
    User stdUser = TestDataFactory.createStandardUser();
    PermissionSet ps = [SELECT Id FROM PermissionSet WHERE Name = 'Premium_Features'];
    insert new PermissionSetAssignment(AssigneeId = stdUser.Id, PermissionSetId = ps.Id);

    Test.startTest();
    System.runAs(stdUser) {
        Boolean hasAccess = PremiumFeatureService.currentUserHasAccess();
        System.assert(hasAccess, 'User with Premium_Features perm set should have access');
    }
    Test.stopTest();
}

Assertion Best Practices

Every System.assert should have a descriptive failure message. Use the right assertion method for the right comparison.

Apex — Assertion Reference
// Boolean assertion
System.assert(isActive, 'Account should be active after onboarding');

// Equality — use this for most value comparisons
System.assertEquals(expected, actual, 'Message shown if test fails');

// Inequality
System.assertNotEquals(null, result, 'Result should not be null');

// Modern Assert class (Summer '23+) — more readable
Assert.isTrue(result.success, 'API call should succeed');
Assert.areEqual(200, response.statusCode, 'Expected HTTP 200');
Assert.isNotNull(acc.Id, 'Account should have been inserted');
Assert.isFalse(errors.isEmpty(), 'Validation errors should exist');

// ❌ Bad — no message, impossible to debug on failure
System.assert(result != null);
System.assertEquals('Closed Won', opp.StageName);

// ✅ Good — clear failure message
System.assertNotEquals(null, result, 'Service result should never be null');
System.assertEquals('Closed Won', opp.StageName, 'Opportunity stage should update to Closed Won on payment');
75%
Minimum coverage to deploy
90%+
Target for production code
200
Records for bulk tests
1
Scenario per test method

Test Checklist

checklist

Before submitting your Apex for review, confirm:

✅ Positive path tested with assertions on every key field

✅ Negative/error path tested (invalid input, missing required data)

✅ Bulk test with 200 records (test bulkification)

✅ No hardcoded IDs or org-specific data

✅ Callouts mocked with HttpCalloutMock

✅ All assertions include descriptive failure messages

✅ Test data created in test context only (no seeAllData=true)

✅ Async code wrapped in Test.startTest() / Test.stopTest()