Salesforce

Apex testing patterns that actually catch bugs

Salesforce requires 75% Apex coverage to deploy. Most teams stop there — and most teams keep shipping regressions to production. Here's the testing framework we now use on every engagement, refined over a recent CPQ rollout that handled 80,000 quote lines a day.

We've all seen the test class: a single @isTest method that creates one record, calls a trigger, and asserts that Database.insert() didn't throw. Coverage hits 78%. CI is green. Deploy goes through.

Six weeks later, a sales rep clones a quote with discount approval pending, and the entire pricing engine recalculates against the wrong currency. The test that "covered" the cloning logic never actually asserted what the logic was supposed to do.

Coverage is a deployment gate, not a quality signal. Here's what we test for instead.

1. One assert per logical outcome, not per method

The instinct is to write one test per Apex method. The better unit is one test per business outcome. A single trigger handler might have five distinct outcomes — each one deserves its own focused test.

Compare these two structures:

// Anti-pattern: one big test
@isTest
static void testQuoteHandler() {
  Quote q = createTestQuote();
  insert q;
  q.Status = 'Approved';
  update q;
  // ... 40 lines of setup ...
  System.assert(q.Total__c > 0);
}
// Better: one outcome per test
@isTest
static void approvedQuote_locksLineItems() { /* ... */ }

@isTest
static void approvedQuote_firesCommissionCalc() { /* ... */ }

@isTest
static void approvedQuote_blocksDiscountChanges() { /* ... */ }

When a test fails, the name tells you immediately what business rule broke. No spelunking through 80-line setup blocks to figure out which assertion fired.

2. Test the boundaries, not the happy path

The happy path is the path your code already handles correctly — that's why you wrote it. Bugs live at boundaries: empty collections, null values, the 201st record in a 200-record loop, the first day of a new fiscal quarter.

For every method you test, ask:

On the CPQ project, four of the seven post-launch bugs were timezone-related. We now test every date-sensitive method with a record created at 23:59:00 UTC and again at 00:01:00 UTC.

3. Bulk test like you mean it

Salesforce makes you write the words "bulk test" so often that they lose meaning. Most "bulk tests" insert 200 records and assert that nothing threw. That misses the actual risk: most bulk bugs are order-sensitive or cross-record.

@isTest
static void priceRule_appliesAcrossMixedDiscountTiers() {
  List<Quote_Line__c> lines = new List<Quote_Line__c>();
  for (Integer i = 0; i < 200; i++) {
    // Mix tiers deliberately — not all the same
    lines.add(new Quote_Line__c(
      Tier__c = (Math.mod(i, 3) == 0) ? 'Gold' : 'Silver',
      Quantity__c = i + 1
    ));
  }
  Test.startTest();
  insert lines;
  Test.stopTest();

  // Don't just check it didn't throw. Assert the math.
  Map<String, Decimal> totalsByTier = ...;
  System.assertEquals(expectedGold, totalsByTier.get('Gold'));
  System.assertEquals(expectedSilver, totalsByTier.get('Silver'));
}

4. Stop using SeeAllData=true

If a test relies on production data shape, it's not a test — it's a fragile observation. Build a TestDataFactory class that creates the records each test needs, deterministically.

public class TestDataFactory {
  public static Account standardAccount() {
    return new Account(Name = 'Test Account', BillingCountry = 'US');
  }

  public static Quote quoteWith(Integer lineCount, String tier) {
    Quote q = new Quote(Name = 'Test', /* ... */);
    insert q;
    List<Quote_Line__c> lines = new List<Quote_Line__c>();
    for (Integer i = 0; i < lineCount; i++) {
      lines.add(new Quote_Line__c(QuoteId = q.Id, Tier__c = tier));
    }
    insert lines;
    return q;
  }
}

Now every test starts with a known state. Tests don't break when ops adds a new validation rule. New developers can read a test and know exactly what data it depends on.

Watch out Don't push the factory too far. A factory with 30 optional parameters becomes its own configuration nightmare. We cap factory methods at 4 parameters and create more named variants instead.

5. Mock the callouts you don't own

External callouts are the most common source of flaky tests. HttpCalloutMock exists for a reason — use it, and store realistic response fixtures alongside the test class.

@isTest
global class StripeWebhookMock implements HttpCalloutMock {
  global HttpResponse respond(HttpRequest req) {
    HttpResponse res = new HttpResponse();
    res.setStatusCode(200);
    res.setBody(StripeFixtures.successfulCharge());
    return res;
  }
}

@isTest
static void chargeSucceeded_marksQuotePaid() {
  Test.setMock(HttpCalloutMock.class, new StripeWebhookMock());
  // ...
}

Bonus: when Stripe (or whoever) changes their response shape, you update one fixture file and find out which tests break. That's a feature, not a bug.

6. Assert on database state, not just return values

An Apex method can return the right value while writing the wrong thing to the database. After every meaningful test action, query the affected records and assert their actual stored state:

// Don't trust the return value alone
update q;
Quote refetched = [SELECT Status, Approval_Date__c FROM Quote WHERE Id = :q.Id];
System.assertEquals('Approved', refetched.Status);
System.assertNotEquals(null, refetched.Approval_Date__c);

This catches the surprising number of bugs where a Flow modifies records the Apex method also touched, and the in-memory object doesn't match what's persisted.

7. Name tests so failure messages tell a story

When a test fails in CI, the only context you have is its name. Use this pattern: methodName_condition_expectedResult.

You can read a failing test name in CI and know what's broken without opening the test class. Future you will thank present you.

The result, in numbers

On the CPQ engagement, applying these patterns shifted the team's testing posture in measurable ways:

Tests aren't a tax. Done well, they're the fastest feedback loop you have. The trick is writing them like you mean them.

Need help shipping work like this?

NirvanaCorner builds Salesforce, web, and AI systems for ambitious teams.

Book a discovery call →