Blog

SeeAllData is a debt — test data factories in Apex

Since API v24.0, Apex tests see no org data by default. That's an isolation guarantee, not a limitation. @isTest(SeeAllData=true) opts back out of it — and every place it appears is a test that can fail for reasons that have nothing to do with the code under test.

Short version

SeeAllData=true binds a test to whatever rows exist in the org that runs it — it passes by accident and fails by surprise. Replace it with two layers: @testSetup for the shared baseline every test method starts from, and a builder-style factory class for scenario-specific records. The factory is also where schema churn gets absorbed — a new required field means one class changes, not two hundred tests.

What SeeAllData actually opts out of

Before API v24.0, every Apex test could see all data in the org — real Accounts, real Contacts, whatever was sitting in the tables. That made tests fast to write and impossible to trust: a test's outcome depended on data nobody controlled or versioned. v24.0 flipped the default. Test methods now run against an empty data view unless the test explicitly creates what it needs. Setup and metadata objects stay visible — User, Profile, RecordType, custom metadata types — but note that custom settings data does not, a distinction that surprises people exactly once.

@isTest(SeeAllData=true) on a class or method opts back into the old behavior for that scope. It's not deprecated and it's not blocked — it's a valid annotation with a narrow legitimate use: data that genuinely can't be created in a test context. That set of cases is smaller than it used to be. Test.getStandardPricebookId() removed the most commonly cited excuse — you no longer need SeeAllData=true just to get a valid standard pricebook for an Opportunity line item; the method hands you the Id directly.

Why it rots

A test written with SeeAllData=true doesn't test your code in isolation — it tests your code against whatever rows happen to exist wherever it runs. That binding is invisible until it breaks:

  • It passes in the sandbox where someone hand-created "Test Account 1" three years ago, with the exact fields the test happens to query.
  • It fails in a fresh scratch org, because that Account was never created there.
  • It fails in a production validation deploy months later, because a required field was added and the org's real data drifted underneath the test's assumptions.
  • It fails for the new hire running the suite locally against their own dev sandbox, for no reason they can diagnose from the stack trace.

Two structural problems compound the flakiness. First, tests that read org data can't be safely parallelized against each other — two test runs racing against the same real rows can observe each other's side effects, which is exactly the isolation the platform was trying to give you by making the default empty. Second, SeeAllData=true quietly couples unrelated tests to each other through shared rows: a test that mutates a record another test also reads is a race condition wearing a unit test's clothes.

Layer 1: @testSetup for the shared baseline

@testSetup marks a method that runs once per test class, before any test method executes. Salesforce takes a database snapshot after it runs, and every test method in the class starts from a rollback to that snapshot — so setup cost is paid once, not once per method, while each method still sees a clean, private copy of the data.

apex
@isTest
private class OpportunityServiceTest {

    @testSetup
    static void setup() {
        Account acct = TestDataFactory.makeAccounts(1)[0];
        insert acct;

        List<Contact> contacts = TestDataFactory.makeContactsFor(
            new List<Account>{ acct }, 2
        );
        insert contacts;
    }

    @isTest
    static void closingAnOpportunityUpdatesAccountRevenue() {
        Account acct = [SELECT Id FROM Account LIMIT 1];
        // ...
    }
}

The interplay that trips people up: records created in @testSetup persist into each test method via the snapshot rollback, but static variables do not — each test method gets fresh statics. A common bug is caching an Id or a computed value in a static field during @testSetup and expecting it to still be set in a test method; it won't be. Requery the records you need at the top of the test method instead of relying on state carried over from setup.

Layer 2: the factory class

@testSetup covers the baseline every test in a class shares. It doesn't cover scenario-specific shapes — the Opportunity that's already Closed Won, the Contact missing an email, the Account at a specific billing country. That's what a factory class is for: a single place that knows how to build valid records, with defaults sensible enough that most callers don't override anything, and every field overridable for the tests that need to.

apex
@isTest
public class TestDataFactory {

    public static List<Account> makeAccounts(Integer count) {
        List<Account> accounts = new List<Account>();
        for (Integer i = 0; i < count; i++) {
            accounts.add(new Account(
                Name = 'Test Account ' + i,
                BillingCountry = 'United States',
                Industry = 'Technology'
            ));
        }
        return accounts;
    }

