Skip to content
All projects

Case study

SchedulePress: testing a scheduler without waiting for the clock

23 regression specs for a plugin whose entire job is to do something later. Cron is driven on demand rather than waited for, timezones are swept and restored, and every fixture is namespaced so a crashed run cannot poison the next one.

regression specs
23regression specs
active installs
10K+active installs
timezones swept
5timezones swept
  • Playwright
  • TypeScript
  • Docker
  • WP-CLI
  • GitHub Actions

The problem

SchedulePress schedules posts, runs an editorial calendar, recovers missed schedules and auto-shares to social media. Every one of those features is defined by something happening at a time that is not now.

That makes the obvious test strategy useless. You cannot schedule a post for two minutes from now and sleep, not 23 times, not in CI. And the failure you actually care about, a post that was supposed to publish and did not, only shows up if you can control the clock and the cron chain rather than wait for them.

The constraints

  • Time is the feature. Scheduling, republishing, unpublishing, missed-schedule recovery. Nothing meaningful happens synchronously.
  • WordPress cron is not cron. It fires on page requests, so on a quiet test site it effectively does not fire at all.
  • Timezones are a correctness surface, not a setting. A scheduler that is an hour out in UTC+6 is broken, and the bug is invisible if every test runs in one timezone.
  • State leaks between scheduling tests. A post left in future from a crashed run will publish itself during someone else’s test.

The decisions, and what I rejected

Drive cron, do not wait for it

Every time-dependent assertion goes through a helper that shells out to wp cron event run --due-now. The pattern for missed-schedule recovery is:

  1. Create a post through WP-CLI with --post_status=future and a post_date in the past. That is exactly what a missed schedule looks like in the database.
  2. Run cron on demand.
  3. Assert the post is now published.

No sleeping, no polling a wall clock, and the test asserts the actual recovery mechanism rather than a proxy for it. A helper for blocking a few real seconds does exist, for the handful of cron-timing scenarios that genuinely need elapsed time, but it is the exception rather than the shape of the suite.

Sweep timezones and put them back

The timezone spec does not pick a timezone. It sweeps: UTC+0 as a baseline, UTC+6 as a positive offset, UTC−5 as a negative one, and a named zone like America/New_York to confirm named zones behave like numeric offsets.

The behaviour under test is documented in the spec itself, because it is the part that is easy to get wrong: WP-CLI’s --post_date accepts local time, WordPress converts it to post_date_gmt using the current gmt_offset, and cron fires when post_date_gmt <= now(). A scheduler bug in that conversion is a post published at the wrong hour for everyone outside UTC.

Each case restores the original timezone afterwards. A spec that mutates a global site setting and leaves it changed is a spec that breaks the next one, and the failure lands somewhere else entirely.

Playwright’s own browser timezone is separately pinned to UTC with en-US, so browser locale never becomes an uncontrolled variable while the WordPress timezone is the thing being varied.

Give up parallelism on purpose

fullyParallel is off. One worker locally, two in CI.

This is the opposite of the EmbedPress suite, which runs four workers fully parallel, and the difference is the subject matter. EmbedPress specs read seeded pages that nobody else touches. SchedulePress specs mutate global site state: the timezone, the cron queue, post statuses. Running them in parallel would mean one test changing the site’s timezone while another asserts a publish time against it.

The suite is 23 specs. Trading a few minutes of wall clock for results that mean what they say is not a close call at that size.

Namespace every fixture and clean by prefix

Test posts are created with prefixes (E2E-Missed-, E2E-TZ-) and each spec deletes its own prefix in afterAll. Cleanup targets a namespace rather than tracked IDs, so a run that crashes halfway leaves debris that the next run removes on its way past.

What it cost, and what it caught

Cost. 68 commits, March to May 2026. A 120-second per-test timeout, set to allow full cron chains to play out, with a 10-second expectation timeout inside that. One retry in CI, none locally. Separate compose files for local and CI.

The 23 specs are numbered and ordered, from plugin activation and settings through the calendar, scheduling hub, post metabox and admin bar, then into the harder scenarios: full schedule-to-publish, republish and unpublish, missed-schedule recovery, the social share queue, advanced scheduling, API security, user roles, timezones and post-type scheduling.

Caught. The suite is coverage for a specific class of regression: a scheduling change that works in UTC and quietly breaks at an offset, or a cron path that stops recovering missed posts. Those are the failures that reach users as “my post never went out” and never show up in a synchronous smoke test.

Alongside it sits a branch-migration harness. scripts/setup-branch.sh checks out a given free or Pro plugin branch and restarts Docker so Playwright runs against that code, and .migration-state.json tracks a manual migration pass of seven posts covering standard scheduling, advanced scheduling, republish, unpublish, auto-schedule and manual schedule, each with the status it is expected to reach. That is a deliberately manual checklist for a migration, held next to the automated suite rather than pretending to be part of it.

What I deliberately did not automate

  • Real social media delivery. The share queue is verified inside WordPress. Asserting that a post actually appeared on a third-party network would make the suite depend on someone else’s API being up and someone else’s account not being rate-limited.
  • Real elapsed time. With one narrow exception, nothing waits for the clock. A test that takes two minutes to prove a two-minute schedule is a test nobody runs.
  • Parallel execution. Given up deliberately, for the reason above.
  • Cross-browser. Chromium only.