Skip to content

Refactor tests to use sqlx::test and improve test isolation - #3

Merged
davetayls merged 5 commits into
masterfrom
claude/testing-story-analysis-crm69k
Aug 1, 2026
Merged

Refactor tests to use sqlx::test and improve test isolation#3
davetayls merged 5 commits into
masterfrom
claude/testing-story-analysis-crm69k

Conversation

@davetayls

Copy link
Copy Markdown
Member

Summary

This PR significantly refactors the test suite to use #[sqlx::test] macro for better test isolation and removes the stress test migration files. The changes improve test reliability by giving each test its own freshly migrated database and eliminate shared state issues.

Key Changes

  • Migrated from custom test infrastructure to #[sqlx::test]: Replaced manual database pool management and test synchronization with sqlx's built-in test macro, which automatically provides isolated databases for each test
  • Refactored test helpers:
    • Replaced test_pool() with direct pool parameter injection
    • Created JobStream abstraction to capture dispatched jobs instead of using counters and sleeps
    • Added configured_job_runner() for tests that need custom runner options
    • Introduced database query helpers (queue_len, after_message_id, attempt_state, stored_payload, retry_backoff) for direct state inspection
  • Eliminated timing-based assertions: Replaced pause() calls and counter-based assertions with direct database queries and event streams, making tests deterministic and faster
  • Added comprehensive unit tests for should_retry(): Created should_retry_tests module with a StubDbError implementation to test retry logic without database connections
  • Added unit tests for to_duration(): Added tests in runner.rs for the interval-to-duration conversion helper
  • Removed stress test migrations: Deleted migration files from sqlxmq_stress/migrations/ as they're now symlinked to the main migrations directory
  • Updated CI configuration: Modified GitHub Actions to use #[sqlx::test] which handles migrations automatically
  • Updated dependencies: Bumped sqlx to 0.9 and added migrate feature for dev dependencies

Notable Implementation Details

  • Tests now wait for specific events (job dispatch, database state changes) rather than sleeping, making them faster and more reliable
  • The JobStream pattern allows tests to drive job execution explicitly, enabling precise control over retry and ordering scenarios
  • Database state is verified directly via SQL queries rather than inferred from job execution counts
  • Each test gets a completely isolated database, eliminating cross-test contamination
  • The TIMEOUT constant (30 seconds) serves as a hang detector rather than a timing assumption, preventing flaky failures on slow machines

https://claude.ai/code/session_01CmYdXrFbwtMHHHm1tREDUJ

claude added 5 commits August 1, 2026 12:57
`sqlxmq_stress` has not compiled since the sqlx 0.9 bump: it still pinned
sqlx 0.8, so its `Pool<Postgres>` was a different type from the one
`sqlxmq` expects and none of the executor bounds resolved.

CI did not notice because `cargo check` and `cargo clippy --all-targets`
at a root that is both a package and a workspace only build the root
package. Both now run with `--workspace`, as does `cargo fmt`.

The stress crate also carried its own copy of the migrations, which had
drifted to three of the seven currently in the repository, so it was
exercising a schema several fixes behind. Replace the copy with a symlink
to the canonical directory.

Verified by running the harness end to end against a fresh database:
10000 jobs, ~649 jobs/s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmYdXrFbwtMHHHm1tREDUJ
The pure helpers in this crate had no coverage at all: every test needed
a live PostgreSQL connection, so nothing exercised the plain arithmetic.

`to_duration` converts a `PgInterval` into the sleep duration the runner
waits before its next poll. Cover the month/day/microsecond components
and, in particular, the negative case: `Duration` cannot represent a
negative value, so an overdue `attempt_at` must collapse to zero and be
polled immediately rather than waited on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmYdXrFbwtMHHHm1tREDUJ
`should_retry` tells callers which database errors are worth retrying,
but nothing exercised it. The cases it distinguishes are easy to get
wrong and expensive when wrong: a constraint violation on one of the
two ordered-channel constraints is a lost race and should be retried,
whereas the same SQLSTATE raised by the caller's own schema is a genuine
conflict that will just recur.

The errors are built from a stub `DatabaseError`, so these run without a
database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmYdXrFbwtMHHHm1tREDUJ
The SKIP LOCKED change in #2 exists so that concurrent pollers claim
disjoint sets of messages instead of blocking on each other's row locks,
but nothing tested it: no test ever started more than one runner.

Two tests, at different levels:

`it_polls_disjoint_messages_from_concurrent_transactions` calls mq_poll
from two explicit transactions so the first still holds its row locks
when the second runs. It asserts the second poll returns promptly, that
the two claims are disjoint, and that together they cover every spawned
job. Confirmed to fail against the pre-SKIP-LOCKED mq_poll, where the
second poll blocks until the test's timeout fires.

`it_delivers_each_job_exactly_once_with_concurrent_runners` covers the
same ground end to end: four runners with deliberately low concurrency
limits, jobs spread over several channels, asserting exactly-once
delivery and an empty queue afterwards. It does not single out SKIP
LOCKED - it guards the delivery contract the locking strategy exists to
uphold, so it stays valid if that strategy changes again.

Both are event-driven rather than sleep-based, and complete in about a
second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmYdXrFbwtMHHHm1tREDUJ
Two changes to the same harness.

Isolation: replace the hand-rolled pool with `#[sqlx::test]`, which gives
each test a fresh database with the migrations already applied. That
removes three problems at once. The global `TEST_MUTEX` is gone, so tests
run in parallel instead of queueing for one shared database. The
`TRUNCATE mq_payloads` / `DELETE FROM mq_msgs` reset is gone, and with it
the risk of `cargo test` wiping the job tables of whatever database an
ambient `DATABASE_URL` happened to point at - `dotenvy::dotenv()` does not
override an already-set variable, so `.env` was not the safeguard it
looked like. And migrations are now exercised by every test run rather
than applied out of band, so CI no longer builds sqlx-cli: the workflow
just creates an empty database.

Synchronisation: the tests asserted on a counter after sleeping a fixed
interval, which made them a race between the runner and the clock -
`it_can_checkpoint_jobs` lost that race consistently on a slower machine,
because the automatic keep-alive pushes `attempt_at` out from under a
test that assumes a retry lands exactly one backoff after the attempt.
Runners now hand each dispatched job to the test, which waits for the
events it cares about. Where the old tests waited to see whether
something further would happen, the new ones assert on the state that
makes it impossible: a cleared channel has no rows left, an exhausted
message has `attempt_at IS NULL` and can never be polled again, and a
completed one is deleted. Remaining timeouts are hang detectors set far
above any real duration, not timing assumptions.

The assertions got stronger in the process. The ordered-batch test now
checks the chain in `mq_msgs` directly, which is what the ordinality
migration actually changed; the checkpoint test checks that the payload
was replaced rather than only counting attempts; and
`it_can_use_registry`, which previously asserted nothing at all, now
confirms all three jobs ran by having them report through a context.

No `sleep` remains in the test module. The suite is green 15 runs in a
row and 5 more under 2x CPU oversubscription, in about 4 seconds against
11.5 locally and roughly 37 seconds of sleeping on CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmYdXrFbwtMHHHm1tREDUJ
@davetayls
davetayls merged commit f3ff546 into master Aug 1, 2026
12 checks passed
@davetayls
davetayls deleted the claude/testing-story-analysis-crm69k branch August 1, 2026 13:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants