Test Class Best Practices - A Pattern

Introduction – Why and What?

As we all know, Test Classes are integral to the success of any APEX coding. Moreover, the OUTCOME is just as (if not more) important as the coverage. This document will detail a test class pattern that can be applied and be appropriate for 90+% of the time. At this time, it does not address VisualForce, but that will be discussed in the future.

The “What” is explained in the following. But the “WHY” is always more important. Because with the “Why”, you can infer the “What”. This pattern will help make this development repeatable as well as allow you to focus on the actual test instead of repeating actions, code, and DML's over and over.

Non-VisualForce Pattern

Below is a shell of the pattern that should be followed. What follows is an explanation of each piece and why they are so important.

@isTest (SeeAllData=false)

private class ObjectOrClass_IsTest {

  static final integer nNbrRecords = 5;

  // Set low to get out bugs, and then 200 when done

  // Alternatively, you can put this in a Custom Metadata Type record for reference in any environment.

  @TestSetup

  static void SetupData() {

    // Setup required data here

    setupUsers();

  } // END SetupData

  @future

  static void setupUsers() {

    // Use this block to setup any required user(s) for your tests

  } // END setupUsers

  static testMethod void testMyFunctionality1() {

    // Get required data here

    User usrTest = [select ID from User where UserName = '???’ LIMIT 1][0];

    System.runAs(usrTest) {

      Test.StartTest();

      // Do actual required testing here – MUST INCLUDE ASSERTIONS!

      Test.StopTest();

    } // END System.runAs

    // If you had Async events, test them here

  } // END testMyFunctionality1

} // END ObjectOrClass_IsTest
 
  

@IsTest (SeeAllData = false) Annotation

This is the first line in any test class and tells Salesforce this is a Test Class. The first thing this does for you is it allows Salesforce to find your Test Classes separate from other standard classes. 

The other thing it does is tell Salesforce to segregate the tests in this class to only see the data they create. Other than Metadata (User, Profile, Role, Custom MDT, etc), no data will be visible unless you create it.

The SeeAllData = false is MANDATORY unless you need True for some reason. Indeed, this is the default starting with API version 24. However, I like to have it explicitly specified so that those who follow understand it right away. This reason to use True should be very much the exception and “it’s easier” should never be the reason. There are VERY few reasons for this being set to True and be acceptable. Custom Settings, Approval Processes, etc can all be created in your test classes.

Test Class definition

This is a class definition much like any other. Two differences though. First, it is declared Private because it’s not meant to be called from anywhere but Salesforce test processing. Second, naming conventions are to name the class the same as what it’s testing, with “_IsTest” padded to the name for ease in locating these tests.

Global Variable Definition

This area is much like the same area in other standard classes. Use this for any variables you may want to use throughout the Test class. In the template sample provided in this document, I included a variable that will be used to determine the number of records to Insert/Update for my tests. By doing this, you can set this to 5, get your class working, and then change it to 200 for the full test by changing it in one place.

@TestSetup Annotation

A VERY important piece of a Test Class. This method will fire the initial time the class is processed and in a different thread. Once completed, your methods will be processed (though the order cannot be predicted). 

The reason being in a different thread is important is because anything it does cannot be communicated back to the class. Meaning, if you have a global variable for an Account object, and you set it with the results from this method, the global variable Account object will NOT be populated if read from your test methods.

Use this class to create any data your class will need in general for the rest of the tests. Custom Settings, if needed, should be created first, and then any objects.

Setup Data ONCE Definition

This is standard method notation. Simply defining the method that will be executed immediately upon the start of the class. Make sure to provide a descriptive name if you are setting up something specific.

Setup Data ONCE

This is the first area where what goes into the method is up to you. You must know what is required for your test class to test your desired functionality. For our purposes, let’s assume we have a trigger that fires a Service to update all Contacts when a certain condition occurs.

In this case, you would need all of the custom settings needed for Account and Contact triggers (and any others that those triggers use) to be created first. Then, you would create your desired Accounts and Contacts to be used for the test.

A best practice here, is create all of your custom settings into a sObject LIST, no matter how many different custom settings you may have. For example:

User migUser = [Select ID From User Where UserName Like 'migration@citrix.com%' LIMIT 1][0];
string masterAcctRTID = Schema.SObjectType.Account.getRecordTypeInfosByName().get('Master Account').getRecordTypeId();

LIST<sObject> sObjCSLIST = new LIST<sObject>();
sObjCSLIST.add(new CS_Key_IDs__c(Name = 'Migration', Record_ID__c = migUser.ID));
sObjCSLIST.add(new CS_Key_IDs__c(Name = 'Master Account', Record_ID__c = masterAcctRTID));
sObjCSLIST.add(new CS_Diff_Setting__c(Name = 'My Name', My_Field__c = 'MyValue'));
insert sObjCSLIST;

Notice how, even though we had 2 different Custom Setting types, we could insert them in a single call. Also notice how I got the desired Record Type without a SOQL. This is another best practice.

Similarly, you can now do the same for the objects you require if they don’t relate to each other. If they do, then of course you need to insert each object first and use that ID to relate them.

Now, this can get VERY detailed and long if you have a lot of Custom Settings because of the objects in scope. How do you prevent that? The simplest way of doing so is to transition to Custom Metadata Types. They are intrinsically available during your test class with no data setup.

Again, let’s assume we need to create Accounts and Contacts. We’ll further assume that nBatchSize global variable contains the number to insert. You might have something like:

- UtilityTest.getTestContact('Master Account', nBatchSize);

Wait, that’s it? Where are the Accounts and Contacts? This one calls a UtilityTest method that does it all for you. You simply state the Record Type they are for and how many and the creation is taken care of. What if you need access to those Contacts and Accounts? There is an overload you can use:

- UtilityTest.TestData_Structure tds = UtilityTest.getTestContact('Master Account', nBatchSize, true);

This small difference creates a sub-class instance defined in the UtilityTest class. There are objects within the structure called acctLIST and contLIST that will now contain the applicable Accounts/Contacts created. There can be similar overloads for the creation of Custom Objects, Opportunities, Leads, Campaigns, and Cases, etc. Simply asking for that object will cause the other applicable lists to be populated too.

Here is an example you can use for that UtilityTest definition, but the final decision is of course yours. The point is reusability among you various test classes. Otherwise known as a “test factory”.

@IsTest (SeeAllData=false)  
public class UtilityTest {  
    /*
    Created By: Robert Nunemaker
    Created On: 10/27/2012
    Purpose: SAMPLE Test Factory to provide Utility methods for IsTest classes/methods.  Creation of data, etc.
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    Modified By: 
    Modified On:  
    Modification: 
    */
    
