Testing Trigger-Queueable Systems in Apex
Overview
Triggers that enqueue Queueable jobs are a common Apex pattern for handling complex processing -- such as cloning record hierarchies, making callouts, or performing bulk operations that exceed synchronous limits. However, the asynchronous nature of Queueable jobs introduces specific challenges when writing test classes.
This article covers the essential patterns for testing trigger-Queueable systems, including:
- Using
@TestSetupto build complex object hierarchies efficiently - Forcing async execution with
Test.startTest()/Test.stopTest() - Exposing internal state for assertions with
@TestVisible - Verifying parent-child remapping after clone operations
- Handling edge cases like null and empty inputs
- Working around the single
startTest()limitation for chained Queueables
Testing Trigger-Queueable Systems in Apex
Triggers that enqueue Queueable jobs are a common pattern for offloading complex processing -- cloning record hierarchies, making callouts, or performing bulk operations that exceed synchronous limits. Testing these systems requires understanding how the Apex test framework handles asynchronous execution.
@TestSetup: Building Complex Object Graphs
For integrations and CPQ scenarios, test data often involves deep object hierarchies. Use @TestSetup to create this data once and share it across all test methods in the class:
@TestSetup
static void setupData() {
Account acct = new Account(Name = 'Acme Corp Test');
insert acct;
Opportunity opp = new Opportunity(
Name = 'Acme Deal',
AccountId = acct.Id,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30)
);
insert opp;
Quote q = new Quote(
Name = 'Acme Quote',
OpportunityId = opp.Id,
Status = 'Draft'
);
insert q;
List<QuoteLineItem> lines = new List<QuoteLineItem>();
// Assume Product2 and PricebookEntry exist
for (Integer i = 0; i < 5; i++) {
lines.add(new QuoteLineItem(
QuoteId = q.Id,
PricebookEntryId = getTestPricebookEntryId(),
Quantity = 10,
UnitPrice = 100.00
));
}
insert lines;
}
Each test method receives a fresh copy of this data (rolled back between methods), eliminating redundant setup and keeping tests focused on behavior.
Test.startTest() / Test.stopTest(): Forcing Async Execution
The critical mechanism for testing Queueable jobs is the Test.startTest() and Test.stopTest() boundary. Any Queueable job enqueued between these calls executes synchronously when Test.stopTest() is reached:
@IsTest
static void testTriggerEnqueuesCloneJob() {
Quote originalQuote = [SELECT Id FROM Quote LIMIT 1];
Test.startTest();
// This triggers the Queueable via a trigger or explicit enqueue
originalQuote.Status = 'Approved';
update originalQuote;
Test.stopTest(); // Queueable executes HERE, synchronously
// Now assert the results of the Queueable
List<Quote> clonedQuotes = [
SELECT Id, Name, Source_Quote__c
FROM Quote
WHERE Source_Quote__c = :originalQuote.Id
];
System.assertEquals(1, clonedQuotes.size(),
'Expected one cloned Quote from the Queueable job');
}
Without Test.startTest()/stopTest(), the Queueable never executes in the test context, and assertions against its output will fail.
Testing Static Variables with @TestVisible
Queueable classes often use static variables to prevent re-entrancy or pass configuration. The @TestVisible annotation exposes these to test methods:
public class RecordCloneQueueable implements Queueable {
@TestVisible
private static Boolean hasExecuted = false;
@TestVisible
private static Map<Id, Id> sourceToCloneMap = new Map<Id, Id>();
public void execute(QueueableContext ctx) {
hasExecuted = true;
// ... cloning logic that populates sourceToCloneMap
}
}
In the test:
@IsTest
static void testQueueableTracksExecution() {
Test.startTest();
System.enqueueJob(new RecordCloneQueueable(recordIds));
Test.stopTest();
System.assert(RecordCloneQueueable.hasExecuted,
'Queueable should have executed');
System.assert(!RecordCloneQueueable.sourceToCloneMap.isEmpty(),
'Source-to-clone map should be populated');
}
Verifying Object Remapping After Clone Operations
When a Queueable clones a parent record and its children, the cloned children must reference the cloned parent -- not the original. This is a common source of bugs:
@IsTest
static void testClonedLinesReferenceClonedQuote() {
Quote originalQuote = [SELECT Id FROM Quote LIMIT 1];
Test.startTest();
System.enqueueJob(new QuoteCloneQueueable(originalQuote.Id));
Test.stopTest();
Quote clonedQuote = [
SELECT Id FROM Quote
WHERE Source_Quote__c = :originalQuote.Id
LIMIT 1
];
List<QuoteLineItem> clonedLines = [
SELECT Id, QuoteId FROM QuoteLineItem
WHERE QuoteId = :clonedQuote.Id
];
for (QuoteLineItem line : clonedLines) {
System.assertEquals(clonedQuote.Id, line.QuoteId,
'Cloned line item should reference the cloned Quote, not the original');
System.assertNotEquals(originalQuote.Id, line.QuoteId,
'Cloned line item must not reference the original Quote');
}
}
Null Safety and Edge Case Testing
Always test boundary conditions:
@IsTest
static void testEmptyCollectionInput() {
Test.startTest();
System.enqueueJob(new RecordCloneQueueable(new List<Id>()));
Test.stopTest();
// Verify no errors and no records created
System.assert(RecordCloneQueueable.sourceToCloneMap.isEmpty(),
'Empty input should produce no clones');
}
@IsTest
static void testNullHandling() {
Test.startTest();
try {
System.enqueueJob(new RecordCloneQueueable(null));
Test.stopTest();
// If the Queueable handles null gracefully:
System.assert(true, 'Queueable handled null input without exception');
} catch (Exception e) {
System.assert(false,
'Queueable should handle null input gracefully: ' + e.getMessage());
}
}
The Single startTest() Limitation
Test.startTest() can only be called once per test method. This has implications for multi-step async chains (e.g., a trigger enqueues Job A, which enqueues Job B):
- Only the first Queueable in the chain executes within the test boundary.
- Chained Queueables (Job B enqueued by Job A) do not execute.
- To test Job B independently, write a separate test method that directly enqueues Job B with the expected inputs.
// Test method 1: Tests the trigger -> Job A chain
@IsTest
static void testJobAExecution() {
Test.startTest();
// Trigger action that enqueues Job A
Test.stopTest();
// Assert Job A results
}
// Test method 2: Tests Job B in isolation
@IsTest
static void testJobBExecution() {
// Set up the state that Job A would have produced
Test.startTest();
System.enqueueJob(new JobB(expectedInputFromJobA));
Test.stopTest();
// Assert Job B results
}
Key Takeaways
- Use
@TestSetupto build complex object hierarchies once and share across test methods. Test.startTest()/stopTest()is mandatory for Queueable testing -- without it, the job never runs.@TestVisibleexposes internal state for assertions without compromising encapsulation in production.- Always verify parent-child remapping after clone operations.
- Test empty collections and null inputs as first-class scenarios.
- For chained Queueables, test each link in the chain independently in separate test methods.