Salesforce Apex Testing Guide: Unit Tests, Provar, and CI Integration
Salesforce requires a minimum of 75% Apex code coverage before any deployment to production. This guide covers writing Apex unit tests, mocking callouts and DML, testing flows, and automating UI tests with Provar inside a CI pipeline.
Why Salesforce Testing Is Different
Testing Salesforce applications comes with unique constraints that most developers don't encounter elsewhere:
- Governor limits — every test runs inside Salesforce's multitenant execution limits (SOQL queries, DML operations, heap size, CPU time)
- 75% coverage requirement — Salesforce blocks production deployments if aggregate Apex coverage drops below 75%
- No real HTTP callouts — external HTTP requests must be mocked in test context
- Database isolation — Apex tests run in an isolated transaction that rolls back after the test, but test data doesn't automatically exist
- Order of execution — triggers, validation rules, workflows, and flows all fire during DML in tests
Understanding these constraints shapes every testing decision.
Apex Unit Test Basics
Test Class Structure
Every Apex test class must use the @isTest annotation:
@isTest
private class AccountServiceTest {
@TestSetup
static void makeData() {
Account acc = new Account(Name = 'Test Corp', Industry = 'Technology');
insert acc;
}
@isTest
static void testGetAccountByName_returnsMatch() {
Account result = AccountService.getByName('Test Corp');
System.assertNotEquals(null, result, 'Should find account');
System.assertEquals('Test Corp', result.Name);
}
@isTest
static void testGetAccountByName_noMatch_returnsNull() {
Account result = AccountService.getByName('Nonexistent Corp');
System.assertEquals(null, result, 'Should return null for missing account');
}
}Key rules:
@TestSetupruns once before all test methods in the class; data persists for all methods- Each
@isTestmethod runs in its own isolated savepoint — changes roll back - Use
System.assert,System.assertEquals,System.assertNotEqualsfor assertions - Test classes do not count toward your org's code coverage
Testing DML and Triggers
When your code fires triggers, test data setup must account for required fields:
@isTest
static void testOpportunityTrigger_setsCloseDate() {
Account acc = [SELECT Id FROM Account LIMIT 1];
Opportunity opp = new Opportunity(
Name = 'Test Deal',
AccountId = acc.Id,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30)
);
Test.startTest();
insert opp;
Test.stopTest();
Opportunity result = [SELECT CloseDate, CustomField__c FROM Opportunity WHERE Id = :opp.Id];
System.assertEquals(Date.today().addDays(30), result.CloseDate);
System.assertNotEquals(null, result.CustomField__c);
}Test.startTest() and Test.stopTest() create a fresh set of governor limits for the code under test. Always wrap your DML operations inside them when testing governor-limit-sensitive paths.
Mocking HTTP Callouts
Salesforce blocks real HTTP callouts in test context. You must implement HttpCalloutMock:
// Mock implementation
@isTest
global class MockHttpCallout implements HttpCalloutMock {
global HTTPResponse respond(HTTPRequest req) {
HttpResponse res = new HttpResponse();
res.setHeader('Content-Type', 'application/json');
res.setBody('{"status": "success", "id": "12345"}');
res.setStatusCode(200);
return res;
}
}
// Test using the mock
@isTest
static void testExternalApiCall_success() {
Test.setMock(HttpCalloutMock.class, new MockHttpCallout());
Test.startTest();
String result = ExternalApiService.createRecord('Test Payload');
Test.stopTest();
System.assertEquals('12345', result, 'Should return the ID from response');
}For multiple endpoints, implement a dispatch mock:
@isTest
global class MultiEndpointMock implements HttpCalloutMock {
global HTTPResponse respond(HTTPRequest req) {
String endpoint = req.getEndpoint();
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
if (endpoint.contains('/accounts')) {
res.setBody('{"accounts": []}');
} else if (endpoint.contains('/contacts')) {
res.setBody('{"contacts": []}');
}
return res;
}
}Testing Apex REST Services
Custom REST endpoints (@RestResource) need both positive and error path tests:
@isTest
static void testRestEndpoint_validPayload() {
RestRequest req = new RestRequest();
RestResponse res = new RestResponse();
req.requestURI = '/services/apexrest/accounts/v1/create';
req.httpMethod = 'POST';
req.requestBody = Blob.valueOf('{"name": "Test Corp", "type": "Customer"}');
RestContext.request = req;
RestContext.response = res;
Test.startTest();
AccountRestService.doPost();
Test.stopTest();
System.assertEquals(201, res.statusCode);
Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.responseBody.toString());
System.assertNotEquals(null, body.get('id'));
}Mocking Static Resources and Custom Labels
For code that references System.Label or StaticResource, create test data explicitly:
// For Custom Metadata (can't insert, use direct references)
@isTest
static void testWithCustomMetadata() {
// Query the metadata record — it persists in sandbox/scratch org
MySettings__mdt setting = [SELECT DeveloperName, Value__c FROM MySettings__mdt WHERE DeveloperName = 'Default' LIMIT 1];
System.assertNotEquals(null, setting);
}For Custom Settings (unlike Custom Metadata, these CAN be inserted in tests):
@TestSetup
static void makeData() {
MyHierarchySetting__c setting = new MyHierarchySetting__c(
SetupOwnerId = UserInfo.getOrganizationId(),
ApiTimeout__c = 5000
);
insert setting;
}Provar for UI and End-to-End Testing
Provar is the standard Salesforce-specific UI automation tool. It runs on Selenium with Salesforce-aware locators that handle Lightning Experience's dynamic component IDs.
Why Not Selenium Directly?
Salesforce Lightning components generate non-deterministic IDs like input-42-83. Provar's metadata-aware engine resolves fields by API name (Account.Name) rather than DOM position.
Setting Up Provar
- Download Provar from provar.com
- Install the Provar Eclipse plugin
- Connect to your Salesforce org via OAuth
- Create a Test Project pointing to your org's API name namespace
Writing a Provar Test
Provar tests are stored as XML but edited through a GUI:
<!-- Example: Create Account test -->
<testCase name="CreateAccount_Standard" testType="UI">
<steps>
<step action="Log In" params="url=https://login.salesforce.com;username=${SF_USERNAME};password=${SF_PASSWORD}"/>
<step action="Navigate To" params="appName=Sales;tabName=Accounts"/>
<step action="Click" params="button=New"/>
<step action="Set Field" params="field=Account.Name;value=Test Corp"/>
<step action="Set Field" params="field=Account.Industry;value=Technology"/>
<step action="Click" params="button=Save"/>
<step action="Assert Field" params="field=Account.Name;expected=Test Corp"/>
</steps>
</testCase>The key advantage: field=Account.Name works regardless of whether Salesforce renders it as input-1-28 or input-5-91.
Running Provar in CI
Provar supports headless execution via command line:
# Install Provar CLI
npm install -g @provar/provar-cli
<span class="hljs-comment"># Run tests against a scratch org
provar-cli run \
--testproject ./ProvarTests \
--environment Staging \
--resultsformat JUnit \
--resultsdir ./test-resultsIn GitHub Actions:
name: Salesforce Tests
on: [push, pull_request]
jobs:
apex-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: salesforcedx/setup-salesforce-cli@v1
- name: Authenticate to org
run: sf org login sfdx-url --sfdx-url-file ./auth/staging.txt
- name: Run Apex tests
run: sf apex run test --target-org staging --code-coverage --result-format human --output-dir ./coverage
- name: Check coverage threshold
run: |
COVERAGE=$(cat ./coverage/coverage-summary.json | jq '.totalLines.pct')
if (( $(echo "$COVERAGE < 75" | bc -l) )); then
echo "Coverage $COVERAGE% is below 75% minimum"
exit 1
fi
provar-ui-tests:
runs-on: ubuntu-latest
needs: apex-tests
steps:
- uses: actions/checkout@v4
- name: Run Provar UI tests
run: provar-cli run --environment Staging --resultsformat JUnit
- uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results/Apex Test Best Practices
1. Never rely on org data. Queries like [SELECT Id FROM Account LIMIT 1] will return nothing in test context unless you explicitly inserted that account. Use @TestSetup or insert data at the top of each test.
2. Use Test.startTest() / Test.stopTest() around async operations. Any @future method, Queueable, or Batch must be wrapped — Test.stopTest() flushes the async execution synchronously.
3. Test governor limit boundaries. Write tests that insert 200 records at once to catch bulkification issues before they hit production.
4. Name tests after behavior, not implementation. testGetAccountByName_returnsMatch is better than testAccountService.
5. Test negative paths. Every method that can throw an exception or return null should have a test for that path.
Continuous Testing with HelpMeTest
Beyond unit tests and Provar, HelpMeTest adds 24/7 monitoring for your Salesforce integration endpoints. If your org exposes REST APIs to external systems, health checks confirm those endpoints stay responsive after deployments:
helpmetest health salesforce-crm-api 5m \
--check-url https://yourorg.my.salesforce.com/services/apexrest/accounts/v1/healthFailed deployments or configuration changes that break external API consumers surface immediately rather than during the next business cycle.
Summary
Salesforce Apex testing has hard requirements — 75% coverage blocks production deployment. The key patterns:
- Use
@TestSetupto create test data once per class - Mock HTTP callouts with
HttpCalloutMock - Wrap DML-heavy code in
Test.startTest()/Test.stopTest()for accurate governor limit testing - Use Provar for Lightning UI tests that need metadata-aware locators
- Gate deployments in CI on coverage percentage and Provar test results
Meeting the 75% floor isn't the goal — the goal is meaningful coverage of your business logic's actual behavior.