Apex Testing
These are technical notes I compiled while studying using Trailhead, Salesforce's free self-learning portal.
Reference Info from Apex Developer Guide:
Get Started with Apex Unit Tests
Describe the key benefits of Apex unit tests. Define a class with test methods. Execute all test methods in a class and inspect failures. Create and execute a suite of test classes.- Apex Unit Tests
- Testing is key to successful long-term development - it is a critical component of the development process
- Apex code can only be written in a sandbox environment or Developer org, not production
- App developers can distribute Apex code to customers from their Developer orgs by uploading packages to the Lightning Platform AppExchange
- Apex unit tests are a requirement for deploying and distributing Apex
- Benefits of Apex Unit Tests:
- Ensures your Apex classes and triggers work as expected
- Regression tests ensure future updates you make don’t break existing functionality
- Before each major service upgrade, Salesforce picks orgs selectively (not all orgs) and runs all tests on its behalf through a process called Apex Hammer
- Apex Hammer runs in the current version and next release and compares the test results - this ensures that custom code behavior hasn’t been altered as a result of service upgrades
- Code Coverage Requirement for Deployment
- Before you can deploy code or package it for the Lightning Platform AppExchange, at least 75% of Apex code must be covered by tests, and all those tests must pass
- Additionally, each trigger must have some code coverage
- Don’t just write tests to meet this requirement - test the common use cases in your app, including:
- Positive test cases
- Negative test cases
- Bulk processing
- Single-Record processing
- Before you can deploy code or package it for the Lightning Platform AppExchange, at least 75% of Apex code must be covered by tests, and all those tests must pass
- Test Method Syntax
- Test methods are defined using
@isTest
, with the following syntax- Visibility of a test method doesn’t mater, so declaring a test method as public or private doesn’t make a difference - testing framework is always able to access test methods
- Test methods must be defined within Test classes, which are classes annotated with
@isTest
- Test classes can be either public or private
- If you’re using using a test class for unit testing only, declare it as private
- Public test classes are typically used for test data factory classes
- Test methods are defined using
// Test Method
@isTest static void testName() {
// code_block
}
// Test Class
@isTest
private class MyTestClass {
@isTest static void myTest() {
// code_block
}
}
- Unit Test Example: Test the
TemperatureConverter
Class (1 of 2)- Create the following classes in Apex - Developer Console > File > New > Apex Class,
TemperatureConverter
(thenTemperatureConverterTest
), “OK”
- Create the following classes in Apex - Developer Console > File > New > Apex Class,
public class TemperatureConverter {
// Takes a Fahrenheit temperature and returns the Celsius equivalent.
public static Decimal FahrenheitToCelsius(Decimal fh) {
Decimal cs = (fh - 32) * 5/9;
return cs.setScale(2);
}
}
@isTest
private class TemperatureConverterTest {
@isTest static void testWarmTemp() {
Decimal celsius = TemperatureConverter.FahrenheitToCelsius(70);
System.assertEquals(21.11,celsius);
}
@isTest static void testFreezingPoint() {
Decimal celsius = TemperatureConverter.FahrenheitToCelsius(32);
System.assertEquals(0,celsius);
}
@isTest static void testBoilingPoint() {
Decimal celsius = TemperatureConverter.FahrenheitToCelsius(212);
System.assertEquals(100,celsius,'Boiling point temperature is not expected.');
}
@isTest static void testNegativeTemp() {
Decimal celsius = TemperatureConverter.FahrenheitToCelsius(-10);
System.assertEquals(-23.33,celsius);
}
}
- Unit Test Example: Test the
TemperatureConverter
Class (2 of 2)- Verifications are performed by calling
System.assertEquals()
, which takes two parameters and a third optional parameter- 1st parameter is expected value:
100
- 2nd parameter is actual value:
celsius
- 3rd parameter is string that describes comparison:
Boiling point temperature is not expected.
- 1st parameter is expected value:
- To run the tests, in the Developer Console, select Test > New Run. Then under Test Classes, click
TemperatureConverter
Test, then Add Selected, then Run- Results show up under the “Run” tab, see below
- Code Coverage is automatically generated for the Apex classes and triggers in the org
- A known issue with the Developer Console prevents it from update code coverage correctly when running a subset of tests - to update code coverage results, use Test > Run All rather than Test > New Run
- If a test is failed, the error will be returned. For example:
System.AssertException: Assertion Failed: Boiling point temperature is not expected.: Expected: 0, Actual: 100.00
- Verifications are performed by calling
- Increase Your Code Coverage
- When writing tests, try to achieve the highest code coverage possible, not just 75% coverage
- Sometimes even after writing test methods for all class methods, code coverage is still not at 100% - commonly this is due to not covering all data values for conditional code execution
- Some values tend to be ignored when your class method has if statements that cause different branches to be executed. Ensure all values are tested.
- Example below includes the class method
getTaskPriority()
, which includes twoif
statements- Note the equality operator
==
performs case-insensitive string operations (‘ca’ == ‘CA’)
- Note the equality operator
- When writing tests, try to achieve the highest code coverage possible, not just 75% coverage
public class TaskUtil {
public static String getTaskPriority(String leadState) {
// Validate input
if (String.isBlank(leadState) || leadState.length() > 2) {
return null;
}
String taskPriority;
if (leadState == 'CA') {
taskPriority = 'High';
} else {
taskPriority = 'Normal';
}
return taskPriority;
}
}
- Test class below tests
getTaskPriority()
@isTest
private class TaskUtilTest {
@isTest static void testTaskPriority() {
String pri = TaskUtil.getTaskPriority('NY');
System.assertEquals('Normal', pri);
}
}
- After running the
TaskUtilTest
, reopenTaskUtil
, then select the Code Coverage: None, and change the selection to Code Coverage: TaskUtilTest.testTaskPriority 75%- The covered and uncovered lines are shown in blue and red, respectively
- Updating the test class as shown below to include ‘CA’ and an invalid input, then retesting, yields a 100% code coverage, as shown below
@isTest
private class TaskUtilTest {
@isTest static void testTaskPriority() {
String pri = TaskUtil.getTaskPriority('NY');
System.assertEquals('Normal', pri);
}
@isTest static void testTaskHighPriority() {
String pri = TaskUtil.getTaskPriority('CA');
System.assertEquals('High', pri);
}
@isTest static void testTaskPriorityInvalid() {
String pri = TaskUtil.getTaskPriority('Montana');
System.assertEquals(null, pri);
}
}
- Create and Execute a Test Suite
- Test Suites are collections of Apex test classes that you run together
- Ex: create a suite of tests that you run every time you prepare for a deployment or Salesforce releases a new version
- Create a test suite via: Setup > Developer Console > Test > New Suite, provide a name (such as
TempConverterTaskUtilSuite
), select the appropriate classes to include, then save - Thereafter, run with Developer Console > Test > New Suite Run
- Test Suites are collections of Apex test classes that you run together
- Create Test Data
- Salesforce records created in test methods aren’t committed to the database - they’re rolled back after the test executes
- By default, Apex tests don’t have access to pre-existing data in the org, except access to setup and metadata objects like User and Profile objects
- Creating test data makes your tests more robust and prevents failures caused by missing or changed data in the org
- Possible to create test data directly in the test method, or by using a utility test class
- It goes against best practices, but its possible to allow test methods access to pre-existing data using the annotation
@isTest(SeeAllData=true)
- Salesforce records created in test methods aren’t committed to the database - they’re rolled back after the test executes
- More Info
- Can save up to 6 MB of Apex in each org - test classes (with
@isTest
) don’t count toward this limit - Test data is rolled back but there is no separate test database - unique constraints on the production data can still cause errors when inserting duplicate sObject records
- Test methods don’t send emails or make callouts to external services
- SOSL searches performed in a test return empty results - to ensure predictable results, use
Test.setFixedSearchResults()
to define the records to be returned by the search.
- Can save up to 6 MB of Apex in each org - test classes (with
Example from Trailhead
public class VerifyDate {
// Method to handle potential checks against two dates
public static Date CheckDates(Date date1, Date date2) {
// If date2 is within the next 30 days of date1, use date2.
// Otherwise use the end of the month
if(DateWithin30Days(date1,date2)) {
return date2;
} else {
return SetEndOfMonthDate(date1);
}
}
// Method to check if date2 is within the next 30 days of date1
private static Boolean DateWithin30Days(Date date1, Date date2) {
// Check for date2 being in the past
if( date2 < date1) {
return false;
}
// Check that date2 is within (>=) 30 days of date1
Date date30Days = date1.addDays(30);
// Create a date 30 days away from date1
if( date2 >= date30Days ) {
return false;
}
else {
return true;
}
}
// Method to return the end of the month of a given date
private static Date SetEndOfMonthDate(Date date1) {
Integer totalDays = Date.daysInMonth(date1.year(), date1.month());
Date lastDay = Date.newInstance(date1.year(), date1.month(), totalDays);
return lastDay;
}
}
My Solution
@isTest
public class TestVerifyDate {
@isTest static void testSameDate() {
System.assertEquals(
VerifyDate.CheckDates(Date.newInstance(2022, 10, 26),
Date.newInstance(2022, 10, 26)),
Date.newInstance(2022, 10, 26)
);
}
@isTest static void testDateInversion() {
System.assertEquals(
VerifyDate.CheckDates(Date.newInstance(2022, 10, 26),
Date.newInstance(2022, 10, 25)),
Date.newInstance(2022, 10, 26)
);
}
@isTest static void testMoreThan30DayDelta() {
System.assertEquals(
VerifyDate.CheckDates(Date.newInstance(2022, 08, 26),
Date.newInstance(2022, 10, 26)),
Date.newInstance(2022, 10, 26)
);
}
}
Test Apex Triggers
Write a test for a trigger that fires on a single record operation. Execute all test methods in a class.- Test Apex Triggers
- Before deploying a trigger, write unit tests to perform the actions that fire the trigger and verify it gives the expected results
- Example trigger
AccountDeletion
below prevents deletion of an Account if it has related Opportunities
- Prerequisites
- Create a new trigger via: Developer Console > File > New > Apex Trigger
trigger AccountDeletion on Account (before delete) {
// Prevent the deletion of accounts if they have related opportunities.
for (Account a : [SELECT Id FROM Account
WHERE Id IN (SELECT AccountId FROM Opportunity)
AND Id IN :Trigger.old]) {
Trigger.oldMap.get(a.Id).addError(
'Cannot delete account with related opportunities.');
}
}
- Add and Run a Unit Test
- Test method below first sets up a test account with an opportunity, then deletes the test account, which fires the
AccountDeletion
trigger. - Test method verifies that the trigger prevented the deletion by checking the return value of
Database.delete()
- return value of that call is aDatabase.DeleteResult
object that contains information about the delete operation - Test method verifies that the delete was not successful as well as the error message obtained
- Run the test by selecting Test > New Run
- Test method below first sets up a test account with an opportunity, then deletes the test account, which fires the
@isTest
private class TestAccountDeletion {
@isTest static void TestDeleteAccountWithOneOpportunity() {
// Test data setup
// Create an account with an opportunity, and then try to delete it
Account acct = new Account(Name='Test Account');
insert acct;
Opportunity opp = new Opportunity(Name=acct.Name + ' Opportunity',
StageName='Prospecting',
CloseDate=System.today().addMonths(1),
AccountId=acct.Id);
insert opp;
// Perform test
Test.startTest();
Database.DeleteResult result = Database.delete(acct, false);
Test.stopTest();
// Verify
// In this case the deletion should have been stopped by the trigger,
// so verify that we got back an error.
System.assert(!result.isSuccess());
System.assert(result.getErrors().size() > 0);
System.assertEquals('Cannot delete account with related opportunities.',
result.getErrors()[0].getMessage());
}
}
- More Info
- Test method contains
Test.startTest()
andTest.stopTest()
method pair, which delimits a block of code that gets a fresh set of governor limits.
- Test method contains
Example from Trailhead
trigger RestrictContactByName on Contact (before insert, before update) {
//check contacts prior to insert or update for invalid data
For (Contact c : Trigger.New) {
if(c.LastName == 'INVALIDNAME') { //invalidname is invalid
c.AddError('The Last Name "'+c.LastName+'" is not allowed for DML');
}
}
}
My Solution
@isTest
private class TestRestrictContactByName {
@isTest static void insertInvalidName() {
Contact c = new Contact(LastName='INVALIDNAME');
Test.startTest();
Database.SaveResult sr = Database.insert(c, false);
Test.stopTest();
System.assert(!result.isSuccess());
System.assert(result.getErrors().size() > 0);
System.assertEquals('The Last Name "INVALIDNAME" is not allowed for DML',
sr.getErrors()[0].getMessage());
}
}
Create Test Data for Apex Tests
Create a test utility class. Use a test utility method to set up test data for various test cases. Execute all test methods in a class.- Create Test Data for Apex Tests
- Use test utility classes to add reusable methods for test data setup
- Add a Test Utility Class
- This section refactors the previous test method by replacing test data creation with a call to a utility class method
TestDataFactory
is a special type of class that is public and annotated with@isTest
and can only be accessed from a running test- Test utility classes contain methods that can be called by test methods to perform useful tasks, such as setting up test data
- Test utility classes are excluded from the org’s size limit
@isTest
public class TestDataFactory {
public static List<Account> createAccountsWithOpps(Integer numAccts, Integer numOppsPerAcct) {
List<Account> accts = new List<Account>();
for(Integer i=0;i<numAccts;i++) {
Account a = new Account(Name='TestAccount' + i);
accts.add(a);
}
insert accts;
List<Opportunity> opps = new List<Opportunity>();
for (Integer j=0;j<numAccts;j++) {
Account acct = accts[j];
// For each account just inserted, add opportunities
for (Integer k=0;k<numOppsPerAcct;k++) {
opps.add(new Opportunity(Name=acct.Name + ' Opportunity ' + k,
StageName='Prospecting',
CloseDate=System.today().addMonths(1),
AccountId=acct.Id));
}
}
// Insert all opportunities for all accounts.
insert opps;
return accts;
}
}
- Call Utility Methods for Test Data Creation
- Modify the
TestAccountDeletion
class to leverage the new test utility class
- Modify the
@isTest
private class TestAccountDeletion {
@isTest static void TestDeleteAccountWithOneOpportunity() {
// Test data setup
// Create one account with one opportunity by calling a utility method
Account[] accts = TestDataFactory.createAccountsWithOpps(1,1);
// Perform test
Test.startTest();
Database.DeleteResult result = Database.delete(accts[0], false);
Test.stopTest();
// Verify that the deletion should have been stopped by the trigger,
// so check that we got back an error.
System.assert(!result.isSuccess());
System.assert(result.getErrors().size() > 0);
System.assertEquals('Cannot delete account with related opportunities.',
result.getErrors()[0].getMessage());
}
}
- Test for Different Conditions, such as when:
- An account without opportunities is deleted, and
- A bulk number of records instead of just a single record is deleted. See below.
@isTest
private class TestAccountDeletion {
@isTest static void TestDeleteAccountWithOneOpportunity() {
// Test data setup
// Create one account with one opportunity by calling a utility method
Account[] accts = TestDataFactory.createAccountsWithOpps(1,1);
// Perform test
Test.startTest();
Database.DeleteResult result = Database.delete(accts[0], false);
Test.stopTest();
// Verify that the deletion should have been stopped by the trigger,
// so check that we got back an error.
System.assert(!result.isSuccess());
System.assert(result.getErrors().size() > 0);
System.assertEquals('Cannot delete account with related opportunities.',
result.getErrors()[0].getMessage());
}
@isTest static void TestDeleteAccountWithNoOpportunities() {
// Test data setup
// Create one account with no opportunities by calling a utility method
Account[] accts = TestDataFactory.createAccountsWithOpps(1,0);
// Perform test
Test.startTest();
Database.DeleteResult result = Database.delete(accts[0], false);
Test.stopTest();
// Verify that the deletion was successful
System.assert(result.isSuccess());
}
@isTest static void TestDeleteBulkAccountsWithOneOpportunity() {
// Test data setup
// Create accounts with one opportunity each by calling a utility method
Account[] accts = TestDataFactory.createAccountsWithOpps(200,1);
// Perform test
Test.startTest();
Database.DeleteResult[] results = Database.delete(accts, false);
Test.stopTest();
// Verify for each record.
// In this case the deletion should have been stopped by the trigger,
// so check that we got back an error.
for(Database.DeleteResult dr : results) {
System.assert(!dr.isSuccess());
System.assert(dr.getErrors().size() > 0);
System.assertEquals('Cannot delete account with related opportunities.',
dr.getErrors()[0].getMessage());
}
}
@isTest static void TestDeleteBulkAccountsWithNoOpportunities() {
// Test data setup
// Create accounts with no opportunities by calling a utility method
Account[] accts = TestDataFactory.createAccountsWithOpps(200,0);
// Perform test
Test.startTest();
Database.DeleteResult[] results = Database.delete(accts, false);
Test.stopTest();
// For each record, verify that the deletion was successful
for(Database.DeleteResult dr : results) {
System.assert(dr.isSuccess());
}
}
}
Example Contact Test Factory
public class RandomContactFactory {
public static List<Contact> generateRandomContacts (Integer numContacts, String lastName) {
List<Contact> contacts = new List<Contact>();
for (Integer i=0; i<numContacts; i++) {
String firstName = 'Test ' + i;
Contact c = new Contact(FirstName=firstName,
LastName=lastName);
contacts.add(c);
}
return contacts;
}
}