Target: Spring '26. One @IsTest public class TestDataFactory per project; builders set every required field so tests never fail on a new validation rule for a field they don't care about.
@IsTest
public class TestDataFactory {
/* ---------- Account ---------- */
public static AccountBuilder account() { return new AccountBuilder(); }
public class AccountBuilder {
private final Account record = new Account(Name = 'Test Account ' + Crypto.getRandomInteger(), Industry = 'Technology');
public AccountBuilder withName(String name) { record.Name = name; return this; }
public AccountBuilder withRecordType(String developerName) {
record.RecordTypeId = Utils.getRecordTypeId('Account:' + developerName);
return this;
}
public Account build() { return record; }
public Account persist() { Database.insert(record); return record; }
}
/* ---------- Orders (bulk) ---------- */
public static OrderListBuilder orders(Id accountId, Integer count) { return new OrderListBuilder(accountId, count); }
public class OrderListBuilder {
private final List<Order__c> records = new List<Order__c>();
OrderListBuilder(Id accountId, Integer count) {
for (Integer i = 0; i < count; i++) {
records.add(new Order__c(Account__c = accountId, Amount__c = 100, Status__c = 'Draft', Name = 'ORD-' + i));
}
}
public OrderListBuilder withStatus(String status) { for (Order__c o : records) { o.Status__c = status; } return this; }
public List<Order__c> build() { return records; }
public List<Order__c> persist() { Database.insert(records); return records; }
}
/* ---------- Users ---------- */
public static User user(String profileName) {
Profile p = [SELECT Id FROM Profile WHERE Name = :profileName LIMIT 1];
String unique = String.valueOf(Crypto.getRandomInteger()).replace('-', '');
User u = new User(
ProfileId = p.Id, Username = 'test.' + unique + '@example.com', Email = 'test@example.com',
Alias = 'tu' + unique.left(5), LastName = 'Tester', TimeZoneSidKey = 'GMT', LocaleSidKey = 'en_US',
EmailEncodingKey = 'UTF-8', LanguageLocaleKey = 'en_US');
System.runAs(new User(Id = UserInfo.getUserId())) { Database.insert(u); } // avoids MIXED_DML_OPERATION
return u;
}
public static void assignPermissionSet(Id userId, String permissionSetName) {
PermissionSet ps = [SELECT Id FROM PermissionSet WHERE Name = :permissionSetName LIMIT 1];
System.runAs(new User(Id = UserInfo.getUserId())) {
Database.insert(new PermissionSetAssignment(AssigneeId = userId, PermissionSetId = ps.Id));
}
}
}
Rules
- Required fields and record types live in the builder, never in the test method. When a new required field appears, one edit fixes every test.
persist()does one DML for the whole list — the factory itself must be bulk-safe.- Use
Crypto.getRandomInteger()for uniqueness;DateTime.now().getTime()collides in parallel runs. - Profile names differ by org and edition; "Minimum Access - Salesforce" exists in every modern org and is the right base for permission-set-driven negative tests.
- Never query production data (
SeeAllData) to "save time"; it makes tests order-dependent and blocks deployment when data drifts. - Put the factory in the same package as the tests; a shared
TestDataFactoryacross unlocked packages must beglobaland versioned like any API.