How to Test a Cron Job Before It Runs in Production
Updated
Contents
To test a cron job, test its three parts separately: preview the schedule with a parser that follows your scheduler's rules, run the command the way cron will (as the crontab's owner, with cron's environment, no terminal, through /bin/sh), and unit-test the job's logic with a fixed clock. Then start one real run through the scheduler itself and watch the first scheduled run in production, since nothing before that proves the scheduler will start the job on time. Behavior here was checked on October 24, 2026 against cronie 1.7.2 (Fedora, RHEL and derivatives, Arch), Debian's cron 3.0pl1-197 (Debian 13; Ubuntu packages the same cron), systemd 262, Kubernetes 1.37, Hangfire 1.8.25, Quartz.NET 3.15 and 4.6, Celery 5.6, and croniter 6.2.4.
Preview the Schedule With a Parser That Reads It Like Your Scheduler#
Cron never prints when an entry will run next, so a preview always comes from another parser, and five-field expressions don't mean the same thing everywhere. Cron expression syntax covers the fields; the difference that misleads previews is how schedulers combine the two day fields when both are restricted:
| Scheduler | Preview with | Day of month and day of week both set |
|---|---|---|
| cronie, Debian cron | croniter with implement_cron_bug=True |
Either matches, but both if one starts with * |
| Kubernetes CronJob | robfig/cron v3.0.1's cron.ParseStandard |
Either matches, even with a step such as */2 |
| Hangfire 1.8 | Cronos 0.11's CronExpression.Parse |
Both must match |
| Quartz.NET | CronExpression.GetNextValidTimeAfter |
Not supported; one field must be ? |
| systemd timers | systemd-analyze calendar |
Both must match |
cronie and Debian's cron mark a day field as unrestricted when its first character is *, and require both fields to match when either is marked (cronie entry.c and cron.c; Debian entry.c and cron.c). robfig/cron, which the Kubernetes controller calls, drops that mark for a step such as */2 (parser.go, spec.go). So 0 9 */2 * 1 runs on Mondays that fall on odd-numbered days under cron, but on every odd-numbered day and every Monday in Kubernetes. Hangfire 1.8.25 parses with Cronos 0.11.1, which "uses AND operator" for the day fields (README). See also Quartz.NET's CronExpression.cs and systemd.time(7).
Linux Cron: croniter Plus a Syntax Check#
# preview.py (croniter 6.2): the next ten runs as cronie and Debian's cron read them.
from datetime import datetime
from zoneinfo import ZoneInfo
from croniter import croniter
zone = ZoneInfo("UTC") # use the cron daemon's time zone
runs = croniter("0 9 1-7 * 1", datetime.now(zone), implement_cron_bug=True)
for _ in range(10):
print(runs.get_next(datetime))The output includes Mondays after the 7th and days in the first week that aren't Mondays: what cron does with an expression meant as "the first Monday". croniter ORs the day fields by default, and implement_cron_bug=True adds the * rule above (README). It also accepts seconds, years, L, and hashed H values, none of which a crontab supports, so check the file with the cron that will run it: crontab -T file on cronie 1.5.6 and later (crontab(1)), or crontab -n file on Debian's cron 3.0pl1-160 and later (crontab(1), changelog). cronie's -n picks a cluster host instead. Both catch malformed lines, not wrong ones.
croniter reads a sixth field as seconds last unless you pass second_at_beginning=True, so Hangfire's seconds-first 0 0 2 * * *, 02:00 daily, previews as every second from 00:00:00 to 00:00:59 on the 2nd of each month.
In a browser, crontab.space reads a five-field expression back and lists its next runs in the time zone you pick. Before trusting any previewer with both day fields restricted, probe it with 0 9 1 * 1: a list that includes every Monday follows cron's either-field rule, and one with only Mondays falling on the 1st requires both.
systemd Timers: systemd-analyze#
systemd-analyze calendar --iterations=5 'Mon..Fri 09:00 America/New_York'
systemd-analyze calendar --iterations=3 --base-time='2026-10-31 12:00:00' '*-*-* 01:30 America/New_York'
systemctl list-timers nightly-report.timer--iterations needs systemd 242, and --base-time, which previews from a moment you choose, such as the day before clocks change, needs 244 (systemd-analyze(1)). Once installed, list-timers shows the next run from the unit systemd has loaded, which also reveals a missed daemon-reload.
Run the Command the Way Cron Will#
Your terminal gives a command your user, your shell's PATH and startup files, your current directory, and a terminal to prompt on. cronie and Debian's cron give it /bin/sh -c (dash on Debian and Ubuntu), the owner's home directory, and a few variables of their own, as cron environment variables details. Reproduce that:
# As root: run deploy's crontab command the way Debian's cron would.
sudo -u deploy setsid -w env -i \
HOME=/home/deploy LOGNAME=deploy SHELL=/bin/sh PATH=/usr/bin:/bin \
/bin/sh -c 'cd "$HOME" && /srv/app/bin/nightly-report' \
< /dev/null > /tmp/nightly-report-test.log 2>&1
echo "exit status: $?"sudo -u deployruns it as the crontab's owner, with that account's file permissions, groups, and SSH keys.env -istarts from an empty environment and adds only what Debian's cron sets. For cronie 1.7, addUSER=deployand usePATH=/usr/bin:/bin:/usr/sbin:/sbin. Add anyNAME=valuelines the crontab sets above the entry.setsid -wruns the command in a new session with no controlling terminal and returns its exit status (setsid(1)), so a password or host-key prompt fails as it would under cron instead of waiting for you. It matters even under sudo, which since 1.9.14 runs commands in a pseudo-terminal of its own by default (sudoers(5),use_pty).< /dev/nullgives the command no input, so areadgets end-of-file instead of waiting.
This copies cron's defaults but not what PAM adds, such as the locale Debian's cron loads from /etc/default/locale, and BusyBox crond passes its own environment through instead. For an exact copy, capture what a real run receives with a temporary entry and feed those lines to env -i, as the environment guide shows.
If you copied the command out of a crontab line, undo cron's % handling: \% becomes %, and anything after an unescaped % was standard input, not part of the command (crontab(5)). A script runs under its own shebang's interpreter; for #!/bin/sh scripts, shellcheck --shell=sh (ShellCheck 0.11) flags syntax POSIX sh lacks (manual). The test runs the job for real, so use a dry-run mode or staging data.
Start a Real Run Without Waiting for the Schedule#
The test above imitates cron. The next test uses the scheduler itself, with the job's real unit, pod template, or queue, and starts it now:
| Scheduler | Run it now | Not tested |
|---|---|---|
| crontab | A temporary entry two or three minutes ahead | The real time of day |
| systemd timer | systemctl start nightly-report.service |
The timer |
| Kubernetes CronJob | kubectl create job nightly-report-manual --from=cronjob/nightly-report |
Schedule, timeZone, suspend, concurrencyPolicy |
| Hangfire 1.8 | Trigger now on Recurring Jobs, or RecurringJob.TriggerJob("nightly-report") |
The schedule |
| Quartz.NET 3.x, 4.x | await scheduler.TriggerJob(new JobKey("nightly-report", "reports")) |
The cron trigger and its data |
| Celery 5.6 | celery -A proj call proj.tasks.nightly_report |
Beat, and the entry's arguments unless you copy them |
| GitHub Actions | gh workflow run nightly-report.yml, after adding workflow_dispatch |
The schedule |
crontab. Add a line for a time two or three minutes ahead in the cron daemon's time zone, wait for it, then remove it:
# Temporary: 14:47 in the daemon's zone. Remove after it runs.
47 14 * * * /srv/app/bin/nightly-report >> /tmp/nightly-report-test.log 2>&1cronie and Debian's cron check for changed crontabs every minute (cronie cron(8), Debian cron(8)) and log each job they start (cronie do_command.c): journalctl -u crond on Fedora and RHEL, journalctl -u cron on Debian and Ubuntu.
systemd. systemctl start runs the job's own service unit with its User=, environment, and limits, and for a Type=oneshot service waits and fails when the job fails (systemd timers).
Kubernetes. kubectl create job --from copies the CronJob's job template, labels, and annotations, and marks the Job cronjob.kubernetes.io/instantiate: manual (create_job.go). It doesn't add batch.kubernetes.io/cronjob-scheduled-timestamp, which the control plane sets on the Jobs it schedules (annotation reference), so a job that reads its period from that annotation needs a fallback, such as a period argument. Follow it with kubectl logs -f job/nightly-report-manual, then delete the Job.
Hangfire and Quartz.NET. Hangfire's TriggerJob creates a background job at once and enqueues it on the entry's queue (RecurringJobManager.cs). Quartz.NET's TriggerJob fires the job through a new one-off trigger (QuartzScheduler.cs), so data stored on the cron trigger doesn't come with it; pass a JobDataMap to the overload that takes one.
Celery. celery call sends the task by name to the broker for a worker to run, without beat, and prints its ID (call.py). Copy the beat entry's args, kwargs, and options (periodic tasks) as JSON and flags, such as --args='["2026-10-23"]' --queue=reports (CLI).
GitHub Actions. Add the manual trigger next to the schedule:
on:
schedule:
- cron: '17 3 * * *'
workflow_dispatch:It works only once the file is on the default branch, which scheduled runs also use (events); --ref runs another branch's copy (gh workflow run). A dispatched run's github.event_name is workflow_dispatch and it has no github.event.schedule, so steps gated on either behave differently.
Test the Job's Logic in Code#
A job that reads the clock and calls services wherever it needs them can only be tested by running it. Keep the scheduler's part to an entry point, and put the work in functions that take the period and their dependencies as arguments:
# billing_export.py: cron runs `python -m billing_export` at 00:30 UTC on the 1st.
import argparse
import datetime as dt
import sys
def period_for(now):
"""The month a run starting at `now` exports: the previous calendar month in UTC."""
first_of_month = now.astimezone(dt.timezone.utc).date().replace(day=1)
return (first_of_month - dt.timedelta(days=1)).strftime("%Y-%m")
def export(period, invoices, upload):
"""Upload every invoice for the period and return how many failed."""
failed = 0
for invoice in invoices(period):
try:
upload(period, invoice)
except Exception as exc:
print(f"invoice {invoice['id']}: {exc}", file=sys.stderr)
failed += 1
return failed
def main(argv, invoices, upload, now=None):
parser = argparse.ArgumentParser(prog="billing_export")
parser.add_argument("--period", help="YYYY-MM to export; defaults to last month")
args = parser.parse_args(argv)
period = args.period or period_for(now or dt.datetime.now(dt.timezone.utc))
return 1 if export(period, invoices, upload) else 0
if __name__ == "__main__":
from billing.store import load_invoices, upload_invoice # the application's data layer
sys.exit(main(sys.argv[1:], load_invoices, upload_invoice))# test_billing_export.py (pytest)
import datetime as dt
import pytest
from billing_export import main, period_for
UTC = dt.timezone.utc
@pytest.mark.parametrize(
("now", "expected"),
[
(dt.datetime(2026, 11, 1, 0, 30, tzinfo=UTC), "2026-10"),
(dt.datetime(2027, 1, 1, 0, 30, tzinfo=UTC), "2026-12"),
# 20:30 in New York on October 31 is already November 1 in UTC.
(dt.datetime(2026, 10, 31, 20, 30, tzinfo=dt.timezone(dt.timedelta(hours=-4))), "2026-10"),
],
)
def test_period_for(now, expected):
assert period_for(now) == expected
def invoices(period):
return [{"id": 101}, {"id": 102}, {"id": 103}]
class FakeUpload:
def __init__(self, failing=()):
self.failing = set(failing)
self.calls = []
def __call__(self, period, invoice):
self.calls.append((period, invoice["id"]))
if invoice["id"] in self.failing:
raise TimeoutError("storage did not respond")
def test_default_period_comes_from_the_clock():
upload = FakeUpload()
assert main([], invoices, upload, now=dt.datetime(2026, 11, 1, 0, 30, tzinfo=UTC)) == 0
assert upload.calls == [("2026-10", 101), ("2026-10", 102), ("2026-10", 103)]
def test_one_failed_upload_fails_the_run():
upload = FakeUpload(failing={102})
assert main(["--period", "2026-09"], invoices, upload) == 1
assert [invoice_id for _, invoice_id in upload.calls] == [101, 102, 103]The fixed times are ones the calendar eventually hands the job: a new month, a new year, and a clock outside UTC. The last catches a local-date bug: a version that took the date in New York time would export September. --period lets reruns and backfills choose the month without faking the clock.
The failure test checks that one failed upload doesn't stop the rest and that the run still returns 1, so cron or a monitor sees a failure, not a success with missing invoices. sys.exit(main(...)) makes that the exit status; a bare main() call would exit 0. Confirm it once through the real entry point with an input that fails, then check $?.
Jobs also run twice sometimes: a retry, an overlap, a manual rerun. Run yours twice against the same state and assert the second run changes nothing; idempotent scheduled jobs has pytest examples, including a run interrupted partway.
Test in Staging, Then Watch the First Production Run#
Staging is where the pieces meet: the real scheduler, image, secrets, and network. Keep the production schedule there; if you shorten it to see runs sooner, staging never tests the real one.
Give Each Environment Its Own Monitors#
In CronRadar, use one application and API key per environment, so a staging check-in can't update or alert on a production monitor. A check-in that carries schedule creates its monitor on the job's first run; confirm it appears in the staging application with the expression you meant. CronRadar reads five-field cron in UTC and, when both day fields are restricted, expects a run only on dates matching both. So a crontab on a non-UTC server sends the UTC equivalent, and croniter with day_or=False previews the expression as CronRadar reads it. schedule and gracePeriod apply only when a check-in creates the monitor, so after fixing an expression in the job, change it in the monitor's settings too.
There is no CronRadar SDK or auto-discovery for crontab, systemd, Kubernetes, Celery, or GitHub Actions, so each job reports with its own HTTP check-in, such as the wrapper in reviewing AI-generated cron jobs. Hangfire and Quartz.NET recurring jobs can use the CronRadar.Hangfire and CronRadar.Quartz packages, which discover them; cronRadar.Quartz 0.1.0 monitors only jobs on repeating simple triggers: it sends a cron trigger's six-field expression unchanged, and CronRadar rejects it until the pending 0.1.1.
Test Failure Alerts Without Failing Production#
CronRadar's verification guide says to exercise failure handling with a harmless job in a separate test application and an agreed test destination, never by failing a production job. With the test application's key:
curl --fail-with-body --silent --show-error --max-time 5 \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=0 0 1 1 *" \
--data-urlencode "message=Alert route test; no job failed" \
"https://cron.life/api/v1/ping/alert-route-test/fail"A fail check-in opens an incident immediately. Confirm the alert arrives, then send a success check-in to https://cron.life/api/v1/ping/alert-route-test and confirm the recovery message. For a missed run, send one check-in to a second monitor key with schedule=* * * * * and gracePeriod=60, then nothing: it goes warning once the next minute passes, and critical, with an incident, once past the grace period and at least two minutes late. Test monitors count toward the account's active monitors, so delete them afterward. On Grow, a test monitor still counts toward that month's bill after you delete it, because each schedule class is billed on the month's peak number of paid monitors, and the first 10 free monitors go to the fastest schedules first, so a * * * * * test monitor can take a free slot (pricing).
For a Hangfire job, make a test job throw in a non-production application. The published CronRadar.Hangfire 0.1.0 (checked September 20, 2026) can report a failure before Hangfire finishes retrying, including cancellations, so the alert can come on the first failed attempt; reporting only after retries are exhausted requires the pending 0.2.1 release.
The First Scheduled Run Is the Last Test#
A self-registering monitor appears with its job's first check-in, so look for it in the production application after the first scheduled time; if it's missing, the job didn't run or couldn't report. From then on, a run that doesn't arrive turns it critical once past the grace period and at least two minutes late, and a start with no finish is flagged as stuck once the next expected run plus the grace period passes.
Pre-Deploy Checklist#
| Check | How | Catches |
|---|---|---|
| Schedule | Next runs from a parser in the scheduler's dialect | Day-field rules, seconds-first expressions |
| Syntax | crontab -T, crontab -n, systemd-analyze verify |
Lines the scheduler rejects |
| Environment | sudo -u, setsid -w env -i, /bin/sh -c, /dev/null |
PATH, variables, relative paths, prompts |
| Scheduler path | A run started through the scheduler itself | Permissions, image, queue, unit settings |
| Period | Unit tests at month and year ends, on a non-UTC clock | Wrong periods |
| Failure paths | One failing item; $? from the real entry point |
Failures that exit 0 |
| Reruns | Run twice; interrupt and rerun | Duplicate rows, charges, emails |
| Alert route | A fail and a missed run in a test application |
Alerts that reach no one |
| First real run | The monitor appears after the first scheduled time | A job that never starts |
Frequently Asked Questions#
How do I test a cron job without waiting for it to run?#
Add a temporary crontab entry two or three minutes ahead and remove it after it runs; cronie and Debian's cron pick up the change within a minute. Other schedulers start a run on demand, such as systemctl start for a timer's service; none of these tests the schedule.
How do I run a cron job manually?#
Run the command as the crontab's owner with sudo -u, under setsid -w env -i with only cron's variables, through /bin/sh -c from the home directory, with input from /dev/null. That reproduces cron's user, environment, shell, directory, and lack of a terminal.
How can I check a cron expression's next run times?#
Use a parser that follows the scheduler that will run it: croniter with implement_cron_bug=True for cronie and Debian's cron, systemd-analyze calendar for timers, Cronos for Hangfire, and robfig/cron v3 for Kubernetes. Probe any other tool with 0 9 1 * 1 to learn whether it combines the day fields with OR or AND.
Why does my cron job work when I run it manually but not from cron?#
Cron runs it with a minimal environment and a short PATH, through /bin/sh instead of your shell, from the home directory, and without a terminal. Running it with env -i as shown above tells you which difference breaks it.
How do I test a Kubernetes CronJob?#
kubectl create job name-manual --from=cronjob/name starts a Job from the CronJob's template now, testing the image, command, service account, and secrets. It skips the schedule, timeZone, suspend, and concurrencyPolicy, and the Job lacks the scheduled-timestamp annotation. Preview the schedule with robfig/cron v3, the parser the controller uses.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.