    public class TestData_Structure {
        public LIST<Account> acctLIST;
        public LIST<Contact> contLIST;
        public LIST<Lead> leadLIST;
        public LIST<Opportunity> oppLIST;
        public LIST<Case> caseLIST;
        public LIST<Campaign> campLIST;
    } // END TestData_Structure
    public static TestData_Structure tds = new TestData_Structure();
    
    //Does not insert the account -- that still client code's responsibility
    public static LIST<Account> createTestAccount(String strRecordType, integer nCount) {
        
        system.debug('Received RT: ' + strRecordType);
        LIST<Account> acctLIST = new LIST<account>();
        Account acct = new Account();
        Schema.DescribeSObjectResult AccntRes = Account.SObjectType.getDescribe();
        Map<String,Schema.RecordTypeInfo> AccRecordTypeInfo = AccntRes.getRecordTypeInfosByName();
        ID rtID = AccRecordTypeInfo.get(strRecordType).getRecordTypeId();
        
        for (integer nLoop = 1; nLoop <= nCount; nLoop++) {
            acct = new Account(
                Name = 'Test Account ' + string.valueOf(nLoop),
                BillingStreet='851 Cypress Creek Rd',
                BillingCity = 'Fort Lauderdale',
                BillingState = 'FL',
                BillingPostalCode = '33309',
                BillingCountry = 'USA',
                Status__c = strStatus,
                RecordTypeID = rtID);
            acctLIST.add(acct);
        } // END for (integer nLoop = 1; nLoop <= nCount; nLoop++)
        
        return acctLIST;
    } // END createTestAccount
    
    public static LIST<Account> getTestAccount(String strRecordType, integer nCount) {
        
        LIST<Account> acctLIST = UtilityTest.createTestAccount(strRecordType, nCount);
        insert acctLIST;
        system.debug('Inserted Account Test LIST: ' + acctLIST);
        
        return acctLIST;
    } // END getTestAccount
    
    public static LIST<Contact> createTestContact(Account acct, string strRecordType, integer nCount) {
        // Creates nCount records of Contact for the strRecordType Record Type name with the provided Account as the linked account
        
        Schema.DescribeSObjectResult ContRes = Contact.SObjectType.getDescribe();
        Map<String,Schema.RecordTypeInfo> ContRecordTypeInfo = ContRes.getRecordTypeInfosByName();
        ID rtID = ContRecordTypeInfo.get(strRecordType).getRecordTypeId();
        LIST<Contact> contLIST = new LIST<Contact>();
        Contact cont = new Contact();
        ID rtID = Utility.getRecordTypeID('Contact', strRecordType);
        
        for (integer nLoop = 1; nLoop <= nCount; nLoop++) {
            cont = new Contact(
                AccountId = acct.ID,
                FirstName = 'FirstName' + string.valueOf(nLoop),
                LastName = 'LastName' + string.valueOf(nLoop),
                Contact_Status__c = strStatus,
                email = 'FirstName' + string.valueOf(nLoop) + '.LastName' + string.valueOf(nLoop) + '@test-citrix.com',
                MailingStreet = acct.BillingStreet,
                MailingCity = acct.BillingCity,
                MailingPostalCode = acct.BillingPostalCode,
                MailingCountry = acct.BillingCountry,
                RecordTypeID = rtID);
            contLIST.add(cont);
        } // END for (integer nLoop = 1; nLoop <= nCount; nLoop++)
        system.debug('Created Contact Test LIST: ' + contLIST);
        
        return contLIST;
    } // END createTestContact
    
    public static LIST<Contact> getTestContact(string strRecordType, integer nCount) {
        // Inserts nCount records of Contact for the strRecordType Record Type name including linking accounts.
        LIST<Account> acctLIST = UtilityTest.getTestAccount(strRT, nCount);
        Schema.DescribeSObjectResult ContRes = Contact.SObjectType.getDescribe();
        Map<String,Schema.RecordTypeInfo> ContRecordTypeInfo = ContRes.getRecordTypeInfosByName();
        ID rtID = ContRecordTypeInfo.get(strRecordType).getRecordTypeId();
        
        LIST<Contact> contLIST = new LIST<Contact>();
        Contact cont = new Contact();
        
        LIST<Contact> contToDeleteLIST = new LIST<Contact>();
        for (integer nLoop = 1; nLoop <= nCount; nLoop++) {
            cont = new Contact(
                AccountId = acctLIST[nLoop-1].Id,
                FirstName = 'FirstName' + string.valueOf(nLoop),
                LastName = 'LastName' + string.valueOf(nLoop)+ System.now(),
                Contact_Status__c = strStatus,
                email = 'FirstName' + string.valueOf(nLoop) + '.LastName' + string.valueOf(nLoop) + '@EXAMPLE.com',
                MailingStreet = acctLIST[nLoop-1].BillingStreet,
                MailingCity = acctLIST[nLoop-1].BillingCity,
                MailingPostalCode = acctLIST[nLoop-1].BillingPostalCode,
                MailingCountry = acctLIST[nLoop-1].BillingCountry,
                RecordTypeID = rtID);
            contLIST.add(cont);
        } // END for (integer nLoop = 1; nLoop <= nCount; nLoop++)
        insert contLIST;
        system.debug('Inserted Contact Test LIST: ' + contLIST);
        
        return contLIST;
    } // END getTestContact
    
    public static TestData_Structure getTestContact(string strRecordType, integer nCount, boolean bReturnTestDataStructure) {
        // Inserts nCount records of Contact for the strRecordType Record Type name including linking accounts.
        LIST<Account> acctLIST = UtilityTest.getTestAccount(strRT, nCount);
		tds.acctLIST = acctLIST;

        Schema.DescribeSObjectResult ContRes = Contact.SObjectType.getDescribe();
        Map<String,Schema.RecordTypeInfo> ContRecordTypeInfo = ContRes.getRecordTypeInfosByName();
        ID rtID = ContRecordTypeInfo.get(strRecordType).getRecordTypeId();
        
        LIST<Contact> contLIST = new LIST<Contact>();
        Contact cont = new Contact();
        
        LIST<Contact> contToDeleteLIST = new LIST<Contact>();
        for (integer nLoop = 1; nLoop <= nCount; nLoop++) {
            cont = new Contact(
                AccountId = acctLIST[nLoop-1].Id,
                FirstName = 'FirstName' + string.valueOf(nLoop),
                LastName = 'LastName' + string.valueOf(nLoop)+ System.now(),
                Contact_Status__c = strStatus,
                email = 'FirstName' + string.valueOf(nLoop) + '.LastName' + string.valueOf(nLoop) + '@EXAMPLE.com',
                MailingStreet = acctLIST[nLoop-1].BillingStreet,
                MailingCity = acctLIST[nLoop-1].BillingCity,
                MailingPostalCode = acctLIST[nLoop-1].BillingPostalCode,
                MailingCountry = acctLIST[nLoop-1].BillingCountry,
                RecordTypeID = rtID);
            contLIST.add(cont);
        } // END for (integer nLoop = 1; nLoop <= nCount; nLoop++)
        insert contLIST;
        system.debug('Inserted Contact Test LIST: ' + contLIST);
        tds.contLIST = contLIST;
        
        return contLIST;
        
        return tds;
    } // END getTestContact
    
