afv-library/skills/platform-apex-test-run/references/testing-best-practices.md

16 KiB
Raw Permalink Blame History

Apex Testing Best Practices

Overview

This guide covers best practices for writing effective, maintainable Apex tests that ensure code quality and prevent regressions.

The Testing Pyramid

                    ╱╲
                    E2E╲        Few end-to-end tests
                 ╱──────╲
                Integration╲    Moderate integration tests
              ╱────────────╲
                Unit Tests   ╲  Many unit tests
           ╱──────────────────╲

Core Principles

1. Test One Thing Per Method

// ❌ BAD: Testing multiple things
@IsTest
static void testEverything() {
    Account acc = new Account(Name = 'Test');
    insert acc;
    acc.Name = 'Updated';
    update acc;
    delete acc;
    // What exactly are we testing?
}

// ✅ GOOD: Single responsibility
@IsTest
static void testAccountInsert_ValidName_Success() {
    Account acc = new Account(Name = 'Test');
    insert acc;
    Assert.isNotNull(acc.Id, 'Account should be inserted');
}

@IsTest
static void testAccountUpdate_ChangeName_Success() {
    Account acc = TestDataFactory.createAndInsertAccounts(1)[0];
    acc.Name = 'Updated';
    update acc;
    Account updated = [SELECT Name FROM Account WHERE Id = :acc.Id];
    Assert.areEqual('Updated', updated.Name, 'Name should be updated');
}

2. Use Descriptive Test Names

// ❌ BAD: Unclear names
@IsTest static void test1() { }
@IsTest static void testMethod() { }

// ✅ GOOD: Describes scenario, condition, and expectation
@IsTest static void testCreateAccount_WithValidData_ReturnsId() { }
@IsTest static void testCreateAccount_WithNullName_ThrowsException() { }
@IsTest static void testBulkInsert_251Records_AllSucceed() { }

3. Follow AAA Pattern (Arrange-Act-Assert)

@IsTest
static void testCalculateDiscount_VIPCustomer_Returns20Percent() {
    // ARRANGE - Set up test data
    Account vipAccount = new Account(
        Name = 'VIP Customer',
        Type = 'VIP'
    );
    insert vipAccount;

    // ACT - Execute the code under test
    Test.startTest();
    Decimal discount = DiscountService.calculateDiscount(vipAccount.Id);
    Test.stopTest();

    // ASSERT - Verify results
    Assert.areEqual(0.20, discount, 'VIP customers should get 20% discount');
}

Test Data Factory Pattern

Why Use Test Data Factory?

  1. Consistency: Same data creation logic everywhere
  2. Maintainability: Change in one place affects all tests
  3. Readability: Tests focus on logic, not data setup
  4. Flexibility: Easy to create variations

Factory Methods

@IsTest
public class TestDataFactory {

    // Basic creation (no insert)
    public static List<Account> createAccounts(Integer count) {
        List<Account> accounts = new List<Account>();
        for (Integer i = 0; i < count; i++) {
            accounts.add(new Account(
                Name = 'Test Account ' + i,
                Industry = 'Technology'
            ));
        }
        return accounts;
    }

    // Create and insert
    public static List<Account> createAndInsertAccounts(Integer count) {
        List<Account> accounts = createAccounts(count);
        insert accounts;
        return accounts;
    }

    // With specific attributes
    public static Account createAccount(String name, String industry) {
        return new Account(Name = name, Industry = industry);
    }
}

@TestSetup for Efficiency

@IsTest
private class AccountServiceTest {

    @TestSetup
    static void setupTestData() {
        // This runs ONCE for all test methods in the class
        // Data is rolled back after each test method
        List<Account> accounts = TestDataFactory.createAccounts(10);
        insert accounts;

        List<Contact> contacts = new List<Contact>();
        for (Account acc : accounts) {
            contacts.addAll(TestDataFactory.createContacts(5, acc.Id));
        }
        insert contacts;
    }

    @IsTest
    static void testMethod1() {
        // Has access to 10 accounts and 50 contacts
        List<Account> accounts = [SELECT Id FROM Account];
        Assert.areEqual(10, accounts.size());
    }

    @IsTest
    static void testMethod2() {
        // ALSO has access to 10 accounts and 50 contacts
        // @TestSetup data is available to all test methods
        List<Contact> contacts = [SELECT Id FROM Contact];
        Assert.areEqual(50, contacts.size());
    }
}

Bulk Testing (251+ Records)

Why 251 Records?

Triggers process records in batches of 200. Testing with 251 records ensures:

  • First batch: 200 records
  • Second batch: 51 records
  • Crosses the batch boundary, revealing bulkification issues
@IsTest
static void testTrigger_BulkInsert_NoDMLInLoop() {
    // ARRANGE
    List<Account> accounts = TestDataFactory.createAccounts(251);

    // ACT
    Test.startTest();
    insert accounts;  // Trigger fires twice: 200, then 51
    Test.stopTest();

    // ASSERT
    Assert.areEqual(251, [SELECT COUNT() FROM Account],
        'All 251 accounts should be created');

    // Verify governor limits not approached
    Assert.isTrue(Limits.getQueries() < 90,
        'Should not approach SOQL limit');
}

Negative Testing

Test for Expected Failures

@IsTest
static void testCreateAccount_NullName_ThrowsException() {
    try {
        Account acc = new Account(Name = null);
        insert acc;
        Assert.fail('Expected DmlException was not thrown');
    } catch (DmlException e) {
        Assert.isTrue(
            e.getMessage().contains('REQUIRED_FIELD_MISSING'),
            'Should throw required field error'
        );
    }
}

@IsTest
static void testWithdraw_InsufficientFunds_ReturnsFalse() {
    Account acc = TestDataFactory.createAndInsertAccounts(1)[0];
    acc.AnnualRevenue = 100;
    update acc;

    Test.startTest();
    Boolean result = AccountService.withdraw(acc.Id, 200);
    Test.stopTest();

    Assert.isFalse(result, 'Withdrawal should fail with insufficient funds');
}

Mock Framework Patterns

HttpCalloutMock

private class SuccessHttpMock implements HttpCalloutMock {
    public HttpResponse respond(HttpRequest req) {
        HttpResponse res = new HttpResponse();
        res.setStatusCode(200);
        res.setBody('{"success": true}');
        return res;
    }
}

@IsTest
static void testExternalAPICall_Success() {
    Test.setMock(HttpCalloutMock.class, new SuccessHttpMock());

    Test.startTest();
    String result = MyService.callExternalAPI();
    Test.stopTest();

    Assert.isTrue(result.contains('success'));
}

Stub API (Test.StubProvider)

@IsTest
private class AccountServiceTest {

    // Stub for dependency injection
    private class SelectorStub implements System.StubProvider {
        public Object handleMethodCall(
            Object stubbedObject,
            String stubbedMethodName,
            Type returnType,
            List<Type> paramTypes,
            List<String> paramNames,
            List<Object> args
        ) {
            if (stubbedMethodName == 'getAccountById') {
                return new Account(Name = 'Mocked Account');
            }
            return null;
        }
    }

    @IsTest
    static void testServiceWithMockedSelector() {
        // Create stub
        AccountSelector mockSelector = (AccountSelector) Test.createStub(
            AccountSelector.class,
            new SelectorStub()
        );

        // Inject into service
        AccountService service = new AccountService(mockSelector);

        // Test with mocked dependency
        Test.startTest();
        Account result = service.getAccount('001xx000000001');
        Test.stopTest();

        Assert.areEqual('Mocked Account', result.Name);
    }
}

Governor Limit Testing

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

    Test.startTest();
    insert accounts;
    Test.stopTest();

    // Assert limits not exceeded
    System.debug('SOQL queries used: ' + Limits.getQueries());
    System.debug('DML statements used: ' + Limits.getDmlStatements());

    Assert.isTrue(Limits.getQueries() < 100,
        'Should stay under SOQL limit: ' + Limits.getQueries());
    Assert.isTrue(Limits.getDmlStatements() < 150,
        'Should stay under DML limit: ' + Limits.getDmlStatements());
}

Common Anti-Patterns to Avoid

SeeAllData=true

// ❌ BAD: Depends on org data
@IsTest(SeeAllData=true)
static void testBadPattern() {
    Account acc = [SELECT Id FROM Account LIMIT 1];
    // This will fail in empty orgs!
}

// ✅ GOOD: Creates own data
@IsTest
static void testGoodPattern() {
    Account acc = TestDataFactory.createAndInsertAccounts(1)[0];
    // Works in any org
}

No Assertions

// ❌ BAD: No assertions - test passes even if code is broken
@IsTest
static void testNoAssertions() {
    Account acc = new Account(Name = 'Test');
    insert acc;
    // Test "passes" but proves nothing
}

// ✅ GOOD: Meaningful assertions
@IsTest
static void testWithAssertions() {
    Account acc = new Account(Name = 'Test');
    insert acc;
    Assert.isNotNull(acc.Id, 'Account should have an Id after insert');
    Account inserted = [SELECT Name FROM Account WHERE Id = :acc.Id];
    Assert.areEqual('Test', inserted.Name, 'Name should be preserved');
}

Hardcoded IDs

// ❌ BAD: Hardcoded IDs fail across orgs
Account acc = [SELECT Id FROM Account WHERE Id = '001xx0000000001'];

// ✅ GOOD: Query or create dynamically
Account acc = TestDataFactory.createAndInsertAccounts(1)[0];

Test Method Template

/**
 * @description Tests [method] with [condition] expecting [outcome]
 */
@IsTest
static void test[Method]_[Condition]_[ExpectedOutcome]() {
    // ═══════════════════════════════════════════════════════════════
    // ARRANGE - Set up test data and conditions
    // ═══════════════════════════════════════════════════════════════
    // Create test data using TestDataFactory
    // Set up any mocks needed

    // ═══════════════════════════════════════════════════════════════
    // ACT - Execute the code under test
    // ═══════════════════════════════════════════════════════════════
    Test.startTest();
    // Call the method being tested
    Test.stopTest();

    // ═══════════════════════════════════════════════════════════════
    // ASSERT - Verify expected outcomes
    // ═══════════════════════════════════════════════════════════════
    // Assert.areEqual(expected, actual, 'Descriptive message');
    // Assert.isTrue(condition, 'Descriptive message');
    // Assert.isNotNull(value, 'Descriptive message');
}

Test Speed Philosophy

Why Test Speed Matters

Fast tests enable continuous integration. Slow tests become barriers to frequent commits.

Target Metrics

Test Type Target Speed Purpose
Unit test (no DML) < 50ms Test pure business logic
Unit test (mocked DML) < 100ms Test with stubbed database
Integration test < 500ms Verify real database behavior
Full scenario test < 2000ms End-to-end validation

Speed Strategy

┌─────────────────────────────────────────────────────────────┐
│  INTEGRATION TESTS (Few)                                    │
│  - Real DML, real triggers                                  │
│  - Test happy path completely                               │
│  - Slow but high confidence                                 │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│  UNIT TESTS WITH MOCKS (Many)                               │
│  - Stub database operations                                 │
│  - Test edge cases, variations                              │
│  - Fast, run on every save                                  │
└─────────────────────────────────────────────────────────────┘

Balance Reality vs Speed

// INTEGRATION TEST: Full database interaction (slower, realistic)
@IsTest
static void testAccountCreation_Integration() {
    Test.startTest();
    Account acc = new Account(Name = 'Test');
    insert acc;
    Test.stopTest();

    Account queried = [SELECT Name, Status__c FROM Account WHERE Id = :acc.Id];
    Assert.areEqual('Active', queried.Status__c, 'Trigger should set status');
}

// UNIT TEST: Mocked database (faster, tests logic only)
@IsTest
static void testAccountRules_IsHighValue() {
    // No database interaction - tests pure logic
    Account acc = new Account(AnnualRevenue = 1500000);
    Assert.isTrue(AccountRules.isHighValue(acc), 'Should be high value');

    Account lowValue = new Account(AnnualRevenue = 500000);
    Assert.isFalse(AccountRules.isHighValue(lowValue), 'Should not be high value');
}

Techniques for Faster Tests

Technique Benefit Trade-off
@TestSetup Reuse data across methods Small setup overhead
Stub API No real DML Less realistic
Selector mocking Skip SOQL Must trust Selector
Domain class testing Pure logic, no DB Limited scope
Avoid SeeAllData Predictable data Must create test data

When to Prioritize Speed vs Reality

Scenario Priority Approach
Business logic validation Speed Unit test with mocks
Trigger behavior Reality Integration test
Edge case coverage Speed Many unit tests
Deployment validation Reality RunLocalTests
Developer feedback loop Speed Fast unit tests

Measuring Test Speed

# Run tests and view timing
sf apex run test --test-level RunLocalTests --target-org alias --result-format human

# Check individual test timing
sf apex get test --test-run-id [id] --code-coverage --result-format json | jq '.tests[] | {name: .MethodName, time: .RunTime}'

Fast Test Checklist

□ Is there a pure logic portion that can be unit tested?
□ Can database operations be mocked for edge cases?
□ Is @TestSetup reused across multiple test methods?
□ Are tests avoiding SeeAllData=true?
□ Are integration tests reserved for critical paths only?
□ Can SOQL be mocked via Selector pattern?