Testing Apex in Salesforce: Beyond Code Coverage
When developers first begin writing Apex tests, code coverage often becomes the primary thing they focus on. Salesforce requires a minimum level of coverage before code can be deployed to production, and it's easy to fall into treating that percentage as the goal in itself. The trouble is that a test can execute every line of a method and produce an impressive coverage number while proving almost nothing about whether the code actually works.
Consider a simple test:
@IsTest
static void testAccountProcessing() {
Account account = new Account(Name = 'Test Account');
insert account;
AccountService.processAccount(account);
}
If processAccount() contains a fair amount of logic, this test may well execute most of it, which is enough to satisfy the coverage requirement. But what has it actually proven? Only that the method can run without throwing an exception for this particular input. It has not necessarily proven that:
- the correct fields were populated;
- the correct records were created;
- invalid data was rejected;
- related records were updated; or
- the resulting business state is correct.
That distinction is worth sitting with, because it's the source of most of what follows.
The Coverage Illusion
Coverage is a measurement of which code was executed, not whether that code produced the right result, and a suite can score well on the former while telling you nothing about the latter. If production code changes account.Status__c = 'Approved' to account.Status__c = 'Pending', a test that merely calls the method without checking Status__c will keep passing, and the coverage percentage won't move an inch. The test has detected nothing about a change that matters. Coverage should be treated as a baseline measurement, not a definition of test quality — it tells you a line ran, never whether it ran correctly.
That gap becomes especially costly with automation that has side effects. Suppose a trigger is responsible for creating a related Case whenever an Account meets certain criteria, and the test looks like this:
@IsTest
static void testCreatesCase() {
Account account = new Account(Name = 'Test Account');
insert account;
AccountService.process(account);
}
The test is named testCreatesCase, but it never actually checks that a Case was created — a defect that silently breaks Case creation could ship without this test ever noticing. A meaningful version of the same test queries for the resulting state and asserts against it:
List cases = [
SELECT Id, AccountId, Status
FROM Case
WHERE AccountId = :account.Id
];
Assert.areEqual(1, cases.size(), 'A Case should be created for the Account.');
Assert.areEqual('New', cases[0].Status, 'The Case should start in New status.');
Now the test encodes a business expectation, and if someone later changes the code so the Case stops being created, the test fails — which is the actual point of writing it in the first place.
The Anatomy of a Meaningful Assertion
A useful Apex test generally follows the same shape: arrange the conditions the scenario needs, act by calling the code being tested, then assert that the resulting state matches what the business actually expects.
// Arrange
Account account = new Account(Name = 'Test Account', Status__c = 'New');
insert account;
// Act
AccountService.approveAccount(account.Id);
// Assert
Account result = [SELECT Status__c FROM Account WHERE Id = :account.Id];
Assert.areEqual(
'Approved',
result.Status__c,
'An eligible Account should be moved to Approved status.'
);
The assertion is the part that turns "this code executed" into "this behavior was verified," and it's worth being deliberate about what you assert. Depending on what the requirement actually cares about, that might be a field value, a calculated amount, a record count, or a relationship between two records. The question to ask isn't "what line of code can I assert against," but what result would prove the requirement was satisfied — that framing tends to produce noticeably stronger tests than reflexively asserting whatever happens to be convenient.
Writing Assertion Messages Worth Reading
Apex assertions accept an optional message, and it's worth using it well. Without one, a failure only tells you that the expected and actual values differed; the message is what tells you why that value mattered in the first place. This becomes especially useful once a test contains several assertions in a row, since each failure needs to stand on its own:
Assert.areEqual('Approved', account.Status__c, 'The Account should be approved after processing.');
Assert.areEqual('High', account.Priority__c, 'Approved Accounts should receive High priority.');
Assert.areEqual(true, account.Notification_Required__c, 'An approval notification should be required.');
A good message describes the expected business behavior rather than restating the comparison. 'Status should equal Approved' tells you nothing you couldn't already see from the assertion itself; 'An eligible Account should be moved to Approved status.' tells you why the value matters, which is exactly the context you want when the test fails months later and someone unfamiliar with the code needs to understand what broke.
Testing the Right Data
Another common mistake is asserting against the wrong representation of the data. When the code under test performs DML, it's generally safer to query the record back out rather than trusting the in-memory variable, since that confirms what was actually persisted rather than what you assume happened:
AccountService.processAccount(account.Id);
Account result = [SELECT Status__c, Priority__c FROM Account WHERE Id = :account.Id];
Assert.areEqual('Approved', result.Status__c);
Assert.areEqual('High', result.Priority__c);
The same logic applies to related records. If the requirement involves more than a record simply existing, checking cases.size() alone isn't enough — depending on the requirement, it may also be worth verifying:
- the parent relationship;
- status and record type;
- ownership;
- important field values and dates; or
- any calculated values that represent the business outcome.
The goal isn't to assert every field a record has, only the ones that matter to the requirement being tested.
Negative Testing
A method can have one implementation but many meaningful inputs and outcomes, and testing only the successful case leaves most of the interesting behavior unverified. A strong suite asks not just whether valid data works, but whether invalid data fails the way it's supposed to. Suppose an Account can't be approved without billing information — a positive test confirms the happy path:
Account account = createEligibleAccount();
AccountService.approveAccount(account.Id);
Account result = [SELECT Status__c FROM Account WHERE Id = :account.Id];
Assert.areEqual('Approved', result.Status__c);
but a negative test needs to confirm the opposite scenario actually fails, and fails for the right reason:
Account account = createAccountWithoutBillingInformation();
try {
AccountService.approveAccount(account.Id);
Assert.fail('An Account without billing information should not be approved.');
} catch (Exception e) {
Assert.isTrue(
e.getMessage().contains('billing information'),
'The exception should explain that billing information is required.'
);
}
The exact exception-handling strategy will depend on how the production code is designed, but the important part is that the test deliberately supplies invalid input and verifies the expected failure. Negative tests like this earn their keep around validation rules and required fields, invalid state transitions, duplicate prevention, and authorization checks. A system correctly rejecting bad input is just as much a piece of verified business behavior as one that successfully processes good input.
Bulk Testing
Apex operates under governor limits and is generally expected to handle collections rather than one record at a time, so a test that only processes a single record can miss an entire class of defects. Code that runs a SOQL query inside a loop, for instance, will pass a single-record test without any trouble:
for (Account account : accounts) {
List contacts = [SELECT Id FROM Contact WHERE AccountId = :account.Id];
}
and only reveal itself once a large enough batch runs through it. A bulk test should insert a meaningful number of records and process them together:
List accounts = new List();
for (Integer i = 0; i < 200; i++) {
accounts.add(new Account(Name = 'Test Account ' + i));
}
insert accounts;
Test.startTest();
AccountService.processAccounts(accounts);
Test.stopTest();
But simply processing two hundred records isn't the point — the test still needs to assert the resulting state, confirming both that every record survived the run and that each one received the correct outcome:
List results = [SELECT Id, Status__c FROM Account WHERE Id IN :accounts];
Assert.areEqual(200, results.size(), 'All 200 Accounts should be available for processing.');
for (Account account : results) {
Assert.areEqual('Processed', account.Status__c, 'Every Account should be marked Processed.');
}
This catches bugs a single-record test simply can't expose — logic that accidentally depends on the first record in a collection, or that assumes it will only ever receive one item, won't surface any other way.
Downstream Effects and Integration Payloads
Some Apex operations have consequences beyond the record being directly modified. Updating one record may trigger changes to related records, create new records, or initiate a callout through a mocked integration, and those downstream effects are part of the behavior that should be tested. If approving an Account is supposed to create an onboarding Case, a test that stops at confirming the Account's status has only verified half the requirement:
List cases = [SELECT Id, AccountId, Status, Subject FROM Case WHERE AccountId = :account.Id];
Assert.areEqual(1, cases.size(), 'Approving the Account should create an onboarding Case.');
Assert.areEqual('New', cases[0].Status, 'The onboarding Case should start in New status.');
This matters most when automation crosses object boundaries — an Account update triggering a Flow, which creates a child record, which itself kicks off further automation before the final business state is reached. A meaningful test should verify the parts of that chain the requirement actually depends on.
The same principle applies to integrations. If Apex constructs a request for an external service, a test shouldn't make a real callout — Salesforce's callout mocking facilities let it simulate the response and inspect the request the code generated instead. If an approved Account is supposed to send its account number and classification to an external system, the test should confirm those values are actually present in the generated payload, rather than just proving the integration method ran without error.
The Delete and Verify Mindset
A simple way to evaluate whether a test is doing its job is to imagine the implementation breaking and ask whether the test would notice. Take a piece of production code that sets both account.Status__c = 'Approved' and account.Priority__c = 'High', and a test that only calls the method without asserting either value. Mentally remove the Priority__c line — would the test still pass? If so, that line isn't actually protected by anything. Do the same with the Status__c assignment, and if the test still passes, that requirement isn't protected either. The same thought experiment is worth applying to record creation, field assignments, calculations, status transitions, validation logic, and integration payloads: if removing an important piece of behavior wouldn't cause a test to fail, the test probably needs a stronger assertion.
Bringing This Into Code Review
The same shift is worth bringing into code review. Asking whether a change has enough coverage is necessary but not sufficient. A stronger review asks what business requirements are actually being tested, what happens when the input is invalid, whether the code has been tested against a realistic volume of records, and whether the tests would fail if the implementation were quietly broken. A coverage percentage on its own says very little; being able to point to specific verified behaviors — eligible Accounts become Approved, invalid Accounts are rejected, two hundred records process correctly, the integration payload contains the required fields — says a great deal more about the actual quality of the suite.
Conclusion
Code coverage answers a fairly narrow question: did this test execute this code. A meaningful test answers something much more useful: did this code produce the behavior the business actually expects. A test that runs ninety percent of a class but asserts almost nothing can provide less real protection than a smaller suite built around a handful of precise, well-chosen assertions. The goal was never to satisfy Salesforce's coverage requirement — it's to build a safety net that catches important behavior silently changing out from under you. Coverage tells you where your tests have been; the assertions are what tell you whether they were actually paying attention.