    public static User createTestUser(string strProfileName, string strYourTestMethod) {
		// (strYourTestMethod is to keep the user Unique in case of asynchronous tests)
        
        Profile prof = [select Id, Name from Profile where Name = :strProfileName LIMIT 1];
        User usr = new User(
            UserName = strYourTestMethod + '@yourcompany.com',
            FirstName = 'Test-First-Name',
            LastName = 'Test-Last-Name',
            isActive = true,
            Alias = 'test',
            Email = strYourTestMethod + '@yourcompany.com',
            EmailEncodingKey = 'UTF-8',
            LanguageLocaleKey = 'en_US',
            LocalesIdKey = 'en_US',
            TimezonesIdKey = 'America/Los_Angeles',
            ProfileId = prof.Id
        );
        return usr;
        
    } // END createTestUser
} // END UtilityTest 

The important thing is that you need to know what it is you are testing, and what data it requires. You will likely spend 50% of your time creating this method because all of your other methods will depend on it.

setupUsers User Creation

Just like you want to create your data once that everything depends on, you want to create the needed Users once as well. However, if you tried to do it in SetupData, it would result in the dreaded “mixed DML Exception”. But, but putting it in a separate method and calling it as the last step in SetupData, the remaining methods will wait for that Future event of creation to be completed before continuing. Very handy!

Here is an example of how to create those users:

private static void createUsers() {
	LIST<User> usrLIST = new LIST<User>();
	usrLIST.add(UtilityTest.createTestUser('CTXS_HQ Sales Administrator', 'myTestMethod_IsTest'));
	usrLIST.add(UtilityTest.createTestUser('CTXS_HQ Sales Administrator', 'my2ndTestMethod_IsTest'));
	insert usrLIST;

	Group testGroup = [select ID from Group where Name='test group queue' LIMIT 1];
	QueuesObject testQueue = new QueueSObject(QueueID = testGroup.id, SObjectType = 'Lead');
	insert testQueue;
	
} // END createUsers

Test Method Definition

A rather standard definition for a method with the exception of the keyword “testMethod”. This keyword identifies to Salesforce that this method will be a test method to be processed. Without it, this is simply another private method in a class. You may wish this for any helper methods, but any test methods must have the keyword.

Get Previously Created Data

Remember all of the data you created in your TestSetup section? Now’s the time to go get it. Remember, that method is not able to communicate with the rest of your class. So you have to get the applicable records.

To continue with our example, let’s say you need the applicable Accounts and Contacts again. You’ll need 2 SOQL’s in this case (but, if you needed the Contacts relating to an Account to work in the loop, you could always make the Contact selection a sub-select on the SOQL).

LIST<Account> acctLIST = [select ID, Name, … from Account]; // … equates to whatever fields you need
LIST<Contact> contLIST = [select ID, Name, … from Contact]; // … equates to whatever fields you need

Now you have the data needed for your testing later on. Notice we didn’t need to do a “Where” clause. That’s because we know that our data is the only data visible (SeeAllData = false). But, suppose you created 200 Accounts, but they have different data in certain records for different tests. In that case, you may want to include a Where class for just those records and populate different LISTs.

RunAs a Created User

First you select the User created above in “CreateUsers”. Here is where you USE your generated User. This is the same as doing “Login As” in the GUI. From this point until the end of the block, anything done will be done under the credentials of the created User above. This call is the one that will actually insert the created User if necessary.

Test.startTest

OK, now we are ready to actually test. This line should go right before any REAL tests. Getting the data and user aren’t part of the test. It was simply prepping the workspace to allow for the test.

But this is more than a delineation. It also tells Salesforce that this is the beginning of the test and to give you a whole new set of limits. Meaning, any DML or SOQL statements that you might have done prior won’t be counted within this block. You are effectively given 2 sets of limits. One inside the test block, and one outside. 

Just remember that any SOQL’s, DML’s, etc that are executed as a result of your tests count toward this limit. So if your update causes 44 total SOQL’s in the triggers, you can do 2 tests inside this block causing the trigger to fire, but if you do a third, it will fail with a “Too Many SOQL’s” error.

Again, you must know what it is you are testing so that you can build your framework around it.

Actual Testing

Phew! We’ve done a lot and haven’t even tested anything yet. Well, now’s your chance. Here is where you will do whatever actions are necessary to make your functionality occur and then assert the Outcome. Because after all, coverage isn’t very helpful if we haven’t proven what we set out to do.

So, for our purposes, let’s say that if an Account is marked Out of Business, all linking Contacts should be marked Email_OK__c = false. So to make that happen, we’ll update the Accounts so that the trigger fires, and then get the resulting Contacts and verify them. Remember we already got our initial accounts above. Something like:

for (Account acctItem : acctLIST) {
    acctItem.Status = 'Out of Business';
} // END for (Account acctItem : acctLIST)
update acctLIST; // Remember, you are working with items in the list above and Salesforce is BYREF

// Now get the linking Contacts which should have been updated by the trigger
contLIST = [select ID, Email_OK__c from Contact];
for (Contact contItem : contLIST) {
    system.assertEquals(false, contItem.Email_OK__c); // This is the most important step! ASSERT!
} // END for (Contact contItem : contLIST)

Test.stopTest

You’ve started a test, so it’s logical that you need to stop it. However, this ALSO tells Salesforce to wait for any Asynchronous events that you might have triggered (i.e. @Future). Once they are done, it will move to the next line. That gives you a chance to validate those Asynchronous events if applicable.

Optional: Test Async Events

Finally, you have the area where you can test any Asynchronous events. This area won’t get executed until the “test.stopTest” completes. But if you have something that executed asynchronously (@Future), you can now scan for those records and Assert that outcome and prove the functionality here.

Final Thoughts

That’s it! Looks like a lot, but if you tie it all together, you’ll see that not only isn’t it very much, it’s not very difficult either, and more important, you know WHY you need to do these things. When you have another method to create, no more creating the data and everything else again. Just copy/paste the test method, and change your Actual test area and maybe your running User. Done!

This pattern while segregating your actual test from your setup of data not only accomplishes the ability to segregate your Salesforce limits into different buckets, it also helps avoid the dreaded “Mixed DML Operation”. And, I’m sure you can see how a Test Factory can also simplify and speed your test class development.

Good Luck!!!

To view or add a comment, sign in

More articles by Robert Nunemaker

