A blog by Stephen Funk
2025-08-16
Make Apex tests simple to read, write, and iterate on using the Builder pattern and Apex's
@TestSetupannotation
Consider this.
You're a developer on a small Salesforce project tasked with creating an Apex trigger on the OpportuntityLineItemSplit object. Perhaps this trigger calls code that calculates commission. For the purposes of this lesson, the logic behind the trigger is unimportant. All that matters is that your trigger is built, your code is written, and now it's time to create your tests.
To test your trigger, you need to insert an OpportunityLineItemSplit record. "No problem," you think, "I'll just use an insert."
@isTest
static void testTrigger() {
Test.startTest();
insert new OpportuntityLineItemSplit();
Test.stopTest();
// Make assertions...
}
But there is a problem. OpportuntityLineItemSplits cannot be inserted on their own. They must look up to an OpportunityLineItem record and an OpptyLineItemSplitType record. So you write up code that creates these two records.
@TestSetup
static void makeData() {
insert new OpportunityLineItem(); // Fails
insert new OpptyLineItemSplitType(); // Also fails
}
@isTest
static void testTrigger() {
Id opportunityLineItemId =
SELECT Id
FROM OpportunityLineItem
][0].Id;
Id splitTypeId = [
SELECT Id
FROM OpptyLineItemSplitType
][0].Id;
Test.startTest();
insert new OpportuntityLineItemSplit(
OpportunityLineItemId = opportunityLineItemId,
SplitType = splitTypeId
);
Test.stopTest();
// Make assertions...
}
But, of course, now your OpportunityLineItem and OpptyLineItemSplitType have parent records of their own. Pretty soon, your code starts to look pretty unruly.
@TestSetup
static void makeData() {
// Still won't work. This opportunity is missing its AccountId
Opportunity opportunity = new Opportunity();
insert opportunity;
// Still won't work. This pricebookEntry is missing its
// Pricebook2Id and ProductSellingModelId
PricebookEntry pricebookEntry = new PricebookEntry();
insert pricebookEntry;
OpportunitySplitType opportunitySplitType = [
SELECT Id
FROM OpportunitySplitType
LIMIT 1
];
insert new OpportunityLineItem(
OpportunityId = opportunity.Id,
PricebookEntryId = pricebookEntry.Id
);
insert new OpptyLineItemSplitType(
OpportunitySplitTypeId=opportunitySplitType.Id
);
}
@isTest
static void testTrigger() {
Id opportunityLineItemId = [
SELECT Id
FROM OpportunityLineItem
][0].Id;
Id splitTypeId = [
SELECT Id
FROM OpptyLineItemSplitType
][0].Id;
Test.startTest();
insert new OpportuntityLineItemSplit(
OpportunityLineItemId = opportunityLineItemId,
SplitType = splitTypeId
);
Test.stopTest();
// Make assertions...
}
All this for a simple OpportunityLineItemSplit! Fortunately, there is a better way.
The Builder Pattern and Fluent Interface design strategies give us a way to create a simple, flexible, ergonomic, and declarative API for creating parent records for test data. Rather than define and insert each record explicitly, from Account to OpportunityLineItem, we instantiate our builder, specify only the records we need, and call .build(). The builder, the DataFactory, handles the rest, including efficiently creating parent and grandparent records.
Notice that we only need to specify that we need an
OpportunityLineItemandOpptyLineItemSplitType. TheDataFactoryhandles the rest.
The DataFactory has several important features that make it work right.
DataFactory method without record duplication or other unexpected side effects.DataFactory uses the minimum information necessary to create records. It has no opinion about the state of the records it creates except that those records exist. This forces users to explicitly describe the state of the database relevant to their tests, which helps with code readability and hardiness.The first question we must address is which objects should our DataFactory support? At my current job, that answer includes a couple dozen custom objects with about 7 layers of parent/child relationships. For our demo today, we "only" need to include the parent, grandparent, etc. objects of the OpportuntityLineItemSplit object, or 11 objects total. Let's add these objects to our DataFactory class as read-only public members.
public with sharing class DataFactory {
public Account account { public get; private set; }
public Campaign campaign { public get; private set; }
public Contact contact { public get; private set; }
public Contract contract { public get; private set; }
public Pricebook2 pricebook { public get; private set; }
public Product2 product { public get; private set; }
public PricebookEntry entry { public get; private set; }
public Opportunity opportunity { public get; private set; }
public OpportunityLineItem lineItem { public get; private set; }
public OpportunitySplitType oppSplitType {
public get;
private set;
}
public OpptyLineItemSplitType lineSplitType {
public get;
private set;
}
}
For every object, we create a .with* method. This method will instantiate its corresponding object with all the object's required fields except lookup fields (lookup records haven't been created yet, so we can't instantiate lookup fields).
These .with* methods return this, which lets multiple with calls to be chained together.
public with sharing class DataFactory {
/** ... */
public DataFactory withAccount() {
this.account = new Account(Name='Test Account');
return this;
}
}
We want methods to be idempotent, so we'll skip a contents of each function if the function's corresponding object is already instantiated.
public with sharing class DataFactory {
/** ... */
public DataFactory withAccount() {
if (this.account != null) {
return this;
}
this.account = new Account(Name='Test Account');
return this;
}
}
Calling ".with*" on to build a child object should opaquely create any required parent objects, too.
public with sharing class DataFactory {
/** ... */
public DataFactory withAccount() {
if (this.account != null) {
return this;
}
this.account = new Account(Name='Test Account');
return this;
}
public DataFactory withContact() {
if (this.contact != null) {
return this;
}
// Ensure that an Account exists
this.withAccount();
this.contact = new Contact(); // "AccountId" comes later
return this;
}
/** ... continue with other objects */
}
Finally, we create a .build() method which inserts all the instantiated objects and stitches together their lookup IDs. The .build() method is also idempotent, with the help of an isBuilt Boolean flag.
public with sharing class DataFactory {
/** ... */
public Boolean isBuilt = false;
public build() {
if (this.isBuilt) { return; }
// Insert top-level records
insert new SObject[]{
this.account,
this.campaign,
this.pricebook,
this.product,
this.contract
/** etc... */
};
// Populate first generation child records' lookup fields
this.opportunity.ContractId = this.contract.Id;
this.opportunity.Pricebook2Id = this.pricebook.Id;
this.contact.AccountId = this.account.Id;
/** etc. */
// Insert first generation child records
insert new SObject[]{
this.opportunity,
this.contact
/** etc. */
}
/** Continue with second generation child records, etc. */
}
}
Note, I found the "build" step much easier to manage by leveraging the
fflib_SObjectUnitOfWorkclass. You can also create all records with a singleupsertstatements using external IDs.
Congratulations! Your DataFactory is complete. Now you can use it in the setup for any of your unit tests.
@isTest
private class OpportuntityLineItemSplitTriggerTest {
@TestSetup
static void makeData() {
// This test suite will be creating lots of Opportunity Line Item Splits
// Let's create the parent records now
DataFactory factory = new DataFactory()
.withOpportunityLineItem()
.withOpptyLineItemSplitType()
.build();
}
@isTest
static void testTrigger() {
Id opportunityLineItemId = [
SELECT Id
FROM OpportunityLineItem
][0].Id;
Id splitTypeId = [
SELECT Id
FROM OpptyLineItemSplitType
][0].Id;
OpportuntityLineItemSplit testCase =
new OpportuntityLineItemSplit(
OpportunityLineItemId = opportunityLineItemId,
SplitType = splitTypeId
/** etc */
);
Test.startTest();
insert testCase;
Test.stopTest();
// Make assertions...
}
}
Stephen Funk