Skip to main content

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 @TestSetup to build complex object hierarchies once and share across test methods.
  • Test.startTest()/stopTest() is mandatory for Queueable testing -- without it, the job never runs.
  • @TestVisible exposes 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.