Salesforce Apex Testing Guide: Unit Tests, Provar, and CI Integration

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:

  • @TestSetup runs once before all test methods in the class; data persists for all methods
  • Each @isTest method runs in its own isolated savepoint — changes roll back
  • Use System.assert, System.assertEquals, System.assertNotEquals for 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

  1. Download Provar from provar.com
  2. Install the Provar Eclipse plugin
  3. Connect to your Salesforce org via OAuth
  4. 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-results

In 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/health

Failed 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 @TestSetup to 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.

Read more