  • APEX Triggers using pattern EDA3

    Introduction We all have triggers to create and most of us know some of the best practices around them. But creating…

    1 Comment
  • Create a YAML file for an APEX REST Class

    Introduction With so many web services written with APEX being done in REST and fewer in SOAP, we need to be able to…

    2 Comments
  • Get All Permutations (Combinations) Using APEX

    Huh? Permutations? Yeah, every industry likes to have their own words, right? Instead of the $5 word, we can just say…

  • (Re)Discover the Power of Salesforce Custom Permissions

    Custom Permissions are one of those obscure capabilities of the Salesforce platform that is not only under-appreciated,…

    5 Comments
  • The Power of "Why"!

    (For Non-Coders and Coders alike!) Introduction Often times we need to design solutions with incomplete information. It…

    1 Comment
  • Create own Code Coverage Solution!

    A few years ago, Salesforce removed Code Coverage from the Trigger and Class GUI screens. Encouraging you to go to…

    3 Comments
  • Change Test Class Batch Size Dynamically

    Previously, we've discussed best practices for developing Test classes. And, as we all know, we should be testing in…

  • Custom Labels, Settings, Metadata - which should I use?

    Salesforce is chock full of capabilities that allow a much more customized experience. Among these are Custom Labels…

    1 Comment
  • Upserting and Working with Custom Metadata Types

    Discussion Previously, we discussed how to use Custom Metadata Types in conjunction with generic sObjects to provide a…

    4 Comments
  • Custom Metadata & sObjects for full Generic Reads and Updates

    Discussion Ever wish you could create a service that would be able to read and/or write to a given object, but provide…

    1 Comment

Others also viewed

Explore content categories