Study guide
14.2 Writing Unit Tests for Triggers & Classes
Salesforce Certified Platform Developer I Exam
Track your progress as you read
Finished sections get a check mark.
1 Introduction & Exam Overview
1.1 Current Exam Facts 1.2 Blueprint Weights & Study Strategy
2 Platform Fundamentals
2.1 Multi-Tenant Architecture & Shared Resources 2.2 MVC Mapping on the Lightning Platform 2.3 Declarative vs Programmatic Decisions 2.4 Agentforce for Developers: Use Cases & Limitations
3 Data Modeling & Schema
3.1 Objects, Fields & Schema Design 3.2 Relationship Types & Implications 3.3 Schema Design Impact on Apex 3.4 Data Import & Export for Development
4 Platform Data Features
4.1 Formula Fields & Roll-Up Summaries 4.2 Core CRM Objects & Standard Schema 4.3 AppExchange Extensions & Platform Boundaries
5 Declarative Process Automation
5.1 Record-Triggered Flows & Declarative Automation 5.2 Approval Processes & Business Rules 5.3 When to Use Apex vs Declarative Tools
6 Apex Language Basics
6.1 Variables, Constants & Apex Data Types 6.2 Lists, Sets & Maps 6.3 Control Flow & Exception Handling 6.4 Classes, Interfaces & OOP Patterns
7 SOQL, SOSL & DML
7.1 SOQL Query Fundamentals 7.2 SOSL Search Fundamentals 7.3 DML Operations & Bulkification 7.4 Governor Limits & Transaction Boundaries
8 Apex Triggers & Order of Execution
8.1 Trigger Structure & Context Variables 8.2 Bulk Trigger Patterns & Best Practices 8.3 Save Order of Execution 8.4 Recursion, Cascading & Transaction Control
9 Asynchronous Apex
9.1 @future Methods 9.2 Queueable Apex 9.3 Batch Apex 9.4 Schedulable Apex & Job Orchestration
10 Security, Events & Schema Access
10.1 Sharing, FLS, CRUD & Injection Prevention 10.2 Platform Events Publish & Subscribe 10.3 Programmatic Schema Access (Describe)
11 Visualforce
11.1 Visualforce Pages & Markup 11.2 Standard, Custom Controllers & Extensions 11.3 Visualforce in Lightning Experience
12 Lightning Components
12.1 Lightning Component Framework Benefits 12.2 Aura Component Structure & Resources 12.3 Lightning Web Components Fundamentals 12.4 LWC Data Access: Wire, Imperative & Events
13 UI Composition & Apex Integration Patterns
13.1 Composing UI in Lightning Apps 13.2 UI Security, Performance & Accessibility Patterns 13.3 Apex Actions for Flow, LWC, and Agentforce
14 Apex Testing
14.1 Testing Framework & Coverage Requirements 14.2 Writing Unit Tests for Triggers & Classes 14.3 Test Data Strategies & Isolation 14.4 Executing Tests & Execute Anonymous Differences
15 Debugging & Developer Tools
15.1 Debug Logs & Monitoring 15.2 Developer Console, CLI, VS Code & Workbench 15.3 Sandboxes, Scratch Orgs & Environment Strategy
16 Deployment & Release
16.1 Change Sets & Metadata Deployment 16.2 Unlocked Packages, Metadata API & Salesforce DX 16.3 Release Readiness & Credential Maintenance
14.2 Writing Unit Tests for Triggers & Classes
Key Takeaways Explain This Topic AI
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
Last updated: August 2026
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. Use Test.startTest() / Test.stopTest() for a fresh governor window and to flush async work. Use System.runAs when 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 before stopTest is forced to run when stopTest executes, 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).
@TestSetup for 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* or Assert.* and meaningful messages.
Triggers are exercised by DML , classes/controllers by method calls .
Include positive, negative, and bulk scenarios.
startTest / stopTest reset limits for the act and run async to completion; runAs sets user context.
Next: how test data is isolated so these patterns stay reliable across orgs.