SObjectTestData Test Data Framework

At a previous employer, some colleagues came up with a great Salesforce Apex Test Data framework that lets one easily create Salesforce records in different ways using a “Fluent” API in their Apex test code. I originally posted this code here but wanted to repost it here and expand on it.

Why Use A Test Data Framework?

Creating all your data in Apex test code is a best practice so that it doesn’t have a data dependency on the org in which it’s running. However, managing that test data creation code is often not considered. This typically leads to a lot of duplicated code across the tests. If one adds new validation, then a lot of code typically has to be touched. A maintenance nightmare ensues. With a test data framework in place, this helps manage the code a lot better so that the burden is much less and enhancements much easier. In the framework used here, if a new required field is added, one could add a new “default value” and then all the tests will then have it. The single responsibility is easily met.

Overview

The SObjectTestData framework consists of the SObjectTestData “base” class. One creates one sub-class from it per Object and that is responsible for creating and inserting records for that Object. Each “ObjectTestData” class is a singleton whose job is to build the records in memory and then insert one or more of the records as needed.

Usage

Insert Default Record Example

If one wants to insert a record using the ObjectTestData’s default field values, one can simply call the insert method like this:

@isTest
static void accountDefaultInsertExampleTest() {
    Account defaultAccount = AccountTestData.Instance.insertAccount();
}

Insert Record With Specific Field Values Example

If one wants to specify particular values for different fields, call the appropriate “with<Field>” method to set that fields value and then insert as needed. Every time insert is invoked, it instantiates a new object record and tries to insert a new record. It does not cache a previously inserted record and return that.

@isTest
static void accountSpecificFieldValueExampleTest() {
    Account specificAccount = AccountTestData.Instance.withName('Metillium Inc.')
                                                      .insertAccount();
}

@isTest
static void accountSpecificFieldValuesExampleTest() {
    Account specificsAccount = AccountTestData.Instance.withName('Other Company')
                                                       .withAnnualRevenue(100000)
                                                       .insertAccount();
}

Creating Records Without Inserting

One can also build an Object Record without inserting it. Sometimes this is helpful so one can do that by calling the appropriate with<field> methods and then calling the “create” function which instantiates the record, populates the fields, and then returns the instantiated record without inserting it. Admittedly, create may not be the best name since one can interpret it as “inserting” which it does not. Perhaps, “build” is a better function name. Alas, I’ll leave it as “create”.

@isTest
static void accountCreateWithoutInsertExampleTest() {
    Account createdAccount = AccountTestData.Instance.create();
}

Building Your Own Object Test Data Class

When building an Object specific Test Data class, one inherits from the “SObjectTestData” class. Unfortunately, that base class has to be a non-test class so one can inherit from it. After inheriting from it, one implements:

  • getDefaultValueMap where one specifies the default values for every account that is built, the
  • getSObjectType where one returns the Object’s type. This is used internally in the SObjectTestData to instantiate the appropriate record dynamically.
  • The singleton pattern by making the constructor private and providing a single instantiated object test data through the “Instance” variable.
  • One or more “with<Field>” methods so one can use a Fluent API in the test code to set the fields on the record.
  • Override the beforeInsert, AfterInsert, beforeBuild, afterBuild methods as needed. This is typically rarely needed.
  • Create more specific insert methods, especially for record type specific records. For example, if an Account Object has multiple record types, I typically have one insert method per record type so one can easily insert them without always having to specific the record type.

See the AccountTestData Sample Class below for a sample implementation.

SObjectTestData Base Class

The base class that Object specific TestData classes use for their behavior. Enhance as needed to provide additional functionality.

/**
* @description A fluent interface for creating and inserting SObject records.
* Solely used for testing, NOT a data factory.
*/
public abstract class SObjectTestData {
    private Map<Schema.SObjectField, Object> customValueMap;
    private Map<Schema.SObjectField, Object> defaultValueMap;
    
   /**
    * @description Subclasses of SObjectTestData should call super()
    * from within constructors to invoke the setup() method.
    */
    public SObjectTestData() {
        customValueMap = new Map<Schema.SObjectField, Object>();
        defaultValueMap = getDefaultValueMap();
    }

    /**
     * @description Retrieves the value set for the specified field.
     * @param field The Schema.SObjectField whos value we are retrieving.
     * @return An Object used when constructing SObjects for the specified field.
     */
    protected Object currentValueFor(Schema.SObjectField field) {
        Object val = customValueMap.get(field);

        if (val == null) {
            return defaultValueMap.get(field);
        }

        return val;
    }

