The short answer: wrap the scheduling or the batch launch in Test.startTest() and Test.stopTest(), and the asynchronous work runs synchronously before stopTest returns. For a batch, only one call to execute is guaranteed, so keep your test data inside a single chunk. Then assert on the records the job produced, not on the fact that it ran. A test that schedules a job and checks a CronTrigger row exists has proved nothing about the code.
Testing a Schedulable
Two tests, doing different jobs.
@IsTest
static void schedulesWithTheExpectedExpression() {
Test.startTest();
String jobId = System.schedule(
'Nightly tier refresh',
'0 0 2 * * ?',
new TierRefreshSchedule()
);
Test.stopTest();
CronTrigger ct = [
SELECT CronExpression, TimesTriggered
FROM CronTrigger WHERE Id = :jobId
];
Assert.areEqual('0 0 2 * * ?', ct.CronExpression);
}
That one checks the wiring. The job’s execute also runs at stopTest, so any records it creates are there to assert on afterwards.
The second test skips scheduling entirely and calls the method:
@IsTest
static void refreshesTierOnQualifyingAccounts() {
// arrange accounts here
Test.startTest();
new TierRefreshSchedule().execute(null);
Test.stopTest();
Account a = [SELECT Tier__c FROM Account WHERE Name = 'Acme' LIMIT 1];
Assert.areEqual('Gold', a.Tier__c);
}
Passing null as the SchedulableContext is fine as long as the implementation does not use it, and it usually does not. This is the test that actually covers the logic, and it is faster and clearer than going through the scheduler.
When a schedulable exists only to call Database.executeBatch, do not try to test the whole chain in one method. Assert that the batch was enqueued, and test the batch separately.
Testing a Batchable
@IsTest
static void closesStaleCases() {
List<Case> cases = new List<Case>();
for (Integer i = 0; i < 50; i++) {
cases.add(new Case(Subject = 'Stale ' + i, Status = 'New'));
}
insert cases;
Test.startTest();
Database.executeBatch(new StaleCaseBatch(), 200);
Test.stopTest();
Assert.areEqual(50, [
SELECT COUNT() FROM Case WHERE Status = 'Closed'
]);
}
The important constraint: in a test context only one execution of execute is guaranteed. Insert fewer records than the scope size, or the records beyond the first chunk are never processed and your assertion fails for a reason that has nothing to do with the code. Fifty records with a scope of 200 is a safe shape.
This also means a test cannot demonstrate that the job works across many chunks. It can only demonstrate that one chunk is correct. Which is fine, because correctness per chunk plus a query that is properly filtered is the whole of a batch job.
Test the start method separately
The start method is where most batch bugs live, because a wrong QueryLocator silently processes the wrong set. Test it by calling it directly.
@IsTest
static void startSelectsOnlyStaleCases() {
// one stale case, one fresh case, already inserted
Database.QueryLocator ql = new StaleCaseBatch().start(null);
Database.QueryLocatorIterator it = ql.iterator();
Integer count = 0;
while (it.hasNext()) { it.next(); count++; }
Assert.areEqual(1, count);
}
No startTest needed here, because nothing asynchronous is happening. You are just reading the locator.
Asserting on the job, when that is what you mean
After stopTest, the AsyncApexJob row is available and carries the counts.
AsyncApexJob job = [
SELECT Status, NumberOfErrors, JobItemsProcessed, TotalJobItems
FROM AsyncApexJob WHERE ApexClass.Name = 'StaleCaseBatch'
ORDER BY CreatedDate DESC LIMIT 1
];
Assert.areEqual(0, job.NumberOfErrors);
Assert.areEqual('Completed', job.Status);
Use this alongside record assertions, never instead of them. A job can complete with zero errors and have done nothing useful.
The things that go wrong
- Only one
startTestandstopTestpair per test method. A test that needs two asynchronous phases needs two test methods. - Data created after
startTestis still visible to the job, but it also consumes the fresh limits you were given. Set up before. - Chained jobs do not chain in tests. A batch whose
finishenqueues another batch will not run the second one. Test the second directly. Database.Statefulstate is only meaningful across chunks, and there is one chunk, so you cannot test accumulation this way. Test the accumulating method as a unit instead.@TestSetupdata is created in its own transaction, so it does not eat the test method’s limits. Use it.
Frequently asked
Does a scheduled job run on its schedule during a test? No. It runs once, at stopTest, regardless of the cron expression. That is why asserting on the expression and asserting on the outcome are two separate things.
Can I schedule a job for the past in a test? The scheduler rejects past dates in a cron expression the same way it does in production. Use a valid future expression and rely on stopTest to run the work.
How do I test a batch that makes callouts? Implement Database.AllowsCallouts, set a mock with Test.setMock before startTest, and the mock applies inside the asynchronous execution.
Will the test catch a limit problem in the job? Sometimes, and not reliably. A test runs one chunk against a handful of records, while production runs thousands of chunks against real volume. Read the job’s limit profile yourself rather than trusting a green test: asynchronous work gets 12 MB of heap and 60,000 ms of CPU, and a per record query is still a per record query.
