Testing
The test tiers, how to run each, the fixtures they use, and which of them CI runs. CONTRIBUTING.md has the setup and the full list of checks to run before a pull request; this page is the reference behind the tests themselves.
Contents
- Tier 1: unit tests and coverage
- Tier 2: static contracts
- Tier 3: fixture replay
- Tier 4: real-device runs
- Fixtures
- What CI runs
- Fuzzing
Tier 1: unit tests and coverage
scripts/check.sh test # what CI runs: the suite with coverage, against the floor
scripts/check.sh test -- -k replay # the same, passing arguments on to pytest
mkdir -p local # by hand: coverage keeps its data file there
pytest -q # parser, feed, and device-flow tests; temp SQLite db
pytest -q --cov --cov-report=term-missing # with coverage; fails under the floor
scripts/check.sh’s groups mirror CI’s jobs: test and python together cover this page’s tiers 1 to 3 (--fast runs both), and CONTRIBUTING.md lists the rest.
Hermetic: no emulator, no Instagram account, no network. The device-driving code (login, feed navigation, share sheet, carousels, stories, the scrape loop) runs against tests/fakedevice.py, the feed server against FastAPI’s test client (tests/feedclient.py), and every database is a temporary SQLite file.
The coverage floor is 95%, fail_under in pyproject.toml’s [tool.coverage.report], measured over app/. pytest-cov enforces it, so a run under the floor fails. The README’s coverage badge is the figure from the latest green push to main, measured by .github/workflows/coverage.yml and served by pages.yml; the badge turns bright green at that same fail_under.
tests/conftest.py holds the autouse fixtures every test gets:
profile_v424pins the root profile,v424, so the suite as a whole is thev424regression suite unless a test selects another;no_real_boot_waitandno_real_logcatstand in for the calls that run the realadbbinary (the boot wait, device tuning, anddiagnostics.save_failure_logcat), which on a developer host could reach a live redroid;no_profile_capturekeeps capture mode off whatever the environment says;backups_in_tmpandcontrol_files_in_tmpput database backups and the poll loop’s control files in throwaway directories, andno_alert_deliveryblanksALERT_URL;close_databasescloses every connectiondb_init()opened at the test’s teardown, rather than leaving it to garbage collection and aResourceWarning;no_permalink_backfillturns the permalink backfill off; its own tests turn it back on.
Two more are opt-in: fast_offline, the device-flow setup (paths under tmp_path, no pauses, test credentials), and con, a fresh database.
Tier 2: static contracts
scripts/check.sh python # all four, as CI's lint job runs them
ruff check . && ruff format --check .
basedpyright
lint-imports
constricter app tests .github/scripts
These run over app/ and tests/ alike and are part of the test contract, not just style:
- Typing. basedpyright strict with
reportAny, plus ruff’sANNrules, so no value typedAnygets through, and constricter at its strictest level for the rule that every variable is annotated, which ruff doesn’t have.tests/test_typing_policy.pyruns constricter too and catches the suppression comments the linters can’t forbid on their own. The rules are in CONTRIBUTING.md. - Import boundaries. import-linter’s contracts in
pyproject.toml: the feed server doesn’t import the scraper, the scraper doesn’t import the feed server,sharedimports neither, profiles don’t import the scraper code they configure, andinstadroid.parsingstays pure (no device, network or database code), which is what the replay tests rely on. See ARCHITECTURE.md. - Profiles.
tests/test_profiles.pychecks every profile directory automatically:test_every_profile_meets_the_contractrequires every selector key the root profile has, no override that matches no@versionedfunction, and every validated build to be one its profile covers, and every profile but the lowest must differ from its parent. - The OpenAPI spec.
tests/test_scripts_cli.pyrunsexport-openapi --check, so the suite fails untildocs/openapi.jsonis regenerated after a route change.
Tier 3: fixture replay
pytest -q tests/test_replay.py
promote-dump --update v424 # after an intentional parser or selector change: re-record expectations
tests/test_replay.py parses every recorded screen under app/igprofiles/<profile>/fixtures/ with that profile and compares the result with its .expected.json, and checks each still has its screen’s required selector keys (app/igprofiles/screens.py). A fixture gets there only through promote-dump (below). This is how an older Instagram version keeps passing after a change made for a newer one.
Tier 4: real-device runs
Never run by CI, and never unattended. A real redroid container and a real Instagram account are needed, so read CONTRIBUTING.md first; agents also follow CLAUDE.md and ask before starting one.
- A manual scrape, kept short:
MAX_SCROLLS=5 MAX_STORIES_PER_RUN=2withdocker compose exec app python scraper.py once, after checkingdocker statsheadroom and force-stopping Instagram. - A new Instagram build: the
new-profileflow.new-profile baseline <build>installs the build and does one capped capture-mode run on a scratch database, refusing to start while theappservice runs or memory headroom is short.checklists the selector keys each captured screen is missing,forkcreates a profile only when something drifted,promotescrubs the captures into fixtures,validaterecords the build as validated, andrestorereinstalls the default build. PROFILES.md walks through every step.
Every dated device run, with its caps, result and peak memory, goes in RUNLOG.md, and a new image and build pair in COMPATIBILITY.md.
Fixtures
No real account data is used in any fixture.
- Synthetic screens.
tests/fakedevice.pyis a scripted stand-in for a uiautomator2 device whose screens are synthetic hierarchy XML;gotoandclipattributes on a node script what a tap does.tests/deviceflows.pywires it up as a Home → Following feed with seeded posts. - Scrubbed dumps.
app/igprofiles/<profile>/fixtures/<screen>_<major>.xml, one set per validated version, promoted from a real dump only throughpromote-dump, which replaces the usernames, names, places and captions it can identify and prints the leftover text to read before committing. PROFILES.md records what a leak scan found. - Typed helpers.
tests/support.pyhas typed reads of JSON bodies and SQLite rows, database seeding, and a fakeurlopen()response. - Images, APKs and databases are never committed (
.gitignore).
What CI runs
On every non-draft pull request and every push to main, ci.yml runs tiers 1 to 3: the test job runs the whole suite with coverage (replay and contract tests included), and the lint job runs ruff, basedpyright, lint-imports and constricter, each through scripts/check.sh. Both are skipped when a change touches only Markdown, docs/ or workflow files. coverage.yml runs the suite with coverage again after each green CI run on a push to main, for the badge, and on a same-repository pull request, where it comments the pull request’s coverage and test count next to main’s. The docs job runs .github/scripts/docs_drift.py, which checks .env.example against the settings the code reads. Tier 4 never runs in CI. Everything else CI checks (dependencies, shell, docs, the image, workflows) is in CONTRIBUTING.md.
Fuzzing
None exists. The one input from outside the project that gets parsed is the hierarchy XML a device returns, in instadroid.parsing.parse_hierarchy(), which is pure by contract (above) and so a candidate for a Python fuzzer such as atheris. Captions reach the feed through feedserver.render.caption_html(), which escapes them. See OPENSSF-IMPROVEMENTS.md.