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:
- What happens with an empty list?
- What happens at exactly the governor limit?
- What happens when a related record is missing?
- What happens when two users hit it concurrently?
- What happens on the timezone boundary at midnight?
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.
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.
calculateDiscount_tierUpgradeMidQuote_appliesNewTiercloneQuote_pendingApproval_resetsApprovalStatuscommissionCalc_zeroQuantityLine_excludesFromTotal
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:
- Test count went from 47 to 312, but coverage only moved from 79% to 86% — a sign we were testing harder, not just adding noise.
- Production hotfix deployments dropped from ~3/month to under 1/quarter.
- Average time to debug a failed CI run fell from 22 minutes to under 6, because failure messages now told you what business rule broke.
Tests aren't a tax. Done well, they're the fastest feedback loop you have. The trick is writing them like you mean them.