Many bugs depend on time. A link must stop working after seven days, a session must end after two hours, and a warranty covers 30 days and not 31. These are hard to test the usual way: you either wait, or you rewrite timestamps in the database and hope you edited every one that matters.
Renox tests move the clock forward instead. This post shows how, along with the other tools that keep this site’s tests fast: a whole app booted in memory, and HTTP calls that are faked instead of sent.
The whole app, in memory
A test boots the same app() that production runs, on an in-memory SQLite database migrated for that test:
use *;
use TestApp;
async
The requests go through the real router, middleware, sessions and CSRF checks, but no port is opened. app.htmx() sends a request the way htmx does, and assert_invalid("email") expects a 422 with an error on that field. For apps with accounts, app.acting_as(&user) makes the next requests come from that user. Each test gets its own database, so tests run in parallel without seeing each other’s rows. With TEST_DATABASE_URL set, the same tests run on PostgreSQL.
Moving the clock
app.travel(duration) moves the clock forward for everything the test app does next. This blog hides a post dated in the future until its day comes. Here is the test that it appears on time:
async
The test doesn’t sleep and doesn’t edit any timestamp by hand. The same idea checks anything with a deadline: a signed link that must stop working after seven days is one app.travel(8 days) away from its test, and so is a session that must end after two hours.
Travel works because Renox reads the time in one place, renox::db::now(). Sessions, signed URLs, the queue, the cache, rate limits, the login lock and password confirmation all use it, and so do the created_at and updated_at the framework fills in. Requests and app.run_jobs() see the moved clock. Code you call directly, such as a model method or a scheduled task, runs inside app.at_travelled_time(future).
There is one rule: app code has to read the time through renox::db::now() too, never SystemTime::now() or chrono::Utc::now(). Travel can’t reach a clock the framework doesn’t own.
HTTP that never leaves the test
Apps call other services through state.http, a client with timeouts and retries. In tests, app.fake_http() answers those calls and records them. A test for a page that sends people to Stripe Checkout starts like this:
let http = app.fake_http;
http.on;
After the request under test, the test checks exactly what would have been sent to Stripe:
http.assert_sent;
While faking is on, a request that matches no fake is an error. It is never sent for real, so a forgotten fake fails the test instead of quietly calling a live API from CI.
Jobs, events, notifications and mail
The other side effects work the same way:
| In the test | What it does |
|---|---|
app.run_jobs() / app.run_all_jobs() | Runs queued jobs now; the second also runs retries still waiting for their backoff |
app.fake_events() + assert_emitted::<E>(…) | Records events without running their listeners |
app.fake_notifications() + assert_notified(&user, "kind") | Records notifications without sending mail or writing rows |
app.assert_mail_sent(to, subject) | Checks the test mailer’s outbox |
app.kernel().run_scheduled("report") | Runs a scheduled task now |
Counting queries
renox::db::capture_queries(future) returns every SQL statement the future ran, requests included. That makes a performance promise testable:
let = capture_queries.await;
response.assert_ok;
assert!;
If someone later adds a query inside a loop, this test fails before the slow page ships. The next post in this series covers that kind of bug.
What it adds up to
This site has 31 tests, 25 of them integration tests on TestApp. They cover every page, the contact form and its mail, and the blog: drafts, scheduled posts, series, feeds and ETags. Once compiled, cargo test runs them in about two seconds on a laptop, and CI runs them on every pull request. None of them waits, and none of them touches the network.