14.2 Writing Unit Tests for Triggers & Classes
Key Takeaways
- Structure tests as Arrange-Act-Assert: create data, exercise production code, then assert outcomes
- Use System.assert / System.assertEquals / System.assertNotEquals or the Assert class to verify expected results
- Triggers are tested indirectly with DML (insert/update/delete/undelete), not by calling the trigger by name
- Write positive, negative, and bulk tests; bulk tests should use enough records to stress collection logic
- Test.startTest() and Test.stopTest() reset governor limits for the act and force async jobs to run; System.runAs tests user context
14.2 Writing Unit Tests for Triggers & Classes
Quick Answer: Write Apex unit tests with Arrange → Act → Assert. Create isolated test data, perform DML or call a service/controller, then
System.assert*/Assert.*the results. Test triggers via DML, not by invoking the trigger name. UseTest.startTest()/Test.stopTest()for a fresh governor window and to flush async work. UseSystem.runAswhen behavior depends on user profile or sharing.
Section 14.1 explained why tests and coverage exist. This section is the craft: how Platform Developer I expects you to structure tests for triggers, service classes, and controllers.
Arrange–Act–Assert
Every solid test follows three phases:
- Arrange — Build records, stub dependencies (e.g.,
HttpCalloutMock), choose the running user. - Act — One focused behavior: insert that fires a trigger, call a service method, invoke a controller action.
- Assert — Query or inspect results; fail with a clear message if reality ≠ expectation.
@IsTest
private class OpportunityDiscountServiceTest {
@IsTest
static void applyDiscount_reducesAmount() {
// Arrange
Opportunity o = new Opportunity(
Name = 'Deal',
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Amount = 1000
);
insert o;
// Act
Test.startTest();
OpportunityDiscountService.applyTenPercent(new Set<Id>{ o.Id });
Test.stopTest();
// Assert
o = [SELECT Amount FROM Opportunity WHERE Id = :o.Id];
System.assertEquals(900, o.Amount, '10% discount should yield 900');
}
}
Keep one primary behavior per test method so failures point to a single cause.
Assertion APIs
Classic assertions (still valid and common on the exam):
System.assert(condition, 'optional message');
System.assertEquals(expected, actual, 'optional message');
System.assertNotEquals(notExpected, actual, 'optional message');
Newer Assert class style (readable, preferred in modern code):
Assert.areEqual(900, o.Amount, '10% discount should yield 900');
Assert.isTrue(o.IsWon == false);
Assert.isNotNull(o.Id);
Always pass a message that states the business rule. When a test fails in CI, the message is your first debug clue.
For DML partial success:
Database.SaveResult sr = Database.insert(record, false);
System.assertEquals(false, sr.isSuccess(), 'Invalid rating should fail');
System.assert(sr.getErrors().size() > 0);
Testing Triggers via DML
You never write MyAccountTrigger.execute(). Triggers fire when you perform DML in the matching context:
| Goal | Act |
|---|---|
| before/after insert logic | insert records |
| update logic | update records (often after insert in arrange) |
| delete logic | delete records |
| undelete logic | delete then undelete |
@IsTest
static void accountTrigger_stampsSourceOnInsert() {
Account a = new Account(Name = 'Bulk Co');
insert a;
a = [SELECT Source_System__c FROM Account WHERE Id = :a.Id];
System.assertEquals('Web', a.Source_System__c,
'before insert trigger should default Source_System__c');
}
If the trigger calls a handler, the same DML still covers both trigger and handler lines when those paths run.
Testing Classes and Controllers
Service / domain classes: call public methods directly after arranging data (fastest, clearest unit tests).
Visualforce controllers / controller extensions: construct the controller (often with a StandardController or custom constructor), set properties, call action methods, then assert view state fields, navigations, or database side effects.
@IsTest
static void vfController_savesAccount() {
Account a = new Account(Name = 'Before');
insert a;
ApexPages.StandardController std = new ApexPages.StandardController(a);
AccountEditExtension ext = new AccountEditExtension(std);
ext.accountRef.Name = 'After';
Test.startTest();
PageReference pr = ext.save();
Test.stopTest();
System.assertNotEquals(null, pr, 'save should return a PageReference');
Account refreshed = [SELECT Name FROM Account WHERE Id = :a.Id];
System.assertEquals('After', refreshed.Name);
}
Lightning Apex @AuraEnabled methods: call them as static methods from tests with the same arguments the client would pass; assert return values and DML.
Positive, Negative, and Bulk Tests
| Type | Purpose | Example |
|---|---|---|
| Positive | Happy path works | Valid Account insert sets defaults |
| Negative | Invalid input fails safely | Missing required field → addError or exception |
| Bulk | Collection logic and limits | 200 Accounts in one DML |
Bulk is especially important for triggers: a test that only inserts one row may miss for loops that query inside loops or list index mistakes. Platform bulk size of 200 records per DML is a common test volume.
@IsTest
static void trigger_handlesTwoHundredAccounts() {
List<Account> rows = new List<Account>();
for (Integer i = 0; i < 200; i++) {
rows.add(new Account(Name = 'Acct ' + i));
}
Test.startTest();
insert rows;
Test.stopTest();
System.assertEquals(200,
[SELECT COUNT() FROM Account WHERE Name LIKE 'Acct %']);
}
Negative example with expected failure:
@IsTest
static void service_rejectsNullId() {
Boolean threw = false;
try {
AccountService.recalculate(null);
} catch (AuraHandledException e) {
threw = true;
}
System.assert(threw, 'Null Id should throw AuraHandledException');
}
(Alternatively assert SaveResult errors when using Database.insert(..., false).)
Test.startTest and Test.stopTest
These methods mark a governor-limit boundary inside a test:
- SOQL/DML/CPU used in Arrange does not consume the same limit bucket as code between start and stop.
- Asynchronous work (
@future, Queueable, Batch, Scheduled) queued beforestopTestis forced to run whenstopTestexecutes, so you can assert results synchronously.
// Arrange heavy data setup outside startTest
insert lotsOfAccounts;
Test.startTest();
// Act: code under test + async enqueue
System.enqueueJob(new CleanupJob());
Test.stopTest(); // async runs here
// Assert post-async state
Call one pair of start/stop per test method (nesting is not the pattern to learn for the exam).
System.runAs for User Context
Sharing rules, FLS, CRUD, and with sharing classes behave differently by user. Create a user in test context (see 14.3 for data isolation) and run code as that user:
@IsTest
static void withSharing_hidesOtherOwnerRows() {
Profile p = [SELECT Id FROM Profile WHERE Name = 'Standard User' LIMIT 1];
User u = new User(
Alias = 'tuser',
Email = 'tuser@example.com',
EmailEncodingKey = 'UTF-8',
LastName = 'Test',
LanguageLocaleKey = 'en_US',
LocaleSidKey = 'en_US',
TimeZoneSidKey = 'America/Los_Angeles',
ProfileId = p.Id,
UserName = 'tuser' + DateTime.now().getTime() + '@example.com'
);
insert u;
System.runAs(u) {
// DML and queries run as u
insert new Account(Name = 'Owned by test user');
// assert visibility / update permissions as needed
}
}
runAs does not enforce every UI permission the same way the Lightning UI does, but it is the standard tool for Apex sharing and user-context tests.
Common Patterns Checklist
- Single-behavior methods with descriptive names (
trigger_setsStatus_onInsert). - Query after DML before asserting field values written by triggers/workflows/flows (note: automation outside Apex may also fire—design tests for the org you own).
@TestSetupfor shared data (next section) to keep tests fast and clear.- Mocks for callouts (
Test.setMock) so tests never depend on real HTTP. - No hard-coded org IDs from production; create what you need in the test.
Exam Checklist
- Arrange–Act–Assert is the unit-test spine.
- Assert with
System.assert*orAssert.*and meaningful messages. - Triggers are exercised by DML, classes/controllers by method calls.
- Include positive, negative, and bulk scenarios.
startTest/stopTestreset limits for the act and run async to completion;runAssets user context.
Next: how test data is isolated so these patterns stay reliable across orgs.
How should a developer unit-test an after-insert Account trigger that stamps a custom field?
What is a primary benefit of Test.startTest() and Test.stopTest() inside an Apex unit test?
A developer needs to verify that a with sharing service only returns Accounts the running user can see. Which approach fits best?