    /**
     * @description Generates a map of default values for this SObjectType.
     * @return The Map of SObjectFields to their corresponding default values.
     */
    protected abstract Map<Schema.SObjectField, Object> getDefaultValueMap();

    /**
     * @description Dynamically sets the Schema.SObjectField noted by field to value for
     * SObjects being built.
     * @param field The Schema.SObjectField to map the value to and cannot be null.
     * @param value The value for the field and can be set to null.
     * @return The instance of SObjectTestData.
     */
    protected SObjectTestData withDynamicData(Schema.SObjectField field, Object value) {
        customValueMap.put(field, value);
        return this;
    }

    /**
     * @description Builds an instance of SObject dynamically and sets the instance’s
     * fields from the values in the customValueMap and defaultValueMap.
     * @return an instance of SObject
     */
    private SObject build() {
        beforeBuild();
        SObject instance = getSObjectType().newSObject(null, true);
        Set<Schema.SObjectField> defaultFields = defaultValueMap.keySet().clone();
        defaultFields.removeAll(customValueMap.keySet());

        for (Schema.SObjectField field : defaultFields) {
            instance.put(field, defaultValueMap.get(field));
        }

        for (Schema.SObjectField field : customValueMap.keySet()) {
            instance.put(field, customValueMap.get(field));
        }
    
        afterBuild(instance);
        return instance;
    }

    /**
     * @description Builds an instance of SObject dynamically and sets the instance’s
     * fields from the values in the defaultValueMap.
     * @return an instance of SObject
     */
    protected SObject buildDefault() {
        beforeBuild();
        SObject instance = getSObjectType().newSObject(null, true);

        for (Schema.SObjectField field : defaultValueMap.keySet()) {
            instance.put(field, defaultValueMap.get(field));
        }

        afterBuild(instance);
        return instance;
    }

    /**
     * @description Builds an instance of SObject dynamically and sets the instance’s
     * fields from the values in the customValueMap. Also clears
     * the customValueMap.
     * @return an instance of SObject
    */
    protected SObject buildWithReset() {
        SObject instance = build();
        customValueMap = new Map<Schema.SObjectField, Object>();
        return instance;
    }

    /**
     * @description Builds an instance of SObject dynamically and sets the instance’s
     * fields from the values in the customValueMap map. This method does not
     * clear the customValueMap.
     * @return an instance of SObject
     */
    protected SObject buildWithoutReset() {
        return build();
    }

    /**
     * @description Inserts the built SObject.
     * @return The inserted SObject.
     */
    protected SObject insertRecord() {
        SObject instance = buildWithReset();
        beforeInsert(instance);
        insert instance;
        afterInsert(instance);
        return instance;
    }

    /**
     * @description Inserts the SObject built from only the defaults.
     * @return The inserted SObject.
    */
    protected SObject insertDefaultRecord() {
        SObject instance = buildDefault();
        beforeInsert(instance);
        insert instance;
        afterInsert(instance);
        return instance;
    }

    /**
     * @description Inserts a list of SObjects and ties into the before and after hooks.
     * @param numToInsert the number of SObjects to insert.
     * @return The inserted SObjects.
     */
    protected List<SObject> insertRecords(Integer numToInsert) {
        List<SObject> sobjectsToInsert = new List<SObject>();

        for (Integer i = 0; i < numToInsert; i++) {
            SObject sObj = buildWithoutReset();
            sobjectsToInsert.add(sObj);
            beforeInsert(sObj);
        }

        insert sobjectsToInsert;

        for (SObject sObj : sobjectsToInsert) {
            afterInsert(sObj);
        }

        return sobjectsToInsert;
    }

    /**
     * @description This method allows subclasses to invoke any action before
     * the SObject is built.
     */
    protected virtual void beforeBuild() {}

    /**
     * @description This method allows subclasses to handle the SObject after
     * it is built.
     * @param sObj The SObject that has been built.
     */
    protected virtual void afterBuild(SObject sObj) {}

    /**
     * @description This method allows subclasses to handle the SObject before
     * it is inserted.
     * @param sObj The SObject that is going to be inserted.
     */
    protected virtual void beforeInsert(SObject sObj) {}

    /**
     * @description This method allows subclasses to handle the SObject after
     * it is inserted.
     * @param sObj The SObject that has been inserted.
     */
    protected virtual void afterInsert(SObject sObj) {}

    /**
     * @description Returns the SObject type for this TestData builder.
     * @return A Schema.SObjectType.
     */
    protected abstract Schema.SObjectType getSObjectType();
}

