The Problem With DML in Tests
Every Salesforce developer learns the same pattern early on: create some records with insert, run your code, query the results. It works. But as a codebase grows, those tests become the bottleneck in every deployment. A suite of 500 tests that each do 3–5 DML statements can easily take 10–15 minutes to run — and that’s time where nobody is deploying anything.
The root cause is that DML in tests is not really unit testing. It is integration testing: you are testing your code and the database and validation rules and triggers and sharing rules, all at once. That is sometimes exactly what you want — but for the bulk of your business logic tests, it is unnecessary overhead.
The good news: with a small architectural change — dependency injection — you can eliminate DML from most of your tests entirely. Tests that used to take seconds now take milliseconds.
What DML Tests Actually Cost — Real Numbers
To make this concrete, I wrote two test classes against a real Salesforce sandbox. Both test the exact same business logic: a service that loads a batch of Opportunity records and updates a field on each one. One test class uses DML; the other uses in-memory mocks. Five test methods each.
| Test method | No-DML (ms) | DML (ms) |
|---|---|---|
| test1 | 56 | 1481 |
| test2 | 20 | 1270 |
| test3 | 20 | 1660 |
| test4 | 19 | 1011 |
| test5 | 19 | 1045 |
| Total | 144 ms | 15463 ms |
The DML approach is 107x slower — on just 5 test methods in a single class. In a large project with hundreds of service-layer tests, this gap translates directly to minutes added to every deployment cycle.
Also notice that dml_test1 (1481 ms) is noticeably slower than the others. That is @testSetup running once before the first test method. Its cost is paid every time the suite runs, regardless of how many tests pass.
But execution time is not even the biggest problem.
The Hidden Tax: Setting Up Dependent Data
The moment you write a DML-based test, you take on the responsibility of replicating the org’s data model requirements in your test setup. Every org accumulates customisations over time — required fields, picklist restrictions, validation rules, cross-object dependencies — and none of that knowledge is written down anywhere. You discover it by running tests and reading failure messages.
When I ran the DML test against the sandbox, it failed immediately. Not because the business logic was wrong, but because the test data did not satisfy the org’s Opportunity configuration. Fixing it required finding the correct value for a picklist field whose valid entries in this org differ from the Salesforce standard out-of-the-box values. A developer unfamiliar with the org would have no way to know the right value without either running the test to see the error or querying the schema.
That is a relatively short dependency chain — this particular org’s Opportunity object is not heavily customised. But the pattern scales badly. On the same org, attempting the same exercise with Account produced a chain of eight consecutive failures before the test could run, each one revealing a new dependency: required fields added by the business, validation rules that perform lookups against configuration objects, and ultimately a platform constraint that made proper test isolation structurally impossible — requiring @isTest(SeeAllData=true) on every test method and the abandonment of @testSetup altogether. The Account DML test took significantly longer to write than the Opportunity one, and the result was still fragile.
The no-DML test required none of this knowledge in either case. It compiled, deployed, and passed on the first run.
The No-DML Approach
The key insight is to separate concerns: extract query logic behind an interface, and wrap DML operations behind a unit-of-work interface. In tests, swap both out for lightweight in-memory fakes.
Step 1 — Define the interfaces
public interface BlogDemo_IOpportunitySelector { List<Opportunity> getByIds(Set<Id> opportunityIds);}
public interface BlogDemo_IUnitOfWork { void registerDirty(SObject record); void commitWork();}
Step 2 — Write the real implementations
public class BlogDemo_OpportunitySelector implements BlogDemo_IOpportunitySelector { public List<Opportunity> getByIds(Set<Id> opportunityIds) { return [ SELECT Id, Name, Description FROM Opportunity WHERE Id IN :opportunityIds ]; }}
public class BlogDemo_UnitOfWork implements BlogDemo_IUnitOfWork { private List<SObject> dirtyRecords = new List<SObject>(); public void registerDirty(SObject record) { dirtyRecords.add(record); } public void commitWork() { update dirtyRecords; }}
Step 3 — Write the service with injectable dependencies
public class BlogDemo_OpportunityService { private BlogDemo_IOpportunitySelector selector; private BlogDemo_IUnitOfWork unitOfWork; // Production path: real dependencies wired up automatically public BlogDemo_OpportunityService() { this(new BlogDemo_OpportunitySelector(), new BlogDemo_UnitOfWork()); } // Test path: caller provides the dependencies public BlogDemo_OpportunityService( BlogDemo_IOpportunitySelector selector, BlogDemo_IUnitOfWork unitOfWork ) { this.selector = selector; this.unitOfWork = unitOfWork; } public void markOpportunitiesAsProcessed(Set<Id> opportunityIds) { List<Opportunity> opportunities = selector.getByIds(opportunityIds); for (Opportunity opp : opportunities) { opp.Description = 'Processed'; unitOfWork.registerDirty(opp); } unitOfWork.commitWork(); }}
The no-arg constructor keeps all production call sites unchanged. Nothing breaks.
Step 4 — A utility for generating fake Ids
SObjects need a properly-formatted 18-character Id to be usable in-memory. This helper generates one without touching the database:
@isTestpublic class BlogDemo_TestFactory { private static Integer counter = 0; public static Id fakeId(SObjectType sobjectType) { counter++; String prefix = sobjectType.getDescribe().getKeyPrefix(); String sequence = String.valueOf(counter).leftPad(12, '0'); return (Id)(prefix + '0AA' + sequence); } public static List<Opportunity> buildOpportunities(Integer count) { List<Opportunity> opportunities = new List<Opportunity>(); for (Integer i = 0; i < count; i++) { opportunities.add(new Opportunity( Id = fakeId(Opportunity.SObjectType), Name = 'Test Opportunity ' + i, Description = 'Pending' )); } return opportunities; }}
Step 5 — The no-DML test
@isTestprivate class BlogDemo_NoDMLTest { private class MockSelector implements BlogDemo_IOpportunitySelector { private List<Opportunity> opportunities; MockSelector(List<Opportunity> opps) { this.opportunities = opps; } public List<Opportunity> getByIds(Set<Id> ids) { return opportunities; } } private class MockUnitOfWork implements BlogDemo_IUnitOfWork { public List<SObject> committed = new List<SObject>(); public void registerDirty(SObject record) { committed.add(record); } public void commitWork() { /* no DML */ } } private static void runTest() { List<Opportunity> fakeOpps = BlogDemo_TestFactory.buildOpportunities(10); Set<Id> fakeIds = new Map<Id, Opportunity>(fakeOpps).keySet(); MockUnitOfWork mockUow = new MockUnitOfWork(); BlogDemo_OpportunityService service = new BlogDemo_OpportunityService( new MockSelector(fakeOpps), mockUow ); service.markOpportunitiesAsProcessed(fakeIds); System.assertEquals(10, mockUow.committed.size()); for (SObject obj : mockUow.committed) { System.assertEquals('Processed', ((Opportunity) obj).Description); } } @isTest static void noDml_test1() { runTest(); } @isTest static void noDml_test2() { runTest(); } @isTest static void noDml_test3() { runTest(); } @isTest static void noDml_test4() { runTest(); } @isTest static void noDml_test5() { runTest(); }}
DML operations: 0. SOQL queries: 0. No knowledge of org-specific required fields or valid picklist values required.
Step 6 — The DML test, for comparison
@isTestprivate class BlogDemo_DMLTest { @testSetup static void setup() { List<Opportunity> opportunities = new List<Opportunity>(); for (Integer i = 0; i < 10; i++) { opportunities.add(new Opportunity( Name = 'BlogDemo Opportunity ' + i, Description = 'Pending', StageName = '/* org-specific valid stage value */', CloseDate = Date.today().addDays(30) )); } insert opportunities; } private static void runTest() { Set<Id> oppIds = new Map<Id, Opportunity>( [SELECT Id FROM Opportunity WHERE Name LIKE 'BlogDemo Opportunity%'] ).keySet(); BlogDemo_OpportunityService service = new BlogDemo_OpportunityService(); service.markOpportunitiesAsProcessed(oppIds); List<Opportunity> results = [ SELECT Description FROM Opportunity WHERE Id IN :oppIds ]; System.assertEquals(10, results.size()); for (Opportunity opp : results) { System.assertEquals('Processed', opp.Description); } } @isTest static void dml_test1() { runTest(); } @isTest static void dml_test2() { runTest(); } @isTest static void dml_test3() { runTest(); } @isTest static void dml_test4() { runTest(); } @isTest static void dml_test5() { runTest(); }}
The comment on StageName is intentional: the correct value is not a standard Salesforce value — it is specific to this org’s picklist configuration. A developer writing this test for the first time would not know it without querying the schema or reading a test failure message.
Side-by-Side Comparison
| No-DML | DML | |
|---|---|---|
| Total time (5 tests) | 144 ms | 15463 ms |
| Speed ratio | 1x | 107x slower |
| DML statements | 0 | 10 inserts + 10 updates |
| SOQL queries | 0 | 10 + 10 queries |
| Org-specific knowledge required | None | Required fields, valid picklist values |
| Breaks when org config changes | Never | Yes |
| Test isolation | Full | Partial (depends on org config) |
| Catches trigger/VR regressions | No | Yes |
When DML Tests Are Still the Right Choice
No-DML tests are not a universal replacement. Keep DML-based tests for:
- Trigger logic — triggers only fire on real DML; you must test them with DML.
- Validation rules themselves — if you want to verify that a rule fires correctly, you need to attempt the DML that triggers it.
- End-to-end acceptance tests — a small number of tests that exercise the full stack give you confidence that all the pieces work together in production.
- Selectors —
BlogDemo_OpportunitySelectorshould have its own test that runs a real SOQL query to verify the field list is complete and the query returns the right rows.
The goal is not to eliminate DML tests entirely — it is to push DML to the edges where it is genuinely needed, and keep the bulk of your business logic tests fast, isolated, and free from org-specific data dependencies.
Key Takeaways
No-DML tests are portable. They carry no assumptions about the org they run in. Move them to a new sandbox, a scratch org, or a CI environment and they work immediately.
DML tests are not unit tests. They are integration tests that couple your business logic to your org’s data model, validation rules, and configuration.
The dependency chain is invisible until it breaks. Every required field and valid configuration value your test data must satisfy is a maintenance burden that grows with the org and is discovered only through failure.
The cost compounds with scale. 107x slower on 5 tests becomes hours slower on a full suite. Every test you add to the DML pile makes the next deployment slower.
The architectural investment is small. Two interfaces, two real implementations, and a constructor that accepts them. Production call sites are unchanged.

Leave a comment