Skip to content

Tests that travel in time

· 3 min read · Renoxium

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 renox::prelude::*;
use renox::testing::TestApp;

#[renox::test]
async fn contact_messages_are_checked() {
    let app = TestApp::new(renoxium::app()).await;
    app.htmx()
        .post("/contact", &[("name", ""), ("email", "nope"), ("message", "short")])
        .await
        .assert_invalid("name")
        .assert_invalid("email")
        .assert_invalid("message");
    app.assert_database_count("contact_messages", 0).await;
}

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:

#[renox::test]
async fn a_scheduled_post_appears_on_its_day() {
    let app = blog().await;
    // Hidden today...
    app.get("/writing/from-the-future").await.assert_not_found();
    // ...and there once the clock reaches 1 January 2099.
    let until = NaiveDate::from_ymd_opt(2099, 1, 1).unwrap()
        .and_hms_opt(0, 0, 1).unwrap()
        .and_utc() - renox::db::now();
    app.travel(until.to_std().unwrap());
    app.get("/writing/from-the-future").await.assert_ok();
}

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(
    "https://api.stripe.com/v1/checkout/sessions",
    FakeResponse::json(200, json!({ "id": "cs_test_1", "url": CHECKOUT_URL })),
);

After the request under test, the test checks exactly what would have been sent to Stripe:

http.assert_sent(|r| {
    r.url.ends_with("/v1/checkout/sessions")
        && r.body.contains("mode=payment")
        && r.body.contains("%5Bcurrency%5D=usd")
        && r.body.contains("%5Bunit_amount%5D=2500")
});

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 testWhat 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 (response, queries) = capture_queries(app.get("/products")).await;
response.assert_ok();
assert!(queries.len() <= 3, "{queries:#?}");

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.

  1. · 4 min read A back office without a JavaScript framework Data grids with filters, search, grouping, row actions and exports, plus charts, all rendered on the server by Renox, with htmx for the parts that update in place.
  2. · 3 min read Catching N+1 queries before they ship Renox has no lazy relations on purpose. What it has instead: loaders that fetch a page's related rows in one query each, a debug page that flags repeated SQL, and tests that count queries.
  3. · 4 min read Renox: Laravel's workflow, compiled into one Rust binary What a Renox app is made of: modules, typed handlers, forms that validate themselves, server-rendered views and a CLI that writes the boilerplate.

Get new posts by Atom, RSS or JSON Feed.