Apex Testing

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
  • 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 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 (then TemperatureConverterTest), “OK”
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.
    • 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

  • 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 two if statements
      • Note the equality operator == performs case-insensitive string operations (‘ca’ == ‘CA’)
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, reopen TaskUtil, 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

  • 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)

  • 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.

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 a Database.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
@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() and Test.stopTest() method pair, which delimits a block of code that gets a fresh set of governor limits.

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
@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;
    }
}