    public static List<Contact> makeContactsFor(List<Account> accounts, Integer perAccount) {
        List<Contact> contacts = new List<Contact>();
        for (Account a : accounts) {
            for (Integer i = 0; i < perAccount; i++) {
                contacts.add(new Contact(
                    AccountId = a.Id,
                    FirstName = 'Test',
                    LastName = 'Contact ' + i,
                    Email = 'test.contact' + i + '@example.com'
                ));
            }
        }
        return contacts;
    }
}

Two design choices matter more than the code itself. First, methods return unsaved records — the factory builds objects in memory, the caller decides when and whether to insert. That lets a test mutate a field before saving without a second DML statement, and it lets a test exercise validation-rule failures by inserting a deliberately invalid record. Second, every method is bulk-friendly by taking a count — makeAccounts(1) and makeAccounts(200) are the same code path, so a factory built for one record naturally scales to a bulk test.

For overrides, the common pattern is a template-and-merge: accept an optional SObject of overrides and layer it onto the defaults, or accept named parameters for the fields a scenario actually cares about. Either way, the caller states only what's different about this test's data — not the whole record shape.

apex
Account acct = TestDataFactory.makeAccounts(1)[0];
acct.BillingCountry = 'Germany';  // override what this scenario needs
insert acct;

The payoff shows up on the day the schema changes, not the day the factory is written. When a new validation rule requires Industry on Account, or a required custom field lands on Contact, you fix it in TestDataFactory once. Every test that calls makeAccounts or makeContactsFor picks up the fix automatically. Without a factory, that same schema change means editing every test method that constructs an Account inline — which in a suite of any size is how "just add a required field" turns into a multi-day cleanup.

Anti-patterns that quietly bring the coupling back

  • Copy-pasted arrange blocks per test. If every test method has its own inline new Account(...) with all fields spelled out, you've rebuilt the exact problem a factory solves — a schema change becomes a mass find-and-replace across the suite instead of an edit to one file.
  • Factories that insert eagerly. A factory method that calls insert internally forces DML the caller may not want and removes the chance to mutate the record first. Building in memory is free; let the caller insert deliberately, or offer an explicit saved variant (makeAndInsertAccounts) alongside the unsaved one rather than making insertion implicit.
  • "God factories" that build the entire object graph every time. A method that creates an Account, three Contacts, two Opportunities, and their line items because some test somewhere needed all of it makes every other test pay that cost too. Build what the scenario needs — compose smaller factory methods rather than one that does everything.

Where Test.loadData fits

Test.loadData(Account.sObjectType, 'resourceName') loads records from a CSV static resource into the test context. It's a reasonable choice for large, fixed datasets — hundreds of rows of realistic reference data that would be tedious to express as Apex object literals. The tradeoff is that the data lives outside the code: it doesn't survive schema churn as gracefully (a renamed or newly required field means editing a CSV, not a compiler-checked class), and it hides intent from someone reading the test — the shape of the data isn't visible at the call site. For anything smaller than "large fixed dataset," a factory keeps the arrange step readable in the same language as the assertion.

Where Nimbus fits

A factory-based suite is pure Apex — no org dependency baked into the test, no environment-specific record Ids, no assumption about what's already in the database. That means it runs unchanged locally in Nimbus, where each test method gets its own database transaction that's rolled back afterward: the same isolation model the platform already gives you with @testSetup, just without waiting on a deploy.

SeeAllData=true tests are the one category that stay unportable — by design. They depend on rows in a specific org, so they fail locally, fail in a fresh scratch org, and fail anywhere except the org they were originally written against. A local runner doesn't create that fragility; it just surfaces it sooner, before a migration or a new hire finds it for you.

Fast local runs also remove the incentive that produces SeeAllData=true in the first place. People reach for existing org data because creating proper test data feels expensive when every iteration costs a deploy and a wait. When nimbus test "*" runs the whole suite in seconds instead of minutes, writing a real factory stops being the slow path — it becomes the fast one.

Checklist

  • No SeeAllData=true without a written justification. Data genuinely uncreatable in a test context is a narrow set, and it's smaller than it used to be.
  • @testSetup for the shared baseline, factory for scenario-specific data. Don't make every test method rebuild the same arrange.
  • Defaults valid, overrides explicit, signatures bulk-friendly. A factory method should work for 1 record and 200 without changing shape.
  • Return unsaved records; insert deliberately. Let the caller decide when DML happens.
  • Fix schema churn in one file, not two hundred. That's the actual return on writing the factory.

Run your factory-based suite locally

No org, no deploy — the same per-test transaction isolation Salesforce gives you, running on your machine in seconds.