Why Testing Matters in Apex
Salesforce enforces a hard rule: at least 75% aggregate code coverage across all Apex classes and triggers is required before any deployment to production, and every trigger needs some coverage of its own. But the platform only checks the number — it cannot tell whether your tests actually validate behavior. A test suite that hits 90% coverage with no meaningful assertions is worthless in practice, because it proves the code ran, not that it produced the right result. Every test method also executes inside its own database transaction that Salesforce automatically rolls back afterward, so tests never pollute real org data.
Cricket analogy: Coverage without assertions is like a batsman facing 90 balls in the nets without anyone checking if he actually hit the shots correctly — Rahul Dravid's technique was judged by shot quality, not ball count.
Structuring Test Classes and Test Methods
A test class is annotated with @isTest and its methods with @isTest (or the older testMethod keyword). Inside a test method, wrap the code under test between Test.startTest() and Test.stopTest() — this resets the governor limit counters for that block and forces any asynchronous work (future methods, queueable jobs, batch Apex) to execute synchronously before stopTest() returns, so you can assert on its results immediately. Shared setup data — accounts, custom settings, users — belongs in a method annotated @testSetup, which runs once before each test method in the class and whose records are automatically rolled back and re-created for isolation between methods.
Cricket analogy: Test.startTest()/stopTest() is like a fresh Powerplay restriction kicking in — the over count and field placement rules reset so you can measure exactly what happened during that specific phase of play.
@isTest
private class OpportunityServiceTest {
@testSetup
static void setup() {
Account acc = new Account(Name = 'Acme Corp');
insert acc;
List<Opportunity> opps = new List<Opportunity>();
for (Integer i = 0; i < 200; i++) {
opps.add(new Opportunity(
Name = 'Deal ' + i,
AccountId = acc.Id,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Amount = 1000
));
}
insert opps;
}
@isTest
static void testBulkStageUpdateAppliesDiscount() {
List<Opportunity> opps = [SELECT Id, Amount FROM Opportunity];
Test.startTest();
OpportunityService.applyClosingDiscount(opps);
Test.stopTest();
List<Opportunity> updated = [SELECT Amount FROM Opportunity];
for (Opportunity o : updated) {
System.assertEquals(900, o.Amount,
'Discount of 10% should be applied to each opportunity');
}
}
@isTest
static void testNullListThrowsIllegalArgumentException() {
Test.startTest();
try {
OpportunityService.applyClosingDiscount(null);
System.assert(false, 'Expected an exception for null input');
} catch (IllegalArgumentException e) {
System.assert(e.getMessage().contains('cannot be null'));
}
Test.stopTest();
}
}Test Data Isolation and Mocking Callouts
By default, test classes cannot see existing records in the org — the SeeAllData=false behavior — which forces you to create every record your test needs, guaranteeing tests are deterministic regardless of what's in the sandbox or production org. For outbound HTTP callouts, you cannot make real network calls from a test; instead, implement the HttpCalloutMock interface and register it with Test.setMock(HttpCalloutMock.class, new MyMockImpl()) before the callout-triggering code executes, so the callout returns a canned HttpResponse without touching a live endpoint.
Cricket analogy: It's like practicing in the nets with your own set of balls rather than borrowing from the match day supply — SeeAllData=false forces you to bring (create) everything you need instead of relying on whatever's lying around.
Avoid @isTest(SeeAllData=true) unless you have a specific, unavoidable reason (such as testing against org-wide default sharing settings or certain configuration-only metadata). Relying on existing data makes tests fragile — they can pass in one sandbox and fail in another purely because of differing record counts.
Assertions, Bulk Testing, and Coverage Quality
Modern Apex favors the Assert class (Assert.areEqual, Assert.isTrue, Assert.fail) over the legacy System.assertEquals family because it produces clearer stack traces and integrates better with test result reporting, though both still work. Every test should exercise bulk behavior — insert or update 200+ records inside the tested method, since that is Salesforce's default trigger batch size, and a class that only tests a single record will pass locally but throw a LIMIT_EXCEEDED SOQL or DML error the first time a bulk data load or API integration inserts 200 rows at once. Negative-path tests matter just as much: deliberately trigger validation rule failures, DML exceptions, or custom exceptions and assert that the correct exception type and message surface, using a try/catch with a System.assert(false) fallback in the try block to guarantee the catch was actually reached.
Cricket analogy: Testing only one record is like a bowler nailing a single perfect yorker in the nets but never testing what happens bowling the 20th over under fatigue in a T20 — bulk load is the real match pressure.
A test method with zero assertions still counts toward your 75% coverage number. This is the single most common anti-pattern reviewers flag: code that merely calls a method and lets the transaction complete, proving nothing about correctness. Every test method should contain at least one meaningful Assert.areEqual, Assert.isTrue, or equivalent check tied to the behavior under test.
- 75% org-wide coverage is a deployment gate, not a quality metric — assertions are what prove correctness.
- Test.startTest()/Test.stopTest() resets governor limits and forces async Apex to run synchronously for assertion.
- @testSetup builds shared baseline data once per test class, re-isolated for every test method.
- SeeAllData=false by default forces deterministic tests that don't depend on existing org data.
- Test.setMock with an HttpCalloutMock implementation is required to unit test outbound HTTP callouts.
- Always test with 200+ records to catch bulk-related governor limit failures before they hit production.
- Negative tests that assert on thrown exceptions are as important as happy-path assertions.
Practice what you learned
1. What is the minimum aggregate Apex code coverage percentage required to deploy to a Salesforce production org?
2. What is the primary purpose of wrapping code between Test.startTest() and Test.stopTest()?
3. How should an outbound HTTP callout be tested in Apex?
4. Why should test methods insert or update 200+ records rather than just one?
5. What is wrong with a test method that calls business logic but contains no Assert statements?
Was this page helpful?
You May Also Like
Governor Limits
Understand why Salesforce enforces per-transaction governor limits and how to write bulk-safe Apex that never hits SOQL, DML, CPU, or heap ceilings.
Apex Design Patterns
Explore the Trigger Handler, Service Layer, Selector, and Unit of Work patterns that keep large Apex codebases maintainable, testable, and bulk-safe.
Integrating Apex with LWC
Learn how Lightning Web Components call Apex with @AuraEnabled methods, wire adapters, imperative calls, caching, and error handling patterns.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics