Testing¶
Two things a project's tests need from this package: that they never post to a real
chat, and a way to test their own backup wiring without S3, age or a network.
Tests never notify¶
A CI job that inherited the production bot token from the project's variables once posted the test suite's backup messages to the team's chat. So:
- pytest: installing the package registers a pytest plugin (
pytest11entry point) that silences every notifier able to reach the network, from the first test to the last. Nothing to configure. Under pytest-xdist (pytest -n 2) every worker is a process of its own that loads the plugin itself, so each one is silenced too. -
Django's test runner: use the shipped runner —
TEST_RUNNER = 'offsite_backup.testing.OffsiteBackupTestRunner'— or, when the project already has a runner of its own, mix the behaviour into it, the mixin first:
from offsite_backup.testing import OffsiteBackupRunnerMixin class ProjectRunner(OffsiteBackupRunnerMixin, YourRunner): ...OffsiteBackupTestRunneris exactlyOffsiteBackupRunnerMixinover Django'sDiscoverRunner.
"Silenced" means: a notifier whose class says REACHES_NETWORK = True — Telegram,
django-aiogram, and any third-party notifier unless it says otherwise — is not called.
LogNotifier still logs, and capture() still sees every message. Under a test run that
is not silenced and has NOTIFY set, manage.py check warns W004.
And still: keep production notifier tokens out of the CI test job. The silence covers
this package; it does not cover your own code, other packages, or a script that runs
manage.py outside the test runner. In GitHub Actions, give the token only to the deploy
job's environment; in GitLab CI, make the variable protected and scope it to the
production environment, so the test job never sees it.
To silence a block by hand (a script, a REPL):
from offsite_backup.testing import silence
with silence():
...
Parallel workers¶
manage.py test --parallel N runs the tests in worker processes, started with the
platform's multiprocessing start method — spawn on macOS, and forkserver on Linux from
Python 3.14. A spawned worker inherits the environment and nothing else: a switch set in
the parent's memory never reaches it. The mixin therefore covers the workers three ways:
- it sets
OFFSITE_BACKUP_SILENCE=1in the parent's environment for the length of the run (and restores what was there afterwards). Every worker inherits it, whatever the start method, andoffsite_backup.notify.silenced()honours it; - its
parallel_test_suiteisSilencedParallelTestSuite, a subclass of Django'sParallelTestSuitewhoseprocess_setupsilences each spawn or forkserver worker before its first test (notify.silence_process(), for the rest of the worker's life); - the parent itself runs inside
silence(), as before — the whole of a run without--parallel, and a fork worker, which copies the parent's memory.
A runner that sets its own parallel_test_suite replaces the mixin's. Make it a
subclass of SilencedParallelTestSuite, or its workers rely on the environment variable
alone:
from offsite_backup.testing import OffsiteBackupRunnerMixin, SilencedParallelTestSuite
class ProjectParallelSuite(SilencedParallelTestSuite): ...
class ProjectRunner(OffsiteBackupRunnerMixin, YourRunner):
parallel_test_suite = ProjectParallelSuite
OFFSITE_BACKUP_SILENCE=1 set by hand silences any process — a script, a CI step that must
never notify.
Asserting on messages: capture()¶
from offsite_backup.testing import capture
def test_deploy_pass_says_media_is_left(configured):
with capture() as said:
call_command('backup_offsite', '--without-media', '--reason', 'deploy')
outcome, text = said[-1]
assert outcome.kind == 'written'
assert 'Media: left to the nightly pass' in text
capture() collects every (outcome, text) formatted inside the block, whatever
NOTIFY holds — silenced or not, with or without notifiers.
A fake S3: FakeS3 and use_fake_s3¶
from offsite_backup.testing import FakeS3, use_fake_s3
@pytest.fixture
def fake_s3():
fake = FakeS3(page_size=2, multipart_threshold=1024)
fake.bucket('backups') # Object Lock on, default COMPLIANCE 13 days
with use_fake_s3(fake):
yield fake
Inside use_fake_s3() every S3 client the package builds — the store's and S3Source's —
is the fake. It is in memory and behaves like the providers backups are kept at,
including where they bite:
- versions and delete markers; a delete without a version id adds a marker;
- Object Lock: a locked version refuses deletion (
AccessDenied); - pagination,
page_sizeitems per page, so loops over pages are exercised; - a single PUT without lock arguments lands GOVERNANCE, whatever the bucket's default;
- a multipart upload (from
multipart_thresholdbytes) keeps the mode but takes its date from the bucket's default rule — none without one; - a single PUT above
put_limitis refused withEntityTooLarge.
fake.bucket(name, versioned=..., lock_enabled=..., default_mode=..., default_days=...)
creates or reconfigures a bucket; fake.buckets[name].keys holds every version;
fake.calls records uploads and deletes; FakeS3(clock=...) moves time for lock expiry.
put_object() seeds a media bucket for S3Source.
No age on the CI machine: Passthrough¶
'ENCRYPTION': {'BACKEND': 'offsite_backup.testing.Passthrough', 'RECIPIENTS': ['age1test']},
'COMPRESS': [],
cat instead of age, both ways. It raises W002 on every check — nothing it writes is
encrypted — so it cannot reach production unnoticed. An empty COMPRESS drops the zstd
stage as well.
Media on disk: media_tree¶
from offsite_backup.testing import media_tree
root = media_tree(tmp_path / 'media', {'a.jpg': b'a' * 300, 'sub/b.png': b'b' * 300})
Writes {relative path: bytes} under a root and returns it — a DirectorySource ROOT.
A whole wiring test¶
import pytest
from django.core.management import call_command
from offsite_backup.testing import FakeS3, capture, media_tree, use_fake_s3
@pytest.fixture
def configured(settings, tmp_path):
fake = FakeS3()
fake.bucket('backups')
settings.OFFSITE_BACKUP = {
'PREFIX': 'acme/test',
'STORE': {
'BACKEND': 'offsite_backup.stores.s3.S3Store',
'BUCKET': 'backups',
'ACCESS_KEY': 'k',
'SECRET_KEY': 's',
},
'ENCRYPTION': {'BACKEND': 'offsite_backup.testing.Passthrough', 'RECIPIENTS': ['age1test']},
'COMPRESS': [],
'MEDIA': [
{
'BACKEND': 'offsite_backup.media.filesystem.DirectorySource',
'ROOT': str(media_tree(tmp_path / 'media', {'a.jpg': b'a'})),
},
],
}
with use_fake_s3(fake):
yield fake
def test_nightly_pass_writes_a_volume(configured):
with capture() as said:
call_command('backup_offsite', '--reason', 'nightly')
keys = list(configured.buckets['backups'].keys)
assert any(key.startswith('acme/test/media/') and key.endswith('.001.tar.age') for key in keys)
assert said[-1][0].kind == 'written'
A database source needs pg_dump and a real PostgreSQL; test that in an integration job,
or stand a DatabaseSource subclass of your own in for it.
The guard in tests¶
run_nightly() claims the night in the GUARD['CACHE']. Tests that call it twice in one
"night" see the second skipped — clear the cache between tests, or pass
'GUARD': {'ENABLED': False}.