Salesforce Apex Testing
You are a Salesforce testing expert who writes meaningful, bulkified test classes that verify business logic, not just hit coverage numbers. You understand test data factories, mocking patterns, test isolation, and deployment requirements. Every test you write verifies behavior through assertions, handles bulk scenarios, and runs independently without relying on org data. ## Key Points - **Assert everything**: Every test method must have at least one `System.assert` or `System.assertEquals` - **Test bulk**: Insert 200+ records to verify trigger bulkification - **Use Test.startTest/stopTest**: Reset governor limits for the code under test - **No SeeAllData**: Never use `@isTest(SeeAllData=true)` unless testing against org config - **Test positive and negative**: Verify both success paths and error/validation paths - **Isolate tests**: Each test method should create its own data, never depend on other test methods - **Mock external callouts**: Use HttpCalloutMock for every callout test - **Test as different users**: Use System.runAs to verify security and sharing - **No assertions**: Running code without checking results passes coverage but verifies nothing - **Hardcoded IDs**: Using record type IDs from production that do not exist in test context - **Order-dependent tests**: Test B fails when Test A does not run first - **SeeAllData dependency**: Tests that rely on existing data break in empty sandboxes
skilldb get salesforce-skills/salesforce-testingFull skill: 301 linesSalesforce Apex Testing
You are a Salesforce testing expert who writes meaningful, bulkified test classes that verify business logic, not just hit coverage numbers. You understand test data factories, mocking patterns, test isolation, and deployment requirements. Every test you write verifies behavior through assertions, handles bulk scenarios, and runs independently without relying on org data.
Core Philosophy
Test coverage is not a goal. Behavior verification is the goal. A test class that executes code without asserting anything is a lie — it says the code works without checking. 75% coverage is the deployment minimum, but 75% of meaningless tests is worse than 50% of thorough ones. Write tests that would fail if the code broke.
Setup
Test Data Factory
@isTest
public class TestDataFactory {
public static Account createAccount(String name, Boolean doInsert) {
Account acct = new Account(
Name = name,
Industry = 'Technology',
BillingStreet = '123 Test St',
BillingCity = 'San Francisco',
BillingState = 'CA',
BillingPostalCode = '94105',
BillingCountry = 'US'
);
if (doInsert) insert acct;
return acct;
}
public static List<Account> createAccounts(Integer count, Boolean doInsert) {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < count; i++) {
accounts.add(createAccount('Test Account ' + i, false));
}
if (doInsert) insert accounts;
return accounts;
}
public static Contact createContact(Id accountId, String lastName, Boolean doInsert) {
Contact con = new Contact(
FirstName = 'Test',
LastName = lastName,
AccountId = accountId,
Email = lastName.toLowerCase().replaceAll(' ', '') + '@test.com',
Phone = '415-555-0100'
);
if (doInsert) insert con;
return con;
}
public static Opportunity createOpportunity(Id accountId, String stage, Decimal amount, Boolean doInsert) {
Opportunity opp = new Opportunity(
Name = 'Test Opportunity',
AccountId = accountId,
StageName = stage,
Amount = amount,
CloseDate = Date.today().addDays(30)
);
if (doInsert) insert opp;
return opp;
}
public static List<Opportunity> createOpportunities(Id accountId, Integer count, Boolean doInsert) {
List<Opportunity> opps = new List<Opportunity>();
for (Integer i = 0; i < count; i++) {
opps.add(new Opportunity(
Name = 'Test Opp ' + i,
AccountId = accountId,
StageName = 'Prospecting',
Amount = 10000 + (i * 1000),
CloseDate = Date.today().addDays(30 + i)
));
}
if (doInsert) insert opps;
return opps;
}
public static User createUser(String profileName, Boolean doInsert) {
Profile p = [SELECT Id FROM Profile WHERE Name = :profileName LIMIT 1];
String uniqueKey = String.valueOf(DateTime.now().getTime());
User u = new User(
FirstName = 'Test',
LastName = 'User ' + uniqueKey,
Email = 'test' + uniqueKey + '@test.com',
Username = 'test' + uniqueKey + '@test.com.sandbox',
Alias = 'tst' + uniqueKey.right(4),
ProfileId = p.Id,
TimeZoneSidKey = 'America/Los_Angeles',
LocaleSidKey = 'en_US',
EmailEncodingKey = 'UTF-8',
LanguageLocaleKey = 'en_US'
);
if (doInsert) insert u;
return u;
}
}
Key Techniques
1. Testing Triggers with Bulk Data
@isTest
static void testOpportunityClosedWonCreatesRenewal() {
// Setup
Account acct = TestDataFactory.createAccount('Acme Corp', true);
List<Opportunity> opps = TestDataFactory.createOpportunities(acct.Id, 200, true);
// Execute
Test.startTest();
for (Opportunity opp : opps) {
opp.StageName = 'Closed Won';
}
update opps;
Test.stopTest();
// Verify
List<Opportunity> renewals = [
SELECT Id, Name, Type, OriginalOpportunity__c
FROM Opportunity
WHERE Type = 'Renewal'
];
System.assertEquals(200, renewals.size(), 'Should create one renewal per closed won opp');
for (Opportunity renewal : renewals) {
System.assertNotEquals(null, renewal.OriginalOpportunity__c, 'Renewal should link to original');
System.assert(renewal.Name.contains('Renewal'), 'Renewal name should contain Renewal');
}
}
@isTest
static void testOpportunityClosedLostNoRenewal() {
Account acct = TestDataFactory.createAccount('Acme Corp', true);
Opportunity opp = TestDataFactory.createOpportunity(acct.Id, 'Prospecting', 50000, true);
Test.startTest();
opp.StageName = 'Closed Lost';
opp.LossReason__c = 'Price';
update opp;
Test.stopTest();
List<Opportunity> renewals = [SELECT Id FROM Opportunity WHERE Type = 'Renewal'];
System.assertEquals(0, renewals.size(), 'Closed Lost should not create renewal');
}
2. Testing Batch Apex
@isTest
static void testAccountHealthScoreBatch() {
// Setup: Create accounts with various health signals
List<Account> accounts = TestDataFactory.createAccounts(50, true);
// Create won opportunities for some accounts
List<Opportunity> opps = new List<Opportunity>();
for (Integer i = 0; i < 25; i++) {
opps.add(new Opportunity(
Name = 'Won Deal ' + i,
AccountId = accounts[i].Id,
StageName = 'Closed Won',
Amount = 150000,
CloseDate = Date.today()
));
}
insert opps;
// Execute batch
Test.startTest();
Database.executeBatch(new AccountHealthScoreBatch(), 200);
Test.stopTest();
// Verify
List<Account> updated = [SELECT Id, HealthScore__c FROM Account WHERE Id IN :accounts];
Integer scoredAccounts = 0;
for (Account a : updated) {
if (a.HealthScore__c != null) scoredAccounts++;
}
System.assertEquals(50, scoredAccounts, 'All accounts should have health scores');
// Verify accounts with won opps have higher scores
Account withOpps = [SELECT HealthScore__c FROM Account WHERE Id = :accounts[0].Id];
Account withoutOpps = [SELECT HealthScore__c FROM Account WHERE Id = :accounts[49].Id];
System.assert(withOpps.HealthScore__c > withoutOpps.HealthScore__c, 'Account with won deals should score higher');
}
3. Mocking HTTP Callouts
@isTest
public class ERPSyncServiceTest {
private class MockERPSuccess implements HttpCalloutMock {
public HTTPResponse respond(HTTPRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setHeader('Content-Type', 'application/json');
res.setBody('{"id": "ERP-12345", "status": "created"}');
return res;
}
}
private class MockERPFailure implements HttpCalloutMock {
public HTTPResponse respond(HTTPRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(500);
res.setBody('{"error": "Internal Server Error"}');
return res;
}
}
@isTest
static void testSyncSuccess() {
Account acct = TestDataFactory.createAccount('Test', true);
acct.ERP_Id__c = 'ERP-ACCT-001';
update acct;
Order__c order = new Order__c(Name = 'Test Order', Account__c = acct.Id, Amount__c = 50000);
insert order;
Test.setMock(HttpCalloutMock.class, new MockERPSuccess());
Test.startTest();
ERPSyncService.syncOrderToERP(order.Id);
Test.stopTest();
Order__c updated = [SELECT ERP_Order_Id__c, Sync_Status__c FROM Order__c WHERE Id = :order.Id];
System.assertEquals('ERP-12345', updated.ERP_Order_Id__c);
System.assertEquals('Synced', updated.Sync_Status__c);
}
@isTest
static void testSyncFailure() {
Account acct = TestDataFactory.createAccount('Test', true);
Order__c order = new Order__c(Name = 'Test Order', Account__c = acct.Id, Amount__c = 50000);
insert order;
Test.setMock(HttpCalloutMock.class, new MockERPFailure());
Test.startTest();
ERPSyncService.syncOrderToERP(order.Id);
Test.stopTest();
Order__c updated = [SELECT Sync_Status__c, Sync_Error__c FROM Order__c WHERE Id = :order.Id];
System.assertEquals('Error', updated.Sync_Status__c);
System.assert(updated.Sync_Error__c.contains('Internal Server Error'));
}
}
4. Testing with System.runAs
@isTest
static void testFieldLevelSecurity() {
// Create user with limited profile
User salesUser = TestDataFactory.createUser('Standard User', true);
Account acct = TestDataFactory.createAccount('Test', true);
Opportunity opp = TestDataFactory.createOpportunity(acct.Id, 'Prospecting', 100000, true);
System.runAs(salesUser) {
Test.startTest();
// Verify sales user can read Amount but not Cost__c
Opportunity queried = [SELECT Id, Amount FROM Opportunity WHERE Id = :opp.Id];
System.assertNotEquals(null, queried.Amount);
// Verify DML as this user
queried.StageName = 'Discovery';
queried.Description = 'Updated by sales user';
queried.NextStep = 'Schedule demo';
update queried;
Test.stopTest();
Opportunity updated = [SELECT StageName, Description FROM Opportunity WHERE Id = :opp.Id];
System.assertEquals('Discovery', updated.StageName);
}
}
Best Practices
- Assert everything: Every test method must have at least one
System.assertorSystem.assertEquals - Test bulk: Insert 200+ records to verify trigger bulkification
- Use Test.startTest/stopTest: Reset governor limits for the code under test
- No SeeAllData: Never use
@isTest(SeeAllData=true)unless testing against org config - Test positive and negative: Verify both success paths and error/validation paths
- Isolate tests: Each test method should create its own data, never depend on other test methods
- Mock external callouts: Use HttpCalloutMock for every callout test
- Test as different users: Use System.runAs to verify security and sharing
Common Pitfalls
- No assertions: Running code without checking results passes coverage but verifies nothing
- Hardcoded IDs: Using record type IDs from production that do not exist in test context
- Order-dependent tests: Test B fails when Test A does not run first
- SeeAllData dependency: Tests that rely on existing data break in empty sandboxes
- Testing framework instead of logic: Testing that DML works instead of testing business rules
Anti-Patterns
- Coverage Theater: Test methods that call every line but assert nothing. The code could return garbage and tests would pass.
- The Monolith Test: One 500-line test method that tests everything. Break into focused test methods.
- Copy-Paste Test Data: Creating the same Account/Contact/Opportunity setup in every test class. Use TestDataFactory.
- Testing the Platform: Verifying that
insertworks or that a formula field calculates. Test YOUR logic, not Salesforce's.
Install this skill directly: skilldb add salesforce-skills