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 bulk with 200 at a time, right? BUT, testing with that many causes Production Quick Deploy and other processes to take so long; sometimes hours more. AND, there are certain objects that you may have technical debt for that can result in too much CPU time, too many SOQL's, etc due to APEX design or Process Builder design. What to do?

In this article, we'll discuss a simple solution using Custom Metadata (or Custom Settings) where you can dynamically control the batch size for both production and sandboxes. Why would you do this? So that in your staging and/or QA environments, you can process the test classes with the maximum size (that they can handle), and in Production, you can lower it to 10 or less to save on time. And, for the reasons stated above, you can override that maximum in any environment as well.

Custom Metadata Design

The first step is the design of your Custom Metadata (or Custom Settings) object. For maximum flexibility, you want the ability to control your batch size not only by the test method, but by test class as well, and for Production as well as the sandbox. Finally, you want to be able to Enable them or not so that you can override the behavior quickly if desired.

So, let's create a Custom Metadata object called "MDT_Test_Class_Batch_Size__mdt". I know, I know, the MDT in front is redundant. I like it because it allows all MDT objects to come together when looking for them in views. Totally optional, but stick with me.

With that object, let's create custom fields as detailed below:

Custom Metadata Usage

Now that we have it, how do we use it? Well, first, we need a helper method to get the applicable data for the class/method in scope. Then we need to call it from the Test Class/Method.

Custom Metadata Helper Method

In whatever Tes Utility class you might have (if not, definitely create one such as "UtilityTest" and ensure it has an "@IsTest" annotation in the beginning), let's create a method called "NoOfRecords" as shown below:

    public static integer NoOfRecords(String ClassName, String MethodName){

        list<MDT_Test_Class_Batch_Size__mdt> Test_Class_list = [select Id, Class_Name__c, Method_Name__c, No_of_record__C, No_of_Records_Sandbox__c, Is_Enable__c from MDT_Test_Class_Batch_Size__mdt where Class_Name__c = 'Default' OR Class_Name__c =: ClassName];

        map<string, MDT_Test_Class_Batch_Size__mdt> NoOfRecordForTestClass = new map<string, MDT_Test_Class_Batch_Size__mdt>();

        for(MDT_Test_Class_Batch_Size__mdt MTC : Test_Class_list){

            if(MTC.Method_Name__c != null && MTC.Method_Name__c != '') NoOfRecordForTestClass.put(MTC.Class_Name__c + '~' +MTC.Method_Name__c, MTC);

            else NoOfRecordForTestClass.put(MTC.Class_Name__c, MTC);

        }// end of for(MDT_Test_Class_Batch_Size__mdt MTC : Test_Class_list)

        system.debug('Test Class MAP: ' + NoOfRecordForTestClass);

        if(ClassName != null && MethodName != null && NoOfRecordForTestClass.containsKey(ClassName + '~' + MethodName) && NoOfRecordForTestClass.get(ClassName + '~' + MethodName).Is_Enable__c){

            If (URL.getSalesforceBaseUrl().getHost().containsIgnoreCase('.cs')){ // Sandbox

                return integer.valueof(NoOfRecordForTestClass.get(ClassName + '~' + MethodName).No_of_Records_Sandbox__c);

            } else { // Production

                return integer.valueof(NoOfRecordForTestClass.get(ClassName + '~' + MethodName).No_of_record__C);

            }// end of If (URL.getSalesforceBaseUrl().getHost().containsIgnoreCase('.cs'))

        }// end of if(ClassName != null && MethodName != null && NoOfRecordForTestClass.containsKey(ClassName + '~' + MethodName) && NoOfRecordForTestClass.get(ClassName + '~' + MethodName).Is_Enable__c)

        else if(ClassName != null && NoOfRecordForTestClass.containsKey(ClassName) && NoOfRecordForTestClass.get(ClassName).Is_Enable__c){

            If (URL.getSalesforceBaseUrl().getHost().containsIgnoreCase('.cs')) { // Sandbox

                return integer.valueof(NoOfRecordForTestClass.get(ClassName).No_of_Records_Sandbox__c);

            } else { // Production

                return integer.valueof(NoOfRecordForTestClass.get(ClassName).No_of_record__C);

            }// end of If (URL.getSalesforceBaseUrl().getHost().containsIgnoreCase('.cs'))

        }// end of else if(ClassName != null && MethodName == null && NoOfRecordForTestClass.containsKey(ClassName) && NoOfRecordForTestClass.get(ClassName).Is_Enable__c)

        else if(NoOfRecordForTestClass.containsKey('Default') && NoOfRecordForTestClass.get('Default').Is_Enable__c){

            If (URL.getSalesforceBaseUrl().getHost().containsIgnoreCase('.cs')) { // Sandbox

                return integer.valueof(NoOfRecordForTestClass.get('Default').No_of_Records_Sandbox__c);

            } else { // Production

                return integer.valueof(NoOfRecordForTestClass.get('Default').No_of_record__C);

            }// end of If (URL.getSalesforceBaseUrl().getHost().containsIgnoreCase('.cs'))

        }// end of else if(NoOfRecordForTestClass.containsKey('Default') && NoOfRecordForTestClass.get('Default').Is_Enable__c)

        return 200;

    }// end of public static integer NoOfRecords(String ClassName, String MethodName)
 
  

Let's discuss what this does. It looks complicated, but all we've done is set up a hierarchy. We don't want to create an entry for EVERY test class in EVERY instance. We'd easily have 1000 or more entries to manage and then what if we forget one? Just the exceptions.

This just says, look for the test class AND method names passed in. If found, see if you are a sandbox or not using the base URL to look for "CS. This can be done with the SOQL:

SELECT IsSandbox FROM Organization LIMIT 1

But, that costs us a SOQL. Maybe that's OK in your organization, maybe not, so feel free to adjust if necessary.

If it can't find a record for the Test Class/Method name, let's look for just the Test Class. If found, apply the same sandbox logic. Finally, return the value for "Default" if found. At the end, we have "200" returned, but this will likely never be hit because the Default entry should always be configured. Still, we should have an exit "just in case".

Custom Metadata Test Class Usage

OK, we've got the number of records, including for default. But how do we use it? Couldn't be simpler. At the top of your Class, provide:

integer nMaxRows = UtilityTest.NoOfRecords('YourTestClass', null);

Then, in the TestSetup portion of your Class, use the value returned for the number of records. For each method where you need to create data not created in the TestSetup for some reason, call it similarly such as:

integer nMaxRows = UtilityTest.NoOfRecords('YourTestClass', 'YourTestMethod');

After executing the line, use the value returned for the number of records there.

Custom Metadata "Default"

But what is this "Default" entry we've mentioned previously? That's where the real power comes in. This is where MOST of your Test Classes and methods will fall into. Because you don't have an entry for them, Default is found and used. That allows you to set a global default for production and sandbox for most test classes without entering them explicitly.

We currently have 200 for Sandbox, and 10 for Production. This allows our Production validations to process MUCH quicker because they've already been vetted in Staging or QA.

Conclusion

Yes, this seems a lot to do, but now imagine how you can dynamically change the behavior of your test methods on the fly. Imagine you get a test class that all of a sudden starts giving "Too Much CPU Time" in production during a deployment. Rather than going crazy trying to resolve it immediately, just change the Production batch size to a smaller amount, and then it should work giving you much needed time to resolve the issue properly.

Even if you don't go down this road, hopefully, it allows you to envision other ways of making your test classes/methods more efficient so that deployments go from being an "I hope" situation to something routine.

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

  • 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