AccountTestData Sample Class

The Object Test Data class for the Account Object. Apex Test code uses this create and insert Account records as needed. Populate the default account values in the getDefaultValueMap so that all account created or inserted have those by default. One can override the defaults using the appropriate “with<Field>” method.

/**
* @description Builder class for dealing with Account records.
* Solely used for testing, NOT a data factory.
**/
@isTest
public class AccountTestData extends SObjectTestData {
    /**
     * @description Overridden method to set up the default
     * Account state for AccountTestData.
     * @return A map of Account default fields.
     */
    protected override Map<Schema.SObjectField, Object> getDefaultValueMap() {
        return new Map<Schema.SObjectField, Object>{
            Account.Name => 'Luke Freeland'
        };
    }

    /**
     * @description Returns the SObject type for AccountTestData builder.
     * @return Account.SObjectType.
     */
    protected override Schema.SObjectType getSObjectType() {
        return Account.SObjectType;
    }

    /**
     * @description Sets the name on the account.
     * @param name The name that the account will have.
     * @return The instance of AccountTestData.
     */
    public AccountTestData withName(String name) {
        return (AccountTestData) withDynamicData(Account.Name, name);
    }

    /* Create a “with” method for each property that can be set */

    /**
     * @description Builds the Account object.
     * @return The created Account object.
     */
    public Account create() {
        return (Account)super.buildWithReset();
    }

    /**
     * @description Inserts the built Account object.
     * @return The inserted Account object.
     */
    public Account insertAccount() {
        return (Account)super.insertRecord();
    }

    /**
     * @description Inserts the Account object using only the default values in the Default Values map.
     * @return The inserted Account object.
     */
    public Account insertDefaultAccount() {
        return (Account) super.insertDefaultRecord();
    }

    /**
     * @description Inserts the specificed number of Account objects.
     * @param numberToInsert The number of accounts to insert
     * @return The inserted Account object.
     */
    public List<Account> insertAccounts(Integer numberToInsert) {
        return (List<Account>) super.insertRecords(numberToInsert);
    }

    private static AccountTestData instancePriv = null;
    /**
     * @description Gets an instance of AccountTestData.
     * @return AccountTestData instance.
     */
    public static AccountTestData Instance {
        get {
            if (instancePriv == null) {
                instancePriv = new AccountTestData();
            }
            return instancePriv;
        }
    }

    /**
     * @description Private constructor for singleton.
     */
    private AccountTestData() {
        super();
    }
}

AccountTestDataTest Class

This test class shows how to use the AccountTestData class and also provides test code for the SObjectTestData base class.

/*
   @description Used to test the SObjectTestData's method to ensure it behaves correctly through
   the AccountTestData class and to provide code coverage.
*/
@isTest
public with sharing class AccountTestDataTest {
    @isTest
    static void insertAccount_insertDefault_expectAccountInsertedTest() {
        Account defaultAccount = AccountTestData.Instance.insertAccount();

        system.assert(defaultAccount.Id != null,
                      'The default account was not inserted.');
    }

    @isTest
    static void insertDefaultAccount_invoke_expectAccountInsertedTest() {
        Account defaultAccount = AccountTestData.Instance.insertDefaultAccount();

        system.assert(defaultAccount.Id != null,
                      'The default account was not inserted.');
    }

    @isTest
    static void insertAccount_invokeWithSpecificName_expectAccountInsertedWithSpecificNameTest() {
        String companyName = 'My Company';

        Account specificNameAccount = AccountTestData.Instance.withName(companyName)
                                                              .insertAccount();

        system.assert(specificNameAccount.Id != null,
                      'The specific name account was not inserted.');

        Account specificNameAccountQueried = getAccountById(specificNameAccount.Id);

        system.assertEquals(companyName, specificNameAccountQueried.Name,
                            'The account was not inserted with the given name.');
    }

    @isTest
    static void insertAccounts_invokeWith2_expect2AccountsInsertedTest() {
        List<Account> twoAccounts = AccountTestData.Instance.insertAccounts(2);

        system.assert(twoAccounts != null,
                      'The list of accounts returned is null.');

        for (Account acct : twoAccounts) {
            system.assert(acct.Id != null,
                          'One of the requested accounts was not inserted.');
        }
    }

    static Account getAccountById(Id accountId) {
        return
        [SELECT Id,
                Name
           FROM Account
          WHERE Id = :accountId];
    }
}

Leave a Reply

Your email address will not be published.