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.