Testing
@nest-native/jobs/testing ships two helpers that keep job tests hermetic —
no worker loop, no timers, no external services. On SQLite the whole stack
(store + claimer + handlers) runs in-memory.
drainJobs
function drainJobs(claimer: JobsClaimer, options?: DrainJobsOptions): Promise<TickReport>;
interface DrainJobsOptions {
runner?: RunnerConfig; // overrides applied to every tick
maxTicks?: number; // safety valve, default 100 — throws if exceeded
}
"Run everything currently due" in one await: it ticks the claimer until a tick claims nothing and returns the aggregated report.
import { drainJobs } from '@nest-native/jobs/testing';
await userService.register('u-1', 'ada@example.com');
const report = await drainJobs(app.get(JobsClaimer));
expect(report.completed).toBe(1);
Jobs a tick reschedules into the future (retry backoff, delayMs) are not due,
so a drain settles even while retries are pending — wait past the delay and
drain again to run them. If a job keeps retrying with no delay, the
maxTicks valve throws instead of spinning forever.
RecordingJobHandler
class RecordingJobHandler implements JobHandler {
handle(payload, ctx): void; // records, then throws if armed
executions(): readonly RecordedJobExecution[]; // every execution, in order
failNextWith(error: Error): void; // throw once, then succeed
failWith(error: Error): void; // throw persistently
clearFailure(): void;
reset(): void;
}
interface RecordedJobExecution {
payload: Record<string, unknown>;
ctx: JobContext; // { jobId, attempt }
}
Subclass it to attach the decorator — the subclass is a normal provider, so the explorer discovers it and your test can inspect it:
import { JobHandler } from '@nest-native/jobs';
import { RecordingJobHandler } from '@nest-native/jobs/testing';
@JobHandler('email.welcome')
@Injectable()
class WelcomeEmailHandler extends RecordingJobHandler {}
// in the test
const handler = app.get(WelcomeEmailHandler);
handler.failNextWith(new RetryableError('flaky', 0));
await userService.register('u-1', 'ada@example.com');
const report = await drainJobs(app.get(JobsClaimer));
expect(report).toEqual({ claimed: 2, completed: 1, retried: 1, failed: 0 });
expect(handler.executions()).toHaveLength(2);
expect(handler.executions()[1].ctx.attempt).toBe(2);
Executions are recorded before an armed failure is thrown, so
executions() reflects every attempt the claimer made — exactly what you want
when asserting retry behavior.
A full in-memory fixture
The engine's own test suite boots a real Nest application context on an in-memory better-sqlite3 database — the same fixture works in your app's tests:
const raw = new Database(':memory:');
raw.exec(JOBS_DDL); // your drizzle-kit migration output
const db = drizzle(raw);
const app = await NestFactory.createApplicationContext(
AppTestModule.register(db), // ClsModule + JobsModule.forRoot + your providers
{ logger: false },
);
// enqueue via your @Transactional services, then:
await drainJobs(app.get(JobsClaimer));
Because the claimer only acts when you tick it, tests stay deterministic: no
sleeping for poll intervals, no races. For time-based behavior (delayMs,
runAt), enqueue with a small delay and await setTimeout(...) past it — or
assert the negative first (tick() claims 0) like the showcase smoke does.