Apex Unit Test: SOQL Filter
Today I participated in an online meeting, which discussed some of the "best practices" for testing Apex code, Visualforce pages, etc. One of the topics that was pretty much skipped but came up during the Q&A portion of the meeting was regarding how to ensure that the test code you write refrains from grabbing any actual live production data and only grabs the records created to facilitate the test. They discussed one way to do this but I have another and I thought it could be useful to other developers.
Depending on your experience you may not think that this is a very important topic. However, those of you that have worked with Enterprise organizations understand that these orgs may have tons and tons of records. For orgs that have millions of records in any given object it may be best to write test code that actually filters the data used within the unit tests so that the test actually completes in a timely manner. This can be especially important for deploying code because test coverage that runs for a long time can cause problems during the deployment process.
The filtering that I sometimes use within my unit tests is fairly straight forward. I create a time stamp (DateTime variable) at test run-time so that I can use it as a filter for the CreatedDate field on the object, which I am using in the test.
To illustrate this concept we are going to create a very simple batch apex process that will grab all Accounts in an org and update a custom field. I know this is not a particularly useful use case but it will illustrate my point.
The batch apex for this is below:
/*
Created by: Greg Hacic
Last Update: 30 March 2011 by Greg Hacic
Questions?: greg@interactiveties.com
*/
global class batch_on_account_object implements Database.Batchable<SObject> {
public String soql = 'SELECT custom_text_field__c, Id FROM Account'; //query string variable
//the "start" method is called at the beginning of a batch Apex job
//use this method to collect the records (of objects) to be passed to the "execute" method for processing
global Database.QueryLocator start(Database.BatchableContext BC) {
return Database.getQueryLocator(soql); //executes the query
}
//the "execute" method is called after the "start" method has been invoked and passed a batch of records
global void execute(Database.BatchableContext BC, List<SObject> scope) {
List<Account> account_updates = new List<Account>(); //list for holding account updates
DateTime now_datetime = Datetime.now(); //datetime variable for right now
String now_datetime_as_string = now_datetime.format('yyyy-MM-dd')+'T'+now_datetime.format('HH:mm:ss')+now_datetime.format('Z').substring(0, now_datetime.format('Z').length()-2)+':00';
for (SObject s : scope) { //for all objects from our query
Account a = (Account)s; //grab the Account
a.custom_text_field__c = now_datetime_as_string; //update the custom field
account_updates.add(a); //add the record to our list
}
if (!account_updates.isEmpty()) { //if the list of accounts is not empty
update account_updates; //pass back the updates
}
}
//the "finish" method is called once all the batches are processed
global void finish(Database.BatchableContext info) {
//send a message about the batch processing completion
AsyncApexJob a = [SELECT CreatedBy.Email, CreatedBy.Name, CreatedDate, CompletedDate, Id, JobItemsProcessed, NumberOfErrors, Status, TotalJobItems FROM AsyncApexJob WHERE Id =: info.getJobId()]; //query the AsyncApexJob object to retrieve the current job's information.
Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage(); //instantiate a new message object
String[] toAddresses = new String[]{a.CreatedBy.Email}; //denote the email address for receiving the message
mail.setToAddresses(toAddresses); //to
mail.setSubject('Apex batch_on_account_object: '+a.Status); //subject
mail.setPlainTextBody('The batch Apex job created by '+a.CreatedBy.Name+' ('+a.CreatedBy.Email+') processed '+a.TotalJobItems+' batches with '+a.NumberOfErrors+' failures. The process began at '+a.CreatedDate+' and finished at '+a.CompletedDate+'.'); //body
Messaging.sendEmail(new Messaging.SingleEmailMessage[]{ mail }); //send
}
}
If you review the code above you will see that I am simply updating a custom field on all Account records with a new value. If you are familiar with batch apex then you already know that you need to put constraints within your unit test logic so that only one execution of the execute method is performed. This is a requirement of the Force.com platform. Now most developers will simply append a " LIMIT 200" clause to their SOQL statement and I am not opposed to this. As a matter of fact this will often be enough to provide adequate unit test coverage for your code.
However, what if you are not simply updating all accounts (as I am in my demo batch logic above)? Specifically, what if you are only updating Accounts that meet very specific criteria. Furthermore, what if you have three million Account records in your salesforce org? What you’ll find is that the SOQL query within your batch apex code will take much, much longer to complete. This will likely be true even if you pass a "LIMIT" clause within your unit test. Additionally, this may cause headaches when you try to deploy your code.
As I mentioned much earlier in this post is that I will create a time stamp to use as a filter within my SOQL statement. This doesn’t necessarily mean that I will only get records that I created during the unit test but it does mean that there is a finite number of records that may be returned in the query and it also means that the query (or the execute method in our batch logic) will return data much faster than having this filter condition excluded. So here’s the test class with the filter condition.
/*
Created by: Greg Hacic
Last Update: 30 March 2011 by Greg Hacic
Questions?: greg@interactiveties.com
*/
@isTest
private class test_batch_on_account_object {
@isTest //defines method for use during testing only
static void test_logic() {
//BEGIN: perform some setup steps...
//create a variable for use in our query later (it's declared here because we need it populated prior to creation of test accounts)
DateTime createdDateValue = DateTime.now(); //DateTime variable
String createdDate_Formatted_Date = createdDateValue.format('yyyy-MM-dd'); //date portion of the createdDateValue variable formatted as Strting
String createdDate_Formatted_Time = createdDateValue.format('HH:mm:ss'); //time portion of the createdDateValue variable formatted as Strting
String createdDate_Formatted_Zone = createdDateValue.format('Z'); //timezone offset of the createdDateValue variable formatted as Strting
String finalDateTimeValue = createdDate_Formatted_Date+'T'+createdDate_Formatted_Time+createdDate_Formatted_Zone.substring(0, createdDate_Formatted_Zone.length()-2)+':00';
//create some accounts
Account[] accounts = new Account[0]; //new Account sObject
for (Integer a = 0; a < 150; a++) {
accounts.add(new Account(Name = 'Test - '+a));
}
insert accounts; //insert the account records
Set<Id> Ids = new Set<Id>(); //set for holding the Ids of all the created Account records
for (Account act : accounts) { //for all inserted Accounts
Ids.add(act.Id); //add the Id to our set
}
//END: perform some setup steps...
//validate that all the records have a NULL value for custom_text_field__c
List<Account> queried_accounts = [SELECT custom_text_field__c FROM Account WHERE Id =: Ids];
for (Account qa : queried_accounts) { //for all accounts
System.assertEquals(null, qa.custom_text_field__c); //assert that the custom_text_field__c is null
}
Test.startTest(); //switch to test context
batch_on_account_object batch = new batch_on_account_object();
batch.soql += ' WHERE CreatedDate >= '+finalDateTimeValue; //append the string to our soql variable
ID batch_processId = Database.executeBatch(batch); //execute the batch
Test.stopTest(); //switch back to runtime context
//validate that all the records now have a value for custom_text_field__c
queried_accounts.clear(); //remove all the records from our Account List
queried_accounts = [SELECT custom_text_field__c FROM Account WHERE Id =: Ids]; //re-query
for (Account qa : queried_accounts) { //for all accounts
System.assertNotEquals(null, qa.custom_text_field__c); //assert that the custom_text_field__c is NOT null
}
}
}
As you can see above, I create a datetime variable at run-time and then create all my test account records, which I want to be returned in the execute method of the batch apex. Since all the test account records were created after the datetime variable was declared they will be returned (as intended).
Anyway, I'm sure many of you have your own tricks for limiting results in your unit tests. This is just one of the ones that I use.