Testing¶
Most of the defects that mattered in leBLOB were found by real clients, real disks and a second platform, not by unit tests. So the tests go all the way down: from pure functions, through a real server driven by real S3 clients, to power cut at the block layer.
Commands¶
| Command | What it runs | Time |
|---|---|---|
make test |
The whole test pyramid | about 2 min |
make lint |
ruff check and format, ty, pyrefly, mypy | |
make format |
ruff format and autofix | |
uvx nox |
What CI runs: the tests on Python 3.12 to 3.14, then the linters | |
make compat |
The ceph/s3-tests suite against a real server |
4 to 9 min |
make power |
Power cuts at the block layer, on ext4, XFS and btrfs | 7 to 10 min |
make disks |
Disks dying, filling and rotting under a real server | about 4 min |
TMPDIR=/var/tmp make bench |
Benchmarks against a real server | over 20 min on Linux |
To run one test, or one layer:
uv run pytest tests/a_unit/test_routing.py::test_root_lists_buckets
uv run pytest -m unit # or -m integration, -m e2e
uv run pytest -m "not slow" # skip the kill -9 crash loop
The layers¶
| Directory | What it tests |
|---|---|
tests/a_unit |
The pure modules: routing, signatures (including AWS's published SigV4 test vectors), aws-chunked decoding split byte by byte, conditions, listings, multipart rules, lifecycle rules, XML |
tests/b_integration |
meta.py and blobstore.py against real SQLite and real files, and the ASGI application with requests signed by botocore, an independent SigV4 implementation |
tests/c_e2e |
boto3, the AWS CLI v1 and v2, rclone and s5cmd against a real server process, over HTTP and HTTPS |
Markers come from the directory. Tests use no mocks, and async tests use anyio.
The end-to-end tests run each client over both schemes because clients send different requests over each. boto3 over HTTPS sends aws-chunked with a trailing CRC32 and no Content-Length. The AWS CLI v2 uses aws-chunked for parts even over plain HTTP. A server that only handled plain bodies would pass the tests over HTTP and fail as soon as TLS was on.
The rclone, s5cmd and AWS CLI v2 tests are skipped if those tools aren't installed. The v2 tests look for aws outside the virtual environment, whose own aws is v1.
The tests ignore the developer's ~/.aws/config, which once hid that boto3 presigns with SigV2 by default. They also turn off botocore's CRT signer, which most boto3 users don't run, so boto3 in the tests can't send crc32c or crc64nvme: the AWS CLI v2 and make compat cover those.
tests/c_e2e/test_durability.py kills the server with kill -9 in a loop under PUT load, then checks that every acknowledged object reads back with the right hash and no partial object is visible. A build that acknowledges before committing loses objects and fails it.
tests/upgrade/stores/ holds a sample store written by each release, and the tests open each with the current code.
S3 compatibility¶
make compat downloads ceph/s3-tests and runs it against a real server, with lifecycle days shortened to 2 s so the suite's own timing tests can run. 349 tests pass. The run fails if any of those named in tests/compat/s3-tests-passing.txt stops passing.
When a change is meant to alter that list, update it with uv run python tests/compat/run_s3tests.py --update, which names any test it drops. A rising or falling count isn't enough to judge a change: some encryption tests once passed only because the server ignored the encryption headers.
Power cuts¶
make power runs a real server on loop devices in a privileged container on Linux, through dm-log-writes, which records the block writes and flushes the server causes. The log is then replayed up to a chosen point onto a copy of the filesystem, which gives what a disk would hold had the power failed there. Half of the cuts fall right after a flush. The others fall inside the writes before one, keeping a random half of them, as a drive's cache may.
Four writers run PUTs, overwrites, deletes and multipart uploads, with the collector running as if the grace period were over. Each operation is marked in the log when acknowledged. At each cut:
- acknowledged operations must all be there, plus at most the one in flight;
- objects must hold exactly the right bytes, with no unknown object among them;
leblob fsckmust accept the store;- a new server must start on it and take a write.
The harness refuses to judge unless replaying the whole log reproduces the device byte for byte. POWER_ARGS=--two-copies runs the same on three volumes, two copies of each blob, one volume draining throughout and versioning on.
A test that can't fail proves nothing. POWER_ARGS=--control runs a build that never flushes a blob's file, and passes only if every filesystem then has failed cuts. Earlier runs of builds with one protection removed showed that each of them is needed on all three filesystems:
| Build, 40 cuts each | btrfs | ext4 | XFS |
|---|---|---|---|
Blob fsync removed |
38 failed | 37 failed | 37 failed |
Directory fsync removed |
11 failed | 5 failed | 7 failed |
SQLite synchronous=NORMAL |
39 failed | 36 failed | 38 failed |
The real build failed no cut in 160 on each filesystem, on Linux 7.2.9 with the store on loop devices over tmpfs.
It can't see a block written in part, a drive that acknowledges a flush it hasn't done, or a promise broken by only milliseconds. A build that acknowledged writes just before committing them passed 80 cuts, because acknowledging and marking the log takes longer than the commit. Such an ordering needs a test of its own, as test_no_one_is_told_a_write_is_done_before_the_commit is for shared commits.
Run it from a Mac on a Linux machine with Docker, through DOCKER_HOST=ssh://host make power or a Docker context. OrbStack's VM lacks ext4 and XFS. tests/power/README.md says how to run and read these tests, and when to.
Failing disks¶
make disks runs a real server on ext4, XFS and btrfs over device-mapper, and uses dm-error to make all I/O to a mounted volume fail with EIO, as a dying disk does. Testing with chmod instead would give EACCES, and wouldn't stop root. It checks that:
- with one copy, objects on the other volume still read, and reads on the dead one fail cleanly, with no wrong bytes;
- a full volume sends new blobs elsewhere. When the only disk is full, PUTs fail until there is room and leave nothing behind;
- with two copies, a volume dying or a disk pulled under load causes no client error, and
volume forget --disk-lostbrings the blobs back to two copies with a cleanfsck; - a block corrupted under the filesystem is found by scrub and repaired.
Continuous integration¶
A push runs three jobs on sourcehut, with manifests in .builds/:
alpine.ymlandubuntu-2404.yml: the tests, the linters, thennoxacross Python 3.12 to 3.14. Alpine'saws-clipackage is v2, so the v2 tests run there.disks.yml: on an Ubuntu 24.04 VM, which gives root, loop devices and device-mapper with no container,make disks, then the power test with 20 cuts, with two copies, and as the control.
Measuring¶
Keep measurement runs to minutes. uv run python -m tests.bench.run_scale fill --dir D --keys 1000000 fills a store with 1M keys in under a minute. Its other subcommands probe, read, run a sustained load and check it. tests/bench/baseline.md holds the results so far, with what each was measured on.
Don't judge write speed on macOS, whose fsync doesn't flush the drive's cache: a 1 KiB PUT looks 10 to 30 times faster there than on Linux.