Testing Apex · Lesson 33 of 41
Apex Test Classes: Writing Real Unit Tests
Salesforce requires at least 75% code coverage to deploy Apex to production. That rule is why every developer writes tests – but coverage only proves that lines ran, not that they did the right thing. Good tests check behaviour: the right values, the right errors, the right results with 200 records. This lesson shows how to write those tests.
In this lesson
- Anatomy of a test class
- The Assert class
- Test data, @TestSetup and isolation
- startTest/stopTest, runAs
- Positive, negative and bulk tests – and coverage
The code under test
// DiscountService.cls
public with sharing class DiscountService {
public class DiscountException extends Exception {}
public static final Decimal MAX_DISCOUNT = 0.30;
// applies a discount percentage to open opportunities; returns the number changed
public static Integer applyDiscount(List<Opportunity> opps, Decimal percent) {
if (percent == null || percent <= 0 || percent > MAX_DISCOUNT) {
throw new DiscountException('Discount must be between 0 and ' + (MAX_DISCOUNT * 100) + '%');
}
List<Opportunity> changed = new List<Opportunity>();
for (Opportunity opp : opps) {
if (opp.IsClosed || opp.Amount == null) continue;
opp.Amount = (opp.Amount * (1 - percent)).setScale(2);
changed.add(opp);
}
update changed;
return changed.size();
}
}
The test class
// DiscountServiceTest.cls
@IsTest
private class DiscountServiceTest {
@TestSetup // runs once; each test gets a fresh copy
static void makeData() {
insert new List<Opportunity>{
new Opportunity(Name = 'Open', StageName = 'Prospecting', CloseDate = Date.today().addDays(30), Amount = 1000),
new Opportunity(Name = 'Won', StageName = 'Closed Won', CloseDate = Date.today(), Amount = 1000),
new Opportunity(Name = 'No amount', StageName = 'Prospecting', CloseDate = Date.today().addDays(30))
};
}
@IsTest
static void discountsOnlyOpenOpportunitiesWithAnAmount() {
// Arrange
List<Opportunity> opps = [SELECT Name, Amount, IsClosed FROM Opportunity];
// Act
Test.startTest();
Integer changed = DiscountService.applyDiscount(opps, 0.10);
Test.stopTest();
// Assert
Assert.areEqual(1, changed, 'only the open opportunity with an amount changes');
Assert.areEqual(900.00, [SELECT Amount FROM Opportunity WHERE Name = 'Open'].Amount);
Assert.areEqual(1000.00, [SELECT Amount FROM Opportunity WHERE Name = 'Won'].Amount, 'closed deals keep their amount');
}
@IsTest
static void rejectsTooLargeDiscount() {
try {
DiscountService.applyDiscount([SELECT Amount, IsClosed FROM Opportunity], 0.5);
Assert.fail('expected a DiscountException');
} catch (DiscountService.DiscountException e) {
Assert.isTrue(e.getMessage().contains('between 0 and 30'), e.getMessage());
}
}
@IsTest
static void handlesTwoHundredRecords() {
List<Opportunity> many = new List<Opportunity>();
for (Integer i = 0; i < 200; i++) {
many.add(new Opportunity(Name = 'Bulk ' + i, StageName = 'Prospecting', CloseDate = Date.today().addDays(10), Amount = 100));
}
insert many;
Test.startTest();
Integer changed = DiscountService.applyDiscount([SELECT Amount, IsClosed FROM Opportunity WHERE Name LIKE 'Bulk%'], 0.25);
Test.stopTest();
Assert.areEqual(200, changed);
Assert.areEqual(200, [SELECT COUNT() FROM Opportunity WHERE Name LIKE 'Bulk%' AND Amount = 75]);
}
}
✓ DiscountServiceTest.discountsOnlyOpenOpportunitiesWithAnAmount ✓ DiscountServiceTest.handlesTwoHundredRecords ✓ DiscountServiceTest.rejectsTooLargeDiscount Tests: 3 passed, 0 failed, 3 total Coverage DiscountService: 100% (11/11 lines)
@IsTeston the class and each test method. Test classes are usuallyprivateand don't count toward your org's code size limit.- Structure every test as Arrange – Act – Assert.
- Each test runs in its own transaction and everything it does is rolled back afterwards.
- Three kinds of test cover most logic: the happy path, the error path, and a bulk test with 200 records.
The Assert class
| Method | Checks |
|---|---|
Assert.areEqual(expected, actual, msg) | values are equal |
Assert.areNotEqual(a, b, msg) | values differ |
Assert.isTrue(cond, msg) / isFalse | a condition |
Assert.isNull(v, msg) / isNotNull | null or not |
Assert.isInstanceOfType(obj, Type, msg) | runtime type |
Assert.fail(msg) | always fails – put it after code that should have thrown |
The older System.assertEquals still works, but the Assert class reads better and puts the expected value first. Always add a message: when a test fails at 2 AM in a deployment, "expected 1 but got 0: only the open opportunity with an amount changes" saves real time.
Test data and isolation
- Tests don't see org data by default (except setup data like users and profiles). Create what you need – this makes tests reliable in any org.
@TestSetupcreates common records once; every test method gets its own fresh copy.- Never use
@IsTest(SeeAllData=true)unless you truly must (a few objects can't be created in tests). - Don't hard-code record Ids – they differ between orgs.
startTest, stopTest and runAs
// LimitsAndUsersTest.cls
@IsTest
private class LimitsAndUsersTest {
@IsTest
static void startTestGivesFreshLimits() {
for (Integer i = 0; i < 90; i++) Integer n = [SELECT COUNT() FROM Account]; // setup uses 90 queries
Test.startTest();
Assert.areEqual(0, Limits.getQueries(), 'the code under test gets a fresh set of limits');
Test.stopTest();
}
@IsTest
static void runAsChecksAnotherUsersAccess() {
Profile p = [SELECT Id FROM Profile WHERE Name = 'Standard User'];
User u = new User(Alias = 'stdu', Email = 'stdu@example.com', EmailEncodingKey = 'UTF-8', LastName = 'Std',
LanguageLocaleKey = 'en_US', LocaleSidKey = 'en_US', ProfileId = p.Id, TimeZoneSidKey = 'Asia/Kolkata',
Username = 'stdu' + System.now().getTime() + '@lf-example.com');
insert u;
System.runAs(u) {
Assert.areEqual(u.Id, UserInfo.getUserId());
Assert.isTrue(Schema.SObjectType.Account.isCreateable(), 'standard users can create accounts');
Assert.isFalse(Schema.SObjectType.User.fields.ProfileId.isUpdateable(), 'but cannot change profiles');
}
}
}
✓ LimitsAndUsersTest.runAsChecksAnotherUsersAccess ✓ LimitsAndUsersTest.startTestGivesFreshLimits Tests: 2 passed, 0 failed, 2 total
Test.startTest()gives the code between it andstopTest()its own fresh governor limits, separate from your setup code – so the test measures the code, not the setup.Test.stopTest()also runs any queued async work (future, Queueable, Batch, Schedulable) synchronously.System.runAs(user)runs code as another user – sharing and the user's permissions apply towith sharingand user-mode code inside.
Code coverage
The run above reports 100% coverage of DiscountService. Coverage is required (75% org-wide, and every trigger must have some), but treat it as a side effect of testing behaviour, not the goal. A test with no assertions can reach 100% coverage and prove nothing. In VS Code, "SFDX: Run Apex Tests" with coverage highlights uncovered lines in the editor; sf apex run test --code-coverage reports it in the terminal.
Practice
Try it yourself
Add a test that a
nullpercent throws a DiscountException.Show answer
@IsTest static void rejectsNullPercent() { try { DiscountService.applyDiscount(new List<Opportunity>(), null); Assert.fail('expected exception'); } catch (DiscountService.DiscountException e) { Assert.isNotNull(e.getMessage()); } }A test passes in the sandbox but fails in production with "List has no rows". What's the likely cause?
Show answer
It relies on existing org data (SeeAllData or a hard-coded record) that isn't in production. Create all test data inside the test.
Quick check
What coverage does production deployment require?
75% overall, and every trigger must be covered.
Do tests see existing org records?
No (unless SeeAllData=true) – create test data in the test.
What two things does Test.stopTest() do?
Ends the fresh-limits block and runs queued asynchronous work.
Summary
- Write tests that assert behaviour: happy path, error path, bulk.
- Use the Assert class with messages; Arrange – Act – Assert.
- Create data in @TestSetup or the test; never depend on org data.
- Use startTest/stopTest for limits and async, runAs for users; coverage follows.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.