Repository navigation
Replace pytest with plain.test, Plain's own test runner - #130
Merged
Merged
Conversation
… test runner Documentation-driven development for replacing pytest with a Plain-native engine. The README is the spec — written entirely from the user's perspective, covering the authoring API (bare asserts, declarative decorators, context-manager helpers), automatic database/email/cache lifecycle, built-in parallelism with template-cloned databases, flake classification, trace-aware performance assertions, route coverage, agent-oriented output (--json, --changed), browser testing, and the built-in test suite. The 'How it works' section proposes the core split: plain.test (core) grows into the authoring vocabulary users import, plain.testing (dev dependency) is the engine that runs tests, and other packages implement a TestLifecycle protocol registered via the plain.testing entry point group — packages never import the engine. No runnable code yet: __init__ and a no-op plain.setup entrypoint only. plain test is still provided by plain.pytest. Design context: futures repo, plain/testing-engine arc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QFQWBn2hag3Wq32iZ8q8M6
…onale Adds a 'Testing code outside the app' section: tests for supporting workspace packages run under the app runtime (lazy lifecycle costs nothing), and projects with no Plain app at all get library mode — kernel features work, app-dependent helpers error clearly. Adds a FAQ answering why the engine can't be named plain.test: the module is load-bearing in core at runtime (plain request is built on Client in plain/cli/request.py), so a dev-only package can't own it. Install plain.testing, import from plain.test, run plain test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QFQWBn2hag3Wq32iZ8q8M6
…omits Drops the vs-pytest negations (no fixtures, no conftest, no marks system, no db fixture, no retry-until-green) from the feature sections and rephrases each positively. The pytest contrast now lives only in the Migrating from pytest section and the FAQs, where readers arrive with that question. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QFQWBn2hag3Wq32iZ8q8M6
If a test file imports it, it lives in plain.test (core); if it runs test files, it lives in plain.testing. Growth is one-way into core — nothing there today moves to the engine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QFQWBn2hag3Wq32iZ8q8M6
plain.pytest will be removed when this ships; the migration section now frames upgrading as a one-time automated rewrite rather than a transition with both runners installed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QFQWBn2hag3Wq32iZ8q8M6
Request side speaks Plain's request vocabulary end to end (form_data=, json_data=, files=, query_params= — same names views receive), with keyword-only args and follow_redirects off by default. Responses are assertable data instead of assertion methods: status_code, headers, text/body, json_data, redirect_to, and response.request (post-middleware, so response.request.user replaces the old response.user attribute). Updates all examples to the new vocabulary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QFQWBn2hag3Wq32iZ8q8M6
….postgres.test plain.test and the runner stay package-agnostic. Package-domain helpers ship with their packages: HTMX request helpers in plain.htmx.test (not grown on the client), query budgets in plain.postgres.test, job execution in plain.jobs.test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QFQWBn2hag3Wq32iZ8q8M6
…, full migration The engine (plain-testing package): - Collection: tests/**/test_*.py, test_* functions, Test* classes (fresh instance per method), async tests natively. Test modules import under synthetic names with the tests root and file dir on sys.path. - Assertion rewriting: an AST pass over test modules so bare assert failures show both sides of single-operator comparisons. Compiled with dont_inherit=True so the collector's __future__ flags don't leak into test modules. - Runner drives TestLifecycle implementations discovered from the plain.testing entry point group; reporter ends every failure with its exact re-run command. Flags: -k, -x, -v, --tag, --exclude-tag. - plain test re-execs into python -m plain.testing so PLAIN_ENV=test is set before settings load. Library mode (no app/) runs kernel-only. Core plain.test additions: - raises (with match= and .exception), cases/skip/tag decorators, override_settings, patch, capture_spans/capture_metrics, and the TestLifecycle base class. - Client/RequestFactory redesigned: form_data=/json_data=/files=/body=+ content_type=/query_params=, keyword-only, follow_redirects=; responses expose text, body, json_data, redirect_to, request. response.user and json() removed. - plain request CLI updated for the new client kwargs. Lifecycles: - plain.postgres: worker-scoped test database, per-test rolled-back transaction, @isolated_db tag for DDL-heavy tests, capture_queries and max_queries helpers. The db/isolated_db/setup_db fixtures and the cursor guard are gone. - plain.email: EMAIL_BACKEND routed to locmem for the run, outbox cleared per test, exposed as plain.email.test.outbox. CLI fixes surfaced by the work: - Registered commands (plain test) now dispatch without an app/ directory; CLIRegistryGroup skips app-module imports in that path. - _ensure_registry_loaded tolerates an already-set-up runtime. Migration: - plain-pytest deleted; pytest removed from every dependency group; scripts/test runs plain test. - All 28 suites (~150 files) migrated: fixtures to lifecycle/helpers, pytest.raises to raises, parametrize to @Cases, monkeypatch to patch, conftest.py files to explicit helper modules, client calls to the new vocabulary. Full run: 1867 passed, 1 skipped, 0 failed. - plain-oauth and plain-postgres dev deps gained cryptography (previously transitive via plain.pytest); plain dev deps gained plain.dev. - Docs and rules updated: core plain.test README rewritten, plain-test rule replaced, package lists and references swept. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QFQWBn2hag3Wq32iZ8q8M6
…nup sweep From a 4-angle review (reuse/simplification/efficiency/altitude) of the engine branch: - TestLifecycle.required_package replaces per-lifecycle _active guards; load_lifecycles() gates on INSTALLED_PACKAGES once, so inactive lifecycles never load and implementations carry no boilerplate. - Collection: CollectedTest drops dead/derivable fields (path, name, case_args — cases bind via functools.partial), the lineno-sort machinery goes (dict order is definition order), discovery uses os.walk with directory pruning instead of rglob-then-filter, per-file parent sys.path insertion removed (root only), and the Plain 'app' exclusion moves from the kernel to the runner. - main.py lets plain.runtime.setup()/AppPathNotFound decide app vs library mode instead of re-deriving the app path. - scripts/test invokes python -m plain.testing directly, skipping the throwaway plain-CLI parent process per package. - Client: ClientResponse loses the object.__getattribute__ dance, the redundant __setattr__, and the legacy url property (redirect handling uses redirect_to); RequestFactory.request() gets explicit parameters. - capture_metrics() yields CapturedMetrics with .points(name) — the flattening helper two suites had hand-rolled; CLI-internal capture_spans renamed capture_trace_spans to end the name collision with plain.test.capture_spans. - OTel SDK imports deferred so importing plain.test doesn't pay them; assertion rewriter locates only the injected import instead of a second whole-tree pass; email lifecycle clears the outbox once. - Tests: ~70 manual save/try/finally-restore blocks became patch()/ override_settings() blocks, _swap_router collapsed onto override_settings, make_admin_client deduped into helpers.py, and the stub tests/app dirs in plain-cloud/tunnel/dev are gone (library mode covers them). plain-oauth's provider-class strings now use f-string __name__ paths instead of relying on per-file sys.path. Deferred (recorded in the futures repo): the runtime setup() split that retires the CLI's special cases, per-test connection reuse, and per-test span-exporter clearing. Full suite: 1867 passed, 1 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QFQWBn2hag3Wq32iZ8q8M6
…essions From a high-effort correctness review (line-by-line, removed-behavior, cross-file, conventions): Collection: - Inherited test_* methods are now collected (MRO walk, subclass overrides win) — mixin/base-class test methods were silently dropped. - path::TestClass targets select the class's tests instead of matching nothing and exiting 5. - One unimportable file no longer aborts the run: per-file collection errors are reported alongside results and fail the exit code while every other file's tests still run. Isolation: - override_settings snapshots every original before applying anything, so an unknown name can't leave earlier overrides applied. - Postgres per-test teardown wraps check_constraints in try/finally — a deferred-constraint violation no longer skips the rollback and leaks an open transaction into the next test. - Runner tears down completed lifecycles when a later setup_worker raises (no leaked test databases), and one teardown failure no longer skips the rest. - capture_metrics actually isolates now: the reader uses delta temporality for synchronous instruments (the entry drain was a no-op for cumulative counters, leaking prior tests' points into every block), and CapturedMetrics accumulates drains so collect()+points() don't double-collect. New .clear() forgets the block so far. - install_test_tracer/meter raise loudly when another global provider won the one-shot install (silent empty captures + test traffic exporting to a real backend otherwise). Client / CLI: - plain request reads get_request_user(response.request) instead of the removed response.user (auth state showed anonymous for everyone), and the JSON body pretty-print uses response.json_data instead of the removed json() method. - Raw body= strings encode with the charset the content type declares. - scripts/type-validate validates plain-testing instead of the deleted plain-pytest. Plus: plain-testing now has its own test suite (28 kernel tests run in library mode, wired into scripts/test) locking in the collection, runner-lifecycle, assertion-rewriter, and vocabulary behavior. Full suite: 1894 passed, 2 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QFQWBn2hag3Wq32iZ8q8M6
- Raw bytes bodies pass through untouched — force_bytes with an encoding transcodes (or crashes on) bytes input, so only str bodies get charset-encoded. - override_settings applies inside the try, so a type-rejected value on a known setting still restores the overrides applied before it. Both found by adversarial verification of the previous fix commits. Full suite: 1894 passed, 2 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QFQWBn2hag3Wq32iZ8q8M6
Brings 204 commits of master onto the branch that replaced pytest with plain.testing. The rule for resolution: the branch wins for anything about the test engine, plain.test, the test-runner CLI, and test-file conventions; master wins for everything else; where master changed a file the branch had also rewritten, master's intent is carried onto the branch's form. Non-trivial resolutions: - plain-redirection: master removed the package; removed here too. - plain-pytest: the branch removed the package; master's changes to it are dropped. Its `plain.pytest` references in docs, rules, and the packages list are now `plain.testing`. - `plain test` registration: the branch needed a CLIRegistryGroup that could load without an app. Master's new `plain.cli` entry point group does that properly (commands there run before `plain.runtime.setup()`), so the command moved to it and the branch's `import_app_modules` workaround is gone. `plain/plain/cli/core.py` keeps only the `SetupError` tolerance, so the CLI still works in-process when the runner already ran setup(). - `plain/plain/cli/_trace.py` and `request.py`: master's rewrite taken wholesale, with the branch's `capture_spans` -> `capture_trace_spans` rename (the name now belongs to `plain.test`) and the new Client API (`follow_redirects=`, `body=`, `response.json_data`, `get_request_user(response.request)` instead of `response.user`). - `plain/plain/test/client.py`: the branch's redesign kept, with master's bodiless-response handling, `_to_buffered_response`, and the delegating `__setattr__` that keeps `status_code` read-only. Wrapper-private state is written through `object.__setattr__`; `user` is no longer a test attribute. - `plain/plain/test/README.md`: master's "Capturing OpenTelemetry signals" section is superseded by the branch's `capture_spans` documentation. - Every `conftest.py` is gone; master's additions to them are ported into importable helper modules next to the tests.
Master added ~35 test files (and 5 conftest.py files) between July and now, all written for pytest. They merged in cleanly and had never been converted. - `import pytest` / `pytest.raises` / `pytest.mark.*` become `plain.test`'s `raises` / `@cases` / `@skip` / `@tag`. - Fixtures become explicit imports: `monkeypatch` -> `patch(...)`, the `settings` fixture -> `override_settings(...)`, `db` / `isolated_db` -> nothing / the `@isolated_db` decorator, `tmp_path` -> `TemporaryDirectory`, `capsys` -> `redirect_stdout`. - The five conftest.py files become importable helper modules at the tests root: `plain-dev/tests/helpers.py` (one `sandbox()` folding in both autouse conftests), `plain-mcp/tests/helpers.py` (the MCP request envelope), `plain-postgres/tests/migration_helpers.py`, `plain-templates/tests/clients.py`, and `plain/tests/log_helpers.py`. - Client calls move to the redesigned API: `data=` -> `form_data=` / `json_data=` / `files=` / `body=`, `follow=` -> `follow_redirects=`, `response.json()` -> `response.json_data`. There is no `caplog` equivalent, so tests that assert on log records attach a recording handler through a local `capture_logs` helper. Three of those exist now (plain, plain-mcp, plain-jobs) — see the report; it wants to be `plain.test.capture_logs`.
Post-merge fixes that aren't conflict resolutions: - `plain.email`'s README documented the `mailoutbox` pytest fixture; it now documents `plain.email.test.outbox` and the test lifecycle behind it. `plain.dev`'s README no longer describes a pytest plugin setting PLAIN_ENV, and `plain.cli`'s README points at plain.testing. - `plain-code`'s shipped ruff defaults drop the `PT` (flake8-pytest-style) ruleset. - `PYTEST_CURRENT_TEST` was how the CLI knew to turn colors off. The runner now sets `PLAIN_TEST_RUNNING` and `plain/plain/cli/formatting.py` reads it. - `plain/plain/cli/core.py` tolerates `SetupError` when loading the registry. The runner calls `plain.runtime.setup()` before collection, so every in-process CLI invocation from a test would otherwise fail to load the app. - The postgres test lifecycle no longer calls `conn.check_constraints()` — master made foreign keys NOT DEFERRABLE and removed the method, so there is nothing left to check before the rollback. `use_test_database` drops its hand-rolled maintenance-cursor DDL for master's new `plain.postgres.databases.create_database` / `drop_database`. - Lint fixes for ruff 0.16.7's expanded defaults: `Self` on `raises.__enter__`, a tuple `startswith` and an `S102` noqa in the collection kernel.
Five ad-hoc logging.Handler subclasses had accumulated across the suites
because there was no log-capture vocabulary. This is the one implementation.
with capture_logs() as logs:
Client().get("/broken/")
assert "Server error" in logs.messages
assert logs[0].path == "/broken/"
With no arguments it captures the `plain` and `app` trees — Plain's loggers
don't propagate to the root logger, so a handler attached there sees nothing.
Name loggers to narrow it. Levels and any global `logging.disable()` are
lowered for the block and restored on exit.
`logs.span_context_for(message)` returns the OpenTelemetry span context that
was current when that record was emitted, which is the assertion the OTel
guidance keeps asking for: an exception log has to land *inside* its error
span, or it exports with empty trace/span ids and the one failure gets
reported twice — the span's exception event plus an orphaned error log.
The span context is stored beside the record, not on it. A LogRecord's
__dict__ is how Plain carries structured context, so an extra attribute there
would surface as a key=value pair in every other handler's output, including
the OTel LoggingHandler's exported attributes.
Replaces the handlers in plain/tests (test_view_hooks, test_urls_instrumentation,
test_server_worker_shutdown, test_server_connection_errors — log_helpers.py is
gone), plain-mcp/tests/helpers.py, and plain-jobs/tests/internal/test_otel.py.
`raises` was annotated `exception: BaseException | None`, so every test that reached for an attribute of the exception it just caught — ValidationError's `.messages`, `.error_dict`, `.error_list` — was a type error. That was 18 of the 20 diagnostics `./scripts/type-check plain-postgres` reported. `raises[E: BaseException]` infers E from the types passed in, and `.exception` returns E. Reading it before the block exits now raises AttributeError with a message saying why, instead of returning a None that fails somewhere further down the test.
`PREFIX` was a fixed string, so every run of this suite handed out the same database names. Two runs sharing a cluster — a CI matrix, parallel worktrees, two agents — then destroy each other: one run's `make()` opens with `drop_database(force=True)`, which lands between the other run's `create_database` and the assertion that follows it. The symptom is a ~40% flake in `test_template_requires_idle_source`, failing with `InvalidCatalogName: template database ... does not exist`. The prefix now carries a token generated once per process. The names are still recognizable, so anything a crashed run leaves behind is still identifiable as this suite's. Verified by running two suites concurrently against separate databases: six concurrent runs, all green, where the fixed prefix flaked.
Parametrized tests reported by position only — `[0]`, `[1]` — so a failure
and its re-run command said nothing about which case broke. pytest had
readable ids and the conversion lost them.
@Cases(
case("a@example.com", True, id="plain address"),
case("nope", False, id="no at sign"),
)
def test_email_validation(email, valid): ...
reports `test_email_validation[no at sign]`, and `plain test
'file.py::test_email_validation[no at sign]'` runs just that one.
The id goes on the case rather than in a parallel `ids=[...]` list, so it
can't drift onto the wrong values when a case is added or reordered, and you
read the name next to the values it names. Duplicated ids are rejected —
two tests with the same id would make the re-run command ambiguous. Cases
without a `case()` wrapper keep their index, so nothing else changes.
Applied to the two tests that had pytest ids: the h1 shutdown drain modes
and the MCP classic header combinations, whose byte-payload and dict cases
were the least legible as bare numbers.
Also lists capture_logs in the testing rule alongside the other `with`-block
helpers, missed when it was added.
`form_data=` always produced multipart, so a view under test saw a content type it would rarely see in production — browsers and htmx send urlencoded unless there's a file input. Forms are now urlencoded, and multipart only when `files=` is given (form fields sent alongside files ride along in the same body, as before). `content_type=` with no body at all now says what to pass. It already rejected `content_type=` next to `form_data=`/`json_data=`, but `post(path, content_type="application/json")` — which is how this reads naturally — got a message about a raw body it hadn't mentioned. Building an empty body instead would hand the view a `b""` that its content type claims is parseable, so the failure would surface somewhere further in. Content headers now follow the content type rather than the byte count: a POST of an empty form declares what it is with Content-Length: 0, which is what a browser submitting an empty form sends. Requests with no body source at all (a GET) still get neither header. Without this, an empty form — previously non-empty multipart boundary bytes, now genuinely empty — would have silently lost its Content-Type. test_urls_trailing_slash's bodiless-POST test asserted multipart; its point is that a 308 preserves the initial request's content headers, which it still does.
`plain test` re-exec'd into `python -m plain.testing` for two reasons, and master's CLI has since removed both. The command is contributed through the `plain.cli` entry point group, whose commands are marked `without_runtime_setup`, so nothing has called `plain.runtime.setup()` by the time it runs — the runner still owns the app-vs-library decision. And `_PLAIN_ENV_DEFAULTS` in plain/cli/core.py already sets `PLAIN_ENV=test` before dispatch, so the dotenv ladder sees it either way. The third reason, cwd on sys.path for `from helpers import ...`, was never the re-exec's doing: collection inserts the root explicitly. `python -m plain.testing` still works and is still what `coverage run -m` uses; this only changes what the `plain test` command does. Verified: app mode, library mode, flag passthrough, and exit codes (1 on failure, 2 on a missing target, 0 on success).
Five packages had a `tests/helpers.py` and two had a `tests/clients.py`.
Each tests root is its own sys.path entry at runtime, so the collision was
invisible there — but the type checker sees them as one flat namespace, and
`from helpers import sandbox` resolved to whichever root `extra-paths` listed
first. That was 18 of the 46 diagnostics `./scripts/type-validate` reported.
Renamed after the `oauth_helpers.py` precedent, and every tests root that
holds helpers is now listed in `extra-paths`:
plain-admin/tests/helpers.py -> admin_test_helpers.py
plain-cloud/tests/helpers.py -> cloud_test_helpers.py
plain-dev/tests/helpers.py -> dev_test_helpers.py
plain-mcp/tests/helpers.py -> mcp_test_helpers.py
plain-postgres/tests/helpers.py -> postgres_test_helpers.py
plain-templates/tests/clients.py -> templates_test_clients.py
plain-templates/tests/error_routers.py -> templates_error_routers.py
The rest of the diagnostics, now that the right modules resolve:
- `get_request_user()` returns `User | None`; tests that read attributes off
it now assert it isn't None first, which also makes a lost session fail
where it happened. plain-admin's five sites share one `acting_user_id`
helper.
- `send_mail(html_message=...)` puts an `EmailMultiAlternatives` in the
outbox, which is typed `list[EmailMessage]` — narrowed with isinstance,
since that promotion is part of what the test checks.
- migration_helpers carried mypy's `# type: ignore[method-assign]`, which ty
doesn't read.
`./scripts/type-validate` now passes 26/26 with no errors.
What renaming can't reach: `plain.auth.requests.get_request_user` is
annotated with `app.users.models.User`, and `app` is a flat module name that
every test app and the example app claim. Checking the workspace resolves it
to the example app's model, so plain-oauth's reads of its own User model are
unresolvable at type time though correct at runtime. Those seven are
suppressed in place with the reason; fixing it properly means changing how
that annotation names the app's user model, which is a framework decision.
An `object()` sentinel made the saved values `str | object`, so restoring them to os.environ was a type error. An env var is never legitimately None, so None is the marker that says "wasn't set" and narrows the restore to str. Last of the diagnostics in a repo-root `ty check .` — it now runs clean.
Picks up "Clean up after plain migrations reset" (4eb466f) and three release commits. Three conflicts, all in tests this branch had converted to plain.testing — the branch's form kept, master's substance applied: - plain-dev/tests/internal/test_postgres_guard.py: master replaced `pending_migration_count` with `pending_migrations` returning a `PendingMigrations(run=, record=)`. Carried onto the branch's `patch(...)` form; `refuse()`'s return annotation follows. - plain-postgres/tests/internal/test_baselines.py: master's new assertion that a migration's own dependencies survive a resolved retired-name edge, reindented into the branch's `with migrations_dir()` block. - plain-postgres/tests/internal/test_migrations_reset.py: the whole file conflicted because the branch turned master's `migrations_dir` / `repo` fixtures into context managers, so no hunk lines up. Resolved by keeping the branch's file — same test set, verified name by name — and applying master's renames to it: `since` -> `shipped_in` (attribute, written source, and the `--shipped-in` CLI flag) and `detect_model_changes(..., {...})` -> `package_labels={...}`. Also adopted master's pending generalization of that file (228a19f on test-fixtures-per-checkout) while it was open: LEAF, NEXT_NUMBER, EXAMPLES_COUNT and LEAF_CREATED_MODELS now come from the migrations directory instead of literals, so adding an examples migration doesn't break every assertion — and so that branch's merge is a no-op here. `test_writer_operation_options.py`, new in the same master commit, needed no conversion: plain functions and bare asserts already. This branch's own test_databases.py per-run naming is unchanged.
# Conflicts: # plain-dev/tests/conftest.py # plain-dev/tests/public/conftest.py # plain-dev/tests/public/test_dotenv.py # plain-dev/tests/public/test_dotenv_encryption.py # plain-postgres/tests/internal/test_autodetector_not_null_errors.py # plain-postgres/tests/internal/test_db_expression_defaults.py # plain-postgres/tests/internal/test_fk_characterization.py # plain-postgres/tests/internal/test_migrations_reset.py # plain-postgres/tests/public/test_databases.py # plain-postgres/tests/public/test_delete_behaviors.py # plain-postgres/tests/public/test_integrity_error_mapping.py # plain-postgres/tests/public/test_manager_assignment.py # plain-postgres/tests/public/test_related.py
#83 arrived with tests written for pytest: the EncryptedJSONField default cases in test_encrypted_fields.py use pytest.raises and pytest.mark.parametrize, and the typed-construction conformance file names pytest as the thing that doesn't check its TYPE_CHECKING half. Move them onto raises/@Cases and say 'the test runner' instead.
Brings the branch up to master 70476ee (118 commits). The engine and the rest of the non-test tree are resolved here; master's test files are taken as they are on master and converted in the next commit, so this commit's suites fail on exactly those 103 files. Resolved by hand: - `plain.test.Client` keeps the branch's keyword vocabulary and carries master's behavior: the body is read through `ResponseLifecycle`, so `body`, `text` and `json_data` are the bytes that were sent, and `status_code` is the status that was sent. - `Client.websocket()`, `WebSocketRejected` and `WebSocketTestConnection` are ported from master. `websocket()` also takes `query_params=`, and the handshake request is on `response.request`. - `plain-pytest` and the `pytest.py` modules in plain-postgres and plain-email stay deleted. Master's only changes to them were the `__future__` import and version bumps. - The conftests stay deleted. Master's new `executed_sql` fixture is a function in `postgres_test_helpers`. - Python 3.14: `plain-testing` requires it, and the `__future__` import is gone from the branch's own files. - Collection looked up `__module__` on every module-level object, and one of master's new postgres tests has an object that raises on it. It now looks only at functions and classes. The three client streaming and websocket test files are converted here, since they are the spec for the client port.
103 test files across 17 packages: 42 new on master, 60 that conflicted in the merge, and 1 that auto-merged. A conflicted file starts from the branch's earlier conversion and takes master's change on top. Every test function on master is here under the same name, with the same assertions. Compared function by function, the only differences from master are: - `test_string_valued_fields_support_the_string_conditions` runs 35 cases where master collects 42. The other 7 are the ones master skips at run time, which the engine can't do yet, so they are left out of the case list. - `test_set_to_none_nullable` is a whole-test `@skip` for the same reason. - `test_capture_spans_isolates_other_processors` is named for the branch's `capture_trace_spans`. Also fixes two things found on the way: - plain-cloud's `test_config_when_logged_in` matched a URL as a substring of the output, which CodeQL flags. It matches the token. - plain-templates' `boom_on_500` read `self.template_name`, which `Template` doesn't have, so it raised `AttributeError` on every template rather than `RuntimeError` on `500.html`. It reads `self.filename`.
The merge-up left a list of things the engine made awkward or got quietly wrong. These are the ones the design rules settle, and the rules themselves are now in the plain-testing README. The report tells the truth: - `skip_test(reason)` skips from inside a test body. It is a `BaseException`, so `except Exception` in the code under test can't swallow it, and lifecycles still exit. The conformance test is back to master's 42 cases with 7 reported as skipped, and `test_set_to_none_nullable` runs again. - Skipped tests are listed with their reasons before the summary. Fail early, and say what to do: - A test whose parameters nothing supplies is a collection error that names the parameters and says fixtures don't exist here. It was a bare `TypeError` per test at run time. - A second `@cases` on one function raises. It replaced the first. - Bare `@skip` and `@tag` raise. They made the test vanish. Setup is explicit, protection is automatic: - A project declares one `TestLifecycle` in `tests/lifecycle.py`. The runner finds it by path, runs it closest to the test, and stops the run if the file is there and wrong. One name per thing: - `response.content` is gone from the test client's response; `body`, `text` and `json_data` are the names. The response forwards unknown attributes to the wrapped one, so `content` has to be refused by name or it would return the unsent bytes. `plain request` read it too. Repetition earns a helper: - `span_sql_statements(spans, *, table=None)` in `plain.postgres.test` replaces five copies of a local `statements()`. `table=` matches the quoted identifier, so `examples_widget` doesn't match `examples_widgettag`. - `CapturedSpans` and `CapturedMetrics` are exported from `plain.test`. And one bug: `patch` restored what attribute lookup returned. On a class that left an inherited attribute copied onto the subclass, and put a plain function back where a `staticmethod` had been. It restores what the class itself held.
The plain-testing README was written as the spec the engine would be built against, and it still read that way: six sections described features that don't exist, examples imported `plain.jobs.test` and `plain.flags.test`, and it said the cache is isolated per test. A reader couldn't tell which examples would run. Every example in the testing docs now runs as written. Checked by resolving each `plain.*` import in each code block. - `plain docs testing` is how to run tests and how the runner works: the command, where tests live, reading the output, the project lifecycle, running outside an app. 730 lines to 422. - `plain docs test` is how to write one. The client, test metadata and overriding context were documented in both; they live here. - Each package documents its own helpers. plain-postgres gets a Testing section (the test database, `@isolated_db`, `max_queries`, and when to use `capture_queries` or `span_sql_statements`), as does plain-sessions. plain-auth's and plain-email's are corrected. - The pytest migration guide moves to the plain-testing changelog as upgrade instructions, where `/plain-upgrade` reads it. Things the docs said that the code doesn't do, now stated as they are: helper modules import as `tests.<name>` from the project root; a failed comparison prints the expression with `left:` and `right:`; the database transaction opens for every test; async tests run under `asyncio.run`.
Two things drop databases that nobody named: a test run, which removes what a killed run left, and `plain db clean`. Both went by evidence that could be true of a database in use. A test run looked for databases whose names start with `test_<database>` and whose run lock nobody held, and dropped them with `FORCE`. Names nest: `test_example` is how every other checkout's test databases start, so a run in the main checkout was looking at all of them. A database named `test_something` by anyone matched. And a run that holds no lock, because its code is older or its connection was lost, looked dead. A run now writes a record into the comment of each database it creates: the database it was testing, its own name, where it was started, and its process. Only a database carrying a record is considered, only for the configured database the record names as a whole value, and only when its run is proved dead: the record says the run marks itself alive the way this code does, nobody holds that lock, the process isn't running, and nothing is connected. It is dropped without `FORCE`, so if something connected in the meantime the drop fails and the database stays. One left in doubt gets a line of output naming `plain db drop <name>`. A run that finds its name taken by a database without its record stops. `plain db clean` dropped a database when the checkout its metadata names was missing from disk. That is who made it, not who uses it: a worktree that was moved, or pointed at a database with `plain db use`, lost its database. And a missing path is what an unmounted volume looks like too. It now leaves a database that any checkout of the project is configured to use, whatever the metadata says. The repository's worktrees are the checkouts. A checkout is gone when its directory is missing and git no longer lists a worktree that held it; outside git, when the directory that held it is still there. A database something is connected to is left. `plain db clean` always prints every database of the project, under "Would drop" or "Leaving", with the reason. `--dry-run` stops there. `--yes` is gone: what it drops can't be brought back, so the question is always asked, and a script that wants a database gone names it with `plain db drop`. What is dropped after a yes is what was listed and still qualifies, read again. `plain db drop` and `plain db reset` no longer force. If something is connected they say so and stop, and `--force` throws it off. What decides is two functions of facts that touch no database: `judge_leftover` and `plan_clean`. The cases that went wrong are tests of them.
…anything is dropped The plain.dev README says what makes a database debris and shows the listing, and the plain.postgres testing docs say what a run records in its databases and what it takes to remove one a dead run left. The rule for agents: run `plain db clean --dry-run`, read it, and show it to the person. `plain db clean` is the person's to run. `--yes` and `--force` go to `plain db drop` and `plain db reset` only for a database the person named, and a database is dropped by its whole name, never by a pattern.
…at passes
The short flags came with the habits of another runner, and one of them
lied: `-k` there takes an expression, and here it took a substring, so
`-k "login and not slow"` matched nothing and said only "No tests found".
Three other options had two names each.
Every flag has one name now, and it says what the flag does:
-k TEXT --match TEXT
-x, --fail-fast --fail-fast
-v, --verbose --verbose
-s, --show-output --show-output
The JSON document's `command.keyword` is `command.match`.
A test that passes prints nothing. The run printed a character for each
test as it finished, which for 1,470 tests is 18 lines that say nothing
the summary doesn't. A run that passes is now the collected count, a
blank line and the summary. When stdout is a terminal, one line says how
far the run has got (`12 of 41, 1 failed`), written over itself and
erased before the report. It is never written to a pipe, and there is
none with --verbose or --show-output, which write there themselves.
--verbose spells a test's outcome as the JSON document does: `passed`,
`failed`, `skipped`.
"No tests matched" exits 4, where it exited 5, so the codes run from 0
to 4, and 130. `plain check` lets 4 through as it did 5.
The runner had learned to read a project written for another runner: it found `conftest.py` files and listed their fixtures, read `import pytest` from the syntax tree and named what replaces each thing used, knew fourteen fixture names and what to write for each, reported a `tests/__init__.py` with code in it, and the client answered for `force_login` and for a body passed by position. All of it was there to help a suite move, and none of it says anything about a suite that has. The runner knows its own rules now, and says those: - A test takes values only from `@cases`. One with parameters nothing passes in is listed with them, and how many tests take each. - A test with a `yield` in it would never run. - A test is defined in the file that runs it. - One `@cases` to a test, and `@skip` and `@tag` are called. - A helper module is imported by its bare name. What it no longer has a word for is what any project would be told. A file that imports a module that isn't installed is a `ModuleNotFoundError` with its traceback. A file that isn't named `test_*.py` is not read, whatever it is called. A name the client doesn't have is an `AttributeError`, and an argument it doesn't take by position is a `TypeError`. The README's "Migrating from pytest" section and its account of these messages go with them, and the changelog is left to be written at release.
`login_client` asked `get_client_session` for the client's session, which writes a new one so that it can hand the client its cookie. Then `login()` gave the session a new key, which writes it again under that key and removes what was just written. A first login sent 13 statements and wrote the session table four times. It sends 7 now and writes twice: the session under the key the login gives it, and the session with the user in it. `logout_client` on a client that never logged in wrote a session and removed it. It writes nothing. A suite that signs in 455 times was paying for it on every one.
A failure gives a file and a line. Running the test again took its
name, which the traceback has but a reader of the file doesn't start
from, and for one case of a test it took quoting.
A target can be a path and a line now. The line is any line of the
test, from its first decorator to the last line of its body, and every
case of a test with `@cases` runs. It goes with other targets and with
--match and the tag flags.
A line that is in no test is an error that exits 2 and names the tests
nearest it:
No test at tests/test_views.py:12: line 12 is in no test. It is
between test_homepage (lines 5 to 9) and test_about (lines 14 to 20).
The command a failure prints to run it again is still by name, since a
line can't name a case.
A target that isn't there raises `TargetError`, where it raised
`FileNotFoundError`.
A test is a function, and a file is the group. A class added a second way to group tests, a second kind of target, and a `self` that carried nothing, since each test got a fresh instance and there was no setup to share through it. A class the file defines that has `test_*` methods in it is now reported with its line and how many tests it holds, whatever it is named and whatever it inherits from, so a class that would have been passed over without a word is reported too. A class that was imported is left alone. Classes are still collected until the test classes in this repository are written as functions. `TEST_CLASSES_ARE_COLLECTED` in `plain/test/runner/problems.py` is the switch, everything that goes with it is marked "CLASSES AS TESTS", and `PLAIN_TEST_CLASSES=refused` runs a suite as it will be.
A test class here was a namespace and nothing more: each test got an instance of its own, there were no setup hooks, and nothing was kept on `self` from one test to the next. So a file of functions says the same thing, and the runner can stop knowing about classes. 202 classes in 50 files become module-level functions. None inherited tests from another, none had a class-level decorator or attribute, and none of their 9 helper methods kept state. - A test keeps its name where the name was already unique in its file and read clearly without its class. Where the class was carrying the meaning it goes into the name: `TestReadFrame.test_payload_too_large` is `test_read_frame_payload_too_large`. - Each group keeps its label, as a comment heading above its first function: the class's name in words, followed by its docstring where it had one. - Helper methods are functions. Three classes in `test_mcp.py` each had one called `_mcp`, so each has a name of its own now; two in `test_select.py` were the same function, and are one. Compared file by file against the classes they came from: the same tests, 1,162 of them counting cases, with the same bodies, decorators and assertions, and every comment and docstring still there. Four multi-line SQL strings moved left with their functions, which changes only their leading whitespace; each is executed, not compared. `TestView`, `TestVersionedAPIView` and the MCP conformance tools hold no tests and are left as they are.
With every test in the repo written as a function, the runner no longer collects classes. A class defined in a test file that holds `test_*` methods is a collection error naming the class, its line and how many tests are in it, so nothing that looks like a test is left out of a run without a word. A class that holds none is a class: a fake, a view, a model the tests define. What existed only for classes is gone from collection: walking a class's bases for inherited tests, making an instance per test, static and class methods, `@skip` and `@tag` on a class, and `path::Class::test` targets.
Brings the branch up to master d372dd6: the ty 0.0.84 and ruff 0.16.9 upgrade, and an SSE view's `stream()` being closed when the client leaves. Nothing needed resolving by hand outside the tests. The SSE change is in `plain/views/sse.py` alone, and the handler and the response lifecycle, which this branch changed, merge untouched: the in-process server reads a response through the same `ResponseLifecycle`, so it closes the view's stream the same way. Master's `test_server_response_lifecycle.py` is taken as it is on master and converted in the next commit, so the `plain` suite and the type check fail on that one file here. The upgraded ruff and ty find nothing else in the branch's code.
`test_server_response_lifecycle.py` is the one test file master changed.
It is the branch's conversion of it again, with master's new test,
`test_sse_stream_cleanup_runs_before_the_send_returns`, which builds its
request with `build_request("GET", "/")`.
The file has the 23 tests master's has, under the same names. The new
one fails against `plain/views/sse.py` as it was before master's change.
`--verbose` prints `passed`, `failed` and `skipped`, the words `--json` uses. The report's own headings still shouted: `FAILED`, `SKIPPED`, `COLLECTION ERROR`, `TEARDOWN ERROR`, `INTERRUPTED`, `WARNINGS`, `WRITTEN OUTSIDE ANY TEST`. They are lower case now, and named as the document names them. A heading is told from what is under it by where it starts, not by its case: it is at the start of its line with a blank line above, and everything that belongs to it is indented. Three tests asserted that "COLLECTION ERROR" was somewhere in the output. In lower case the summary line says that too, so they assert the heading with its file.
The annotation check left out any directory named `test` or `tests`, to keep test files out of its numbers. `plain.test` lives in a directory named `test`, so the whole package was measured as 0 of 0 functions and passed at 100 percent. The same rule had been hiding the `test` module each package ships its helpers in. A `tests` directory and a file named like a test are still left out. A directory named `test` is source and is counted: 306 functions in plain-test, 40 more in plain-postgres and 4 more in plain-email, all of them already annotated. A directory whose name starts with a dot is never looked in, which covers `.git`, `.venv` and whatever cache a tool left behind without naming each tool.
Nothing in Plain is written for a project that used to run pytest, and a few places still said so: a comment in `plain code check` and one in its tests, a comment in the Oxc wrapper and one in plain-oauthserver's test helpers, `.pytest_cache` in `.gitignore` and in `scripts/type-unknown`, and `conftest.py` in `scripts/single-use-functions`. `.pytest_cache` needed no entry of its own: pytest writes a `.gitignore` inside it, and the scripts skip any directory whose name starts with a dot. `scripts/type-unknown` had its own copy of the rule that left a directory named `test` out, and counts it as source now, as the annotation check does. What still names pytest or `conftest.py` is two test files in plain-test. They pin that the runner says nothing about either: a file that imports pytest fails with Python's own `ModuleNotFoundError`, and a `conftest.py` is a module like any other, never run on its own.
When the test classes became functions, each group kept its label as a comment heading: the class's name in words, and its docstring. They came out in three forms. Some had the docstring straight under the name, and some had two blank lines between the heading and the function it is about, which reads as a comment about nothing. All of them are in one form: the name, a bare `#`, the docstring's lines, and the function directly under them. Blank lines and the bare `#` are all that changed, in 11 files. Across the 50 converted files the comments hold the same words and the code parses to the same tree as before. A heading that was added to a banner the file already had is left as the banner was.
`plain agent install` decided a rule or skill was unchanged by comparing
modification times, and skipped it when the project's copy was the newer
file. A project's copy that git checked out an hour ago is newer than
the one the package ships and can still be the old text, so the command
printed "Agent: up to date" over a rule file that wasn't.
It writes a rule when the project doesn't have it or has one that holds
something else, and a skill when any file in it differs, or one is there
that the package no longer ships. Then it names what it wrote and what
it removed:
Agent: wrote 1, removed 1 in .claude/
wrote rules/plain-test.md
removed rules/plain-pytest.md
The docs said a helper module is imported by its bare name, which is true of one in `tests/` itself and says nothing of one further down. A helper that belongs to one directory of tests, `tests/billing/ refund_helpers.py`, is `billing.refund_helpers`. That already worked, and nothing said so. What was wrong was what the runner said about the other spellings: - A relative import was corrected as if its file were in `tests/`. `from .refund_helpers import x` in `tests/billing/` was told to be `from refund_helpers import x`, which isn't there. The correction is worked out from where the file is. - `from refund_helpers import x` failed with `ModuleNotFoundError`. When a module by that name is further down in the tests directory, it is a collection error that gives the whole path, or each of them when the name is in two directories. A directory under `tests/` needs no `__init__.py`, and `plain code check` resolves the same import with nothing configured.
The README said a lifecycle is for what a test "would be wrong without but never mentions". A setting pinned to one value for the tests of one directory is that, and it is also setup those tests read, which the same section says never goes in a lifecycle. What tells them apart is whether a test's assertions depend on it. Protection is the same for every test in the suite and no test asserts on it: no request reaches the payment provider, a rate limiter starts from zero. Anything a test's assertions depend on is setup, and the test gets it in its body, even when every test in a directory needs the same one. There is one lifecycle, for the whole suite. The README also says when `tests/lifecycle.py` is imported: after the app is set up and before any test file, so it can import from `app` at the top.
Two things a report left the reader to work out.
A file whose tests take parameters says which ones, and how many of its
tests take each. A suite with sixty such files prints sixty of those
tables. When more than one file has them, the run prints the total
after the last file's error, the parameter most tests take first:
parameters nothing passes in
Tests in 2 files take these:
user 3 tests in 2 files
client 1 test in 1 file
`--json` has the same, as `parameters_nothing_passes`.
A test file is loaded as a module named for where it is, and Python
puts a module's name in front of what it defines. So a class in a test
file was printed as `<plain_tests.tests.billing.test_refunds.FakeGateway
object at 0x...>`, under a name nobody wrote. Values, tracebacks and
messages in a report have that name taken out: `<FakeGateway object at
0x...>`, and `Declined: ...` for an exception the file defines.
The README's example showed a client as `<plain.test.client.Client
object at 0x...>`. It prints as `<Client cookies=[]>`.
A run in a project with an application doesn't look for tests in
`app/`. It left them out by the name of the directory and said nothing,
so a suite kept in `app/tests/` printed `No tests found` and exited 4,
which `plain check` takes to mean there is nothing to run.
A `test_*.py` in the application that has tests in it is now a
collection error for the run, and the run exits 1:
collection error app
19 test files are in app/, and none of them was run:
app/tests 19 files
Tests live in tests/, beside app/. app/ is the application: it is
imported as `app`, and it is what gets deployed. Move them to
tests/. A test file is the same file there, and a helper module it
imports is imported by its path from tests/.
Up to three files are named, and more than that are counted by
directory. A target in `app/` is refused with the same explanation,
where it used to run: a test that runs only when it is named is one a
plain `plain test` leaves out.
A module of the application named `test_*.py` that defines no test is
left alone, so `app/test_connection.py` doesn't stop every run. Whether
a file defines one is read from its syntax, without importing it.
The application is left out by its path, not by its name. A directory
called `app` somewhere under `tests/` is searched like any other.
The summary says `1 collection error`, not `1 collection errors`.
plain.dev's setup hook runs for every `plain` command, and started the project's services for any command named `test`, `shell`, `request` and so on. The CLI can't tell a command a package or the app registers from one that was mistyped until the app has loaded, so the hook ran, and started services, before anything knew whether there was such a command. `plain test` with no plain.test installed started them, printed `No such command 'test'`, and left them running. The CLI now says when the command it was given has been found, and the hook hands the starting of services to `when_the_running_command_is_found`, which calls it then and never calls it for a command that isn't one. `python -m plain.test` says so for itself before it sets up. A process nobody named a command for is read from its arguments and started for at once, as before. `plain` and `plain --help` start nothing, which they didn't before either. This is about services. The managed Postgres server can still be started for a name that turns out not to be a command: settings need its URL during setup, and the URL has a port that is only known once the server is up.
plain.postgres.test, plain.email.test, plain.auth.test and plain.sessions.test become plain.<pkg>.testing, matching the name of the package they serve.
davegaeddert
marked this pull request as ready for review
October 6, 2026 21:53
Contributor
FAIL: Large-scale change: configuration required (no reviewers defined)Large Scale Change
No review scopes matched. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Plain owns its test runner.
plain-pytestis deleted, and every suite in the repo (29 suites, 4,386 tests) runs onplain.testing.What an app sees
One dev package,
plain.testing, provides everything a test file imports and theplain testcommand that runs them. Core ships no test code.withblock, and takes values only from@cases.@cases,@skipand@tagattach static facts. Runtime state enters throughoverride_settings,patch,raises,skip_test, and the captures.TestLifecycle(plain-postgres wraps each test in a rolled-back transaction; plain-email resets the outbox). A project declares its own intests/lifecycle.py.plain.postgres.testing,plain.email.testing,plain.auth.testing,plain.sessions.testing.What a run prints
A run that passes prints two lines: the collected count and the summary. A failure prints every value inside the assert, a diff of two large values, the test's locals, what the test wrote, and the command that runs it again.
plain test --jsonprints the same run as one document.The command
Every option has one name:
--match,--tag,--exclude-tag,--fail-fast,--verbose,--show-output,--full-values,--json,--list-passed. A target is a directory, a file, a test (tests/test_x.py::test_name) or a line (tests/test_x.py:42). Exit codes run0to4, and130.Where the time goes
The loop an agent runs most is one test, and in that loop the test itself is about 2 percent of the wall clock. The rest was
plain.runtime.setup()and building a test database from scratch, and the runner reported none of it: "1 passed in 0.52s" for a run that took 1.15 s.--verbose, or whenever a run spends more than a second outside its tests, it prints a chronological block: interpreter start, imports, each setup hook and import over 50 ms by name, the test database, the tests, teardown.--jsonalways carriesphases. An app sees its own slow import instead of guessing.CREATE DATABASE … TEMPLATE(about 25 ms) into a database of its own, so any number of runs can go at once in one checkout. A changed migration or model is a new template, and the old one is dropped on a later run by its own record, never by name. Inexample/, one test went from 1.05 s to 0.52 s wall clock; on three real apps of 58, 1,000 and 1,470 tests, from 0.44, 1.11 and 1.95 s to 0.35, 0.64 and 0.83 s, with the database phase at 0.10 s in all three. What is left isruntime setup, most of it the apps' own imports.What core gains
Request(body=...). A request could not be constructed with a body; the server and the client now build one the same way.plain.server.inprocess:InProcessServer().handle(request).send()handles a request on the calling thread and returns what would be sent. The client is built on it and reaches nothing private in core.plain docs <name> --apishows annotated attributes. Model fields had vanished from it.plain requestmoves to plain-dev. It is the client pointed at the dev database, and it already refused to run withoutDEBUG.Fixes that stand apart from the runner
Found on the way, and wrong on master too:
schema_lock()released its lock by closing its session, and Postgres frees it a moment later, so the next acquisition could wait out a five-second retry. It unlocks first.plain db cleancould drop a database a live checkout uses, because it went by the checkout recorded in the database's metadata and not by which checkout is configured to use it. It no longer does, it always lists before it drops, and it has--dry-runand no--yes.patchrestored what attribute lookup returned. That left an inherited attribute copied onto the subclass, and put a plain function back where astaticmethodhad been.boom_on_500in plain-templates' tests readself.template_name, whichTemplatedoesn't have, so it raisedAttributeErroron every template and the test passed for the wrong reason.plain agent installcompared file times, so a fresh checkout (every file dated today) never received a package's older rule text and said "up to date" while differing from what the packages ship. It goes by content, and names each file it wrote or removed.plain code checkskipped any directory namedtest, which took every package'stest/helpers, and all ofplain.testwhile it had that name, out of the annotation check.tests/is on the type checker's search path now.Not built
--lf,--pdb,@timeout,freeze_time, parallelism inside one run, shuffle and flake classification,--changed, browser testing, model factories, and the built-in suite.The
/plain-upgradeautomation for this migration is not written. Section 19 of the upgrade notes says what it has to do.Before release
plain.testingpinsplain>=0.165.0, which does not have the in-process server. The release has to bump core and raise the bound.plain-testing's changelog is its title only, for the release skill to write. What each package's notes must say is in section 18 of the upgrade notes.Tried on five apps
Five apps were migrated against the branch, each in a worktree, with the upgrade notes and the runner's output as the only guide. Two more wait on browser testing, which is not built.
app/, which the runner now reports instead of passing overconftest.pyfilesconftest.pytests/lifecycle.pyand a migration script; five did the files in parallelNothing in any suite was something the runner could not express. No migration script ships: at the larger sizes the agent wrote its own throwaway scripts for the repeated rewrites without being asked, and section 14 of the notes says which rewrites to script and which three counts catch every silent error.
Review shape
Read carefully:
plain-testing/plain/testing/runner/— collection, loading, the assertion rewriter, execution, output capture, failure description, reportingplain-testing/plain/testing/— the vocabulary and the clientplain/plain/server/inprocess.py,plain/plain/http/request.py, and the handler changes inplain/plain/internal/handlers/plain-postgres/plain/postgres/testing/— the lifecycle, the per-run databases, andleftovers.py, which decides what a run may dropplain-dev/plain/dev/postgres/cleaning.py, which decides whatplain db cleanmay dropplain-dev/plain/dev/request/The converted test files can be sampled. Two conversions were checked by script against what they came from: pytest to
plain.testing(every test master has is here, function by function), and classes to functions (the same bodies, decorators, assertions and comments).Verification
./scripts/test: 4,386 passed, 0 failed, 9 skipped, 29 suites./scripts/server-test: h1spec, Autobahn WebSocket and OpenAPI conformance (1301/1301 on every check) pass; h2spec and the load tests need tools not installed here and ran in CI./scripts/type-validate: 26 of 26, no errors./scripts/fix: cleanUpgrade notes
Upgrade notes
Moving an app from pytest to
plain.testingThis is the only migration guide. The runner, its README and its rule file say how tests are written and nothing about how they used to be, so nothing
plain testprints will name pytest, a fixture or aconftest.py. Everything about getting from there to here is in this file.plain.testingreplacesplain.pytest, and there is no release where both run. Assertions don't change. What changes is how a test gets what it needs: nothing is passed to a test by name, and everything it depends on is an import, a call or awithblock in its own file.The steps are in the order they are performed. Each one leaves fewer errors standing for the next.
Contents
conftest.pyguarded againsttests/__init__.pyconftest.py/plain-upgradehas to do1. Dependencies
In
pyproject.toml:plain.pytestwithplain.testingin the dev dependencies.pytestand everypytest-*plugin.[tool.pytest.ini_options], andpytest.iniif there is one.plain.testcame with Plain, so a project could import it with nothing installed. It is gone. What it held isplain.testing, a package of its own: the package isplain.testing, the command is stillplain test. If anything outside the tests importsplain.test, change the import toplain.testingand addplain.testingwherever that code's dependencies are declared.plain requestcomes fromplain.devnow. Nothing to change ifplain.devis a dev dependency, which it is in a project made withplain-start.Then
uv sync.Each package's test helpers are named after the package that uses them:
plain.postgres.testisplain.postgres.testing, and the same forplain.email,plain.authandplain.sessions. Afrom plain.auth.test import login_clientbecomesfrom plain.auth.testing import login_client. The runner reports the old name as an import error with its file and line.Remove what set up the test environment by hand:
PLAIN_CONNECT_EXPORT_ENABLED=falsein.env.test, if it was there for the tests. plain.connect exports nothing during a test run.PLAIN_POSTGRES_URLto keep runs apart. Each run makes a test database of its own and drops it.2. Rule files
Run
uv run plain agent install. It writes each rule and skill that isn't what the installed packages ship, and names the ones it wrote:It goes by what the files hold, so it writes a rule your checkout has an older text of, however recently the file was written.
Agent: up to datemeans nothing differed. Until it has run,.claude/rules/plain-test.mdis the old one, and says to use pytest fixtures; the run removes it and writesplain-testing.mdin its place.3. Commands, flags and exit codes
plain testis the command, as before. What follows it changed. Update scripts, CI and any docs of your own.-k TEXT--match TEXT. The text as written: not a pattern, not an expression-k "a and not b",-k "a or b"--tagand--exclude-tag-x,--exitfirst,--maxfail=1--fail-fast-v--verbose-s,--capture=no--show-output-m slow,-m "not slow"--tag slow,--exclude-tag slow-q-n 4,-p no:randomly,--reuse-db--pdb,--lf,--ff,--durationsbreakpoint()in a test gets the terminal-W errorPYTHONWARNINGS=error plain test, orpython -W error -m plain.testingtests/test_x.py::TestCart::test_addtests/test_x.py::test_add, once the class is gonetests/test_x.py:42runs the test that line is inpython -m pytestpython -m plain.testingcoverage run -m pytestcoverage run -m plain.testingEvery option has one name, and it is the long one.
-k,-x,-vand-sare "No such option" and exit2.Exit codes:
0011,22433451302A script that treated
5as "nothing to run" checks for4.What a run prints changed too. A test that passes prints nothing: a run that passes is
Collected 164 testsand164 passed in 3.21s. On a terminal a line says how far the run has got and is erased before the report.--verboseprints a line per test, with the outcome in lower case (passed tests/test_cart.py::test_empty_cart). Anything that read the old per-test lines reads--json, which is one document on stdout when the run is over.4. Before the first run: what
conftest.pyguarded againstDo this before running anything, if the suite can reach a real service.
A
conftest.pyis not read any more, and nothing says so. An autouse fixture in one that kept every test away from the payment provider, the helpdesk or the mail server has stopped doing it, and the credentials in.envare still there. A test that forgot to stub a call now makes it.Read every
conftest.py, the one intests/and any below it, for autouse fixtures. Sort each by one question: does any test's assertion depend on it?tests/lifecycle.py, now.TestLifecyclesubclass, under any name.tests/lifecycles.py, or alifecycle.pybesidetests/, stops the run and says where it belongs.appat the top, as a test file does. Aconftest.pythat put off an import until a fixture ran doesn't have to here.around_test(test)wraps every test. Apatcha test makes on top of the lifecycle's wins inside the test, and the lifecycle's is back afterwards.setup_worker()andteardown_worker(), which run once, before the first test and after the last.Prove it guards before relying on it: with a made-up key in the environment, run one test that reaches the call with no stub of its own, and see that nothing was sent.
5. The first run
Run
uv run plain teston the suite as it is. It runs what it can and reports the rest as collection errors, a file at a time. It says what is wrong in its own terms. This is what each thing a pytest suite holds comes out as:import pytestModuleNotFoundError: No module named 'pytest'test_signup(db, client) takes parameters, and nothing passes them in., and each name's countfrom tests.helpers import xline 4: `from tests.helpers import x` should be `from helpers import x`from .helpers import xtests/of the module the dots reachfrom helpers import x, for a module further downline 4: `from helpers import x` should be `from billing.helpers import x`TestCaseline 8: TestCart is a class with 3 tests in it.yieldin ittest_x() has a `yield` in it.test_x is defined in billing, not in this file.parametrize, once convertedline 8: test_x has 2 @cases. A test takes one.app/(app/tests/,app/users/tests/)collection error app: how many test files are inapp/, where, and that none was run. Exit 1TypeErrororAttributeErrorWhen more than one file has tests that take parameters, the run adds them up after the last file's error. That table is the list of fixtures the suite uses, and how much work each is:
A suite kept in
app/Tests live in
tests/, besideapp/.app/is the application: it is imported asapp, and it is what gets deployed. Atest_*.pyin it that has tests in it is not run, whether the run came to it by looking or was given it as a target, and the run reports all of them as one collection error and exits 1:Do this before anything else, because nothing else about those files is reported until they are moved:
git mv app/tests tests, or for tests kept beside each package,git mv app/users/tests tests/users. Keep the directories: a file's id is its path.tests/(step 8).from app.tests.helpers import xbecomesfrom helpers import x.testpathssetting that is going away anyway.test_*.pyand defines no test (app/test_connection.py) is left alone and is not reported.What the first run doesn't show
conftest.py. It is not read, so what it set up for every test is not set up, and nothing reports it. That is step 4, and step 7 for the rest of the file.tests/__init__.py. Nothing imports it, so code in it doesn't run. Step 6.ModuleNotFoundError, and its tests' fixture parameters are reported only once the import is gone. One with an import the runner refuses gets that, and itsimport pytestfails on the run after. So the count of files with each problem goes up as well as down between runs, and the first run's totals are a lower bound.Work through the steps below, then run it again.
6.
tests/__init__.pyDelete
tests/__init__.py, and every__init__.pyundertests/.tests/isn't a package: a test file is loaded on its own, a helper module is imported by its path fromtests/, and a directory undertests/is imported through with nothing in it.If one had code in it, the code ran before every test. Sort it by the question in step 4: protection goes in
tests/lifecycle.py, and setup goes in the tests that read it.7.
conftest.pyMove what each
conftest.pyholds, then delete the file. Nothing reads it. Its autouse fixtures that guard every test are intests/lifecycle.pyalready, from step 4.conftest.pyuser,client)tests/helpers.py, that a test callsyield)@contextmanagerfunction in the helper module, entered withwithmake_user)tests/lifecycle.py: oneTestLifecyclesubclass, itsaround_test(test)tests/lifecycle.py:setup_worker()andteardown_worker()pytest_configure,pytest_collection_modifyitems, …pytest_plugins = [...]conftest.pyin a subdirectoryThe rule for the lifecycle is the question in step 4: if a test's assertions depend on it, it is setup and goes in the test.
A
conftest.pyin a subdirectorytests/updates/conftest.pyapplied to the tests undertests/updates/and no others. What it holds goes in a helper module in the same directory,tests/updates/update_helpers.py, which is imported by its path fromtests/:An autouse fixture in it has no equivalent that applies to a directory. It was setup for that directory's tests, so it becomes a helper, and each test whose assertions depend on it enters it:
Only the tests that reach code reading the setting need it. Find them by running the directory with the setting set the other way in the environment (
PLAIN_APP_UPDATES_ENABLED=false uv run plain test tests/updates) and giving the helper to the ones that fail.Name helper modules for what they hold.
tests/comes first on the import path, so a helper module or a directory undertests/named like an installed package takes its place.8. Helper imports
A helper module is imported by its path from
tests/, from any test file in any directory, wherever the command runs from.tests/helpers.pyishelpers, andtests/billing/refund_helpers.pyisbilling.refund_helpers. A directory undertests/needs no__init__.py.from tests.helpers import create_userfrom helpers import create_userfrom .helpers import create_user, intests/from helpers import create_userfrom tests.conftest import make_userfrom helpers import make_userfrom conftest import make_userfrom helpers import make_userimport tests.helpersimport helpersfrom tests.billing.refund_helpers import create_refundfrom billing.refund_helpers import create_refundfrom .refund_helpers import create_refund, intests/billing/from billing.refund_helpers import create_refundfrom ..helpers import create_user, intests/billing/from helpers import create_userfrom refund_helpers import create_refund, intests/billing/from billing.refund_helpers import create_refundEvery spelling on the left is a collection error that prints the one on the right, with its line.
plain code checkpassestests/to the type checker as a search path, so these imports resolve there too.9. What a file imported from pytest
Remove
import pytestand replace what the file used. Everything in the right-hand column is imported fromplain.testingunless it says otherwise.pytest.raises(E, match=...)raises(E, match=...)excinfo.valuecaught.exception, readable after the block. Reading it inside the block raisesstr(excinfo.value),excinfo.match(...)str(caught.exception), ormatch=onraisespytest.mark.parametrize("a,b", [...])@cases((a, b), (a, b)). A single value is passed bare:@cases(1, 2)pytest.param(..., id="x"),ids=[...]case(..., id="x")inside@cases[5-500]Noneor an enum member[user0])[0],[1]. Name them withcase(..., id=)parametrize@cases(*[(a, b, c) for a in FIRST for b, c in SECOND]): each case one flat tupleparametrize(..., indirect=True)pytest.mark.skip(reason=...)@skip("reason"). The reason is requiredpytest.mark.skipif(condition, ...)if condition: skip_test("reason")as the test's first linepytest.skip("reason")in a testskip_test("reason")pytest.importorskip("x")try: import x/except ImportError: skip_test(...)in the testpytest.mark.slow)@tag("slow")pytestmark = pytest.mark.slow@tag("slow")on each testpytest.mark.xfail@skipit with the reason, or assert the failure withraisespytest.mark.usefixtures("db")pytest.mark.django_db,pytest.mark.asyncioasync def test_*runs as writtenpytest.approx(x, abs=...)math.isclose(a, x, abs_tol=...)inside a bareassertpytest.fail("why")raise AssertionError("why")pytest.warns(W)with warnings.catch_warnings(record=True) as caught:and assert oncaughtpytest.fixture@cases, or a@contextmanagerhelper for setup and cleanuptest_assert x == y, f"{x} != {y}"assert x == y. A failure prints every value, a diff and the test's locals10. Fixtures
Drop every fixture parameter, and get what it gave in the test's body. A test function takes no parameters except the values
@casespasses.dbisolated_db@isolated_dbfromplain.postgres.testingtransactional_db, a test that commits or runs DDL@isolated_dbuser = create_user()with helper() as thing:functools.cacheon the helper, if it is safe to share. Otherwise per testclientClient()fromplain.testingsettingswith override_settings(NAME=value):monkeypatch.setattr(settings, "NAME", value)with override_settings(NAME=value):. A setting is always this, neverpatchmonkeypatch.setattr(obj, "name", value)with patch(obj, "name", value):. It takes the object, not a dotted stringmonkeypatch.setattr("pkg.mod.name", value)from pkg import mod, thenwith patch(mod, "name", value):monkeypatch.setattr("pkg.mod.Class.name", value)from pkg.mod import Class, thenwith patch(Class, "name", value):monkeypatch@contextmanagerthat yields what it returned. See belowmonkeypatch.setenv("KEY", "value"),setitemwith patch(os.environ, "KEY", "value"):monkeypatch.delenv,delattr,delitemunittest.mock.patch.dict(os.environ)and delete inside itmonkeypatch.chdir(path)with contextlib.chdir(path):mocker(pytest-mock)unittest.mockdirectly, orpatchotel_spans/otel_metricswith capture_spans() as spans:/with capture_metrics() as metrics:. See step 13caplogwith capture_logs() as logs:capsys,capfdwith contextlib.redirect_stdout(io.StringIO()) as out:tmp_pathwith tempfile.TemporaryDirectory() as tmp:thenPath(tmp)tmp_path_factoryrequest(request.param,request.node)@cases. There is no nodemailoutboxfrom plain.email.testing import outbox. It is cleared around each testtestbrowsersetup_method/teardown_method@contextmanagerhelper each test enterssetup_module,setup_classmonkeypatchis a restructuring, not a renamemonkeypatch.setattrlasted until the test ended. Awith patch(...)lasts until its block ends. So everything in the test after the call moves inside the block, one level in:with. Patches with nothing between them, or only setup that doesn't care about the order, merge into one parenthesizedwith, in the order they were made. A patch made after other statements opens a block of its own there, a level further in.monkeypatch.setattr(settings, ...)in a row are oneoverride_settings(A=1, B=2).if, aforor atrylasted to the end of the test under pytest, and lasts to the end of that block now. Move it up, or usecontextlib.ExitStackandstack.enter_context(patch(...))."app.billing.gateway.Gateway.charge"names an attribute,charge, of an object,Gateway, in a module,app.billing.gateway. The object is what is imported and passed topatch. The module ends at the last part that is a file or a directory; what follows it is reached by attribute.A helper that takes
monkeypatchA function that a test called with its
monkeypatch, to install a fake and hand back what the fake recorded, becomes a@contextmanager. It yields what it returned, and the caller enters it for the rest of the test:A fixture that did the same is the same. How long the patch should last is a question the old code didn't ask: it lasts for the block, so put in the block everything that has to see the fake.
A parameter of the helper's that has the name of what the helper returns (
def argv(argv)) needs one of the two renamed once there is no fixture between them.11. Classes
A test is a function, and a file is the group. A class with
test_*methods in it is a collection error, whatever it is named and whatever it inherits from.def test_x(self):def test_x():at the top of the filetest_createeachtest_invite_create,test_order_create. Or a file for each classself.thingset insetup_methodthing = make_thing()self._post(...))@skipor@tagon the class@cases, one case for what each subclass changedunittest.TestCaseself.assertEqual(a, b)isassert a == b,assertRaisesisraises@staticmethod/@classmethodtestsfile.py::TestCart::test_addfile.py::test_addA class that isn't a test and has a method named
test_*(a fake backend withtest_connection()) belongs in a helper module. A class that was imported into a test file is left alone.12. The client
Import it from the same place:
from plain.testing import Client. Everything after the path is keyword-only, and the keywords are the request's own names.client.post(path, {...}),data={...}form_data={...}data={...}files={...}.form_datarefuses onedata=json.dumps(x), content_type="application/json"json_data=x: the value, not the stringjson.dumpsmade of itdata=x, content_type="application/json",json=xjson_data=xbody=b"...", content_type="..."client.get(path, {...})query_params={...}follow=Truefollow_redirects=Trueresponse.contentresponse.bodyfor bytes,response.textfor a stringresponse.content.decode()response.textresponse.json(),json.loads(response.content)response.json_dataresponse.urlon a redirectresponse.redirect_toresponse.userget_request_user(response.request)fromplain.auth.requestsresponse.resolver_matchresponse.request.resolver_matchresponse.streamingresponse.returned_response.streamingresponse.returned_response.<name>RequestFactory().get("/x"),.post("/x", ...)build_request("GET", "/x"),build_request("POST", "/x", ...)RequestFactory().generic("PUT", "/x")build_request("PUT", "/x")RequestFactory().request(data=, query_string=)body=,form_data=,json_data=,query_params=RequestFactory(headers=...),factory.cookiesheaders=on eachbuild_request(), with aCookieheader for cookiesRequestFactory(json_encoder=...)body=secure=Falseclient.get("http://testserver/x")server_name=,server_port=client.get("https://example.com:8443/x")Client(raise_request_exception=False)Client(raise_exceptions=False)client.trace(path)client.request("TRACE", path), for any methodclient.request(request)with a builtRequestclient.request(method, path, ...)client.force_login(user)login_client(client, user)fromplain.auth.testingclient.logout()logout_client(client)fromplain.auth.testingclient.sessionget_client_session(client)fromplain.sessions.testingclient.cookies = SimpleCookie()client.cookies.clear()ws.response.requestws.requestThe client's response has a fixed set of names and passes nothing else through to the response the view returned:
status_code,headers,cookies,body,text,json_data,redirect_to,redirect_chain,request,exception, andreturned_response.WebSocketRejected.responseis one of these too.Only the test client's response changes.
contenton aResponseyour own code builds or a view returns is unchanged.An old call is not explained by the client. A positional argument after the path, or a keyword it no longer takes, is Python's
TypeError. A name the client or its response no longer has is anAttributeError, and the response's lists the names it has. The table above is where to look.13. Spans, metrics, logs and queries
Each
capture_*block hands back a read-only sequence, and it is read after the block ends.otel_spans.get_finished_spans()spansitself:len(spans),spans[0],for span in spans[s for s in otel_spans.get_finished_spans() if s.name == "x"]spans.filter(name="x"). It also takeskind=otel_spans.get_finished_spans() == ()list(spans) == []otel_spans.clear()after the setupwith capture_spans() as spans:after the setupotel_metrics.get_metrics_data(), walked down to data pointsmetrics.number_points(name)ormetrics.histogram_points(name)attributes={"key": "value"}on eitherotel_metrics.collect()caplog.records,caplog.messageslogsitself, andlogs.messages. It captures theplainandapploggers: name any other,capture_logs("stripe")caplog.set_level(...),caplog.at_level(...)capture_logs(level=logging.INFO). The loggers' levels are lowered for the blockqueries[0]["sql"]fromcapture_queriesqueries[0].sql_with_params, orqueries[0].sqlfor the statement with its%sin placeexecute_wrapperthat records statementsqueries.sql_statements()fromcapture_queries. It takestable=with max_queries(n):fromplain.postgres.testing, which fails on more thann. For an exact count,len(queries)fromcapture_queriesinstall_test_tracer(),install_test_meter()capture_spans()andcapture_metrics()install what they needfrom plain.test.otel import ...from plain.testing import capture_spans, capture_metrics14. Do the repeated parts with a script, and count
A suite of any size has the same rewrite hundreds of times. Done by hand, or by an agent editing file after file, the same thing comes out two ways in two files. The migrations this guide was written from stayed consistent because the repeated parts were scripted: in one, 661
dbparameters, 97.contentreads, and the re-indentation of 191 tests under their newwithblocks.Write throwaway scripts, from the tables in this guide. Work on the syntax tree, not on text: a regular expression for
.contentmisses.content ==and rewrites a model field calledcontent.dband other parameters that become nothingconftest.pyfixture becomes, and whether it is setup or protectionwithit becomes, in orderpytest.raises,parametrizewith literal arguments,skip, marksmonkeypatchcalls towithblocks, and the re-indentationif, afor, atryor a nested function.contentor.json()is the test client's or something else'sforce_login,RequestFactory, helper importssetup_method, anything session-scopedA script should leave alone what it has no rule for, and say so, a line for each thing it left. One that rewrites most of a file and leaves the rest looking finished is worse than none.
Build a fixture's dependencies the way pytest did: the fixtures it depends on first, each built once for the test and passed on. A test that asked for
organdmember, wherememberdepends onorg, buildsorgonce.Three counts, before and after, for every file
Every silent error in the migrations this guide was written from was caught by one of these, and by nothing else, since the tests passed.
assertand everyraisesblock, compared as parsed code with the renames of this guide applied. The same number isn't enough: they should be the same assertions.parametrizelist folded into one@cases(...)line, and a fixture's docstring when the fixture was folded into a call. Count comment lines and docstrings, and compare their words.Take the before counts of every file before editing any of them.
Where to keep the scripts, and these notes
plain checkandplain code checklint, format-check and type-check every file in the project that git doesn't ignore, tracked or not. That includes a migration's own scripts, and the Python in the code blocks of a copy of this guide.Keep both outside the project, or in a directory that
.gitignorelists.15. Things with no replacement
pytest-xdist: tests run in one process. Severalplain testruns can go at once in one checkout, each with a database of its own.pytest-randomly,pytest-rerunfailures: tests run in a fixed order, once.pytest-timeout: none.pytest-playwrightand thetestbrowserfixture: none. Browser tests can't be migrated yet. Keep them out oftests/, or under a name that isn'ttest_*.py, until they can.pytest-cov:coverage run -m plain.testing, thencoverage report.freezegun,time-machine,hypothesis,responses,factory_boy: these are libraries, not plugins. They keep working, used as context managers or called in the test. Their decorators that pass an argument to the test work too, since the wrapper takes it.xfail,--pdb,--lf,--durations,-kexpressions: none.pytest_*hooks: none. There are no plugins. A package that has to protect every test registers aTestLifecycleunder theplain.testingentry point group.16. Behavior that changed without a name changing
Nothing reports these. A test that depended on one fails, or passes for another reason.
login_clientwrites the session once. A test that counted statements around a login counts fewer: a first login is two writes to the session table, where it was four.LISTEN) carries from one test to the next unless the test is@isolated_db.patchputs back what the class itself held. An inherited attribute is inherited again, and astaticmethodis still one.Request(method="GET", path="/").bodyisb"". It used to raiseAttributeError.yieldin it used to be reported as passed with none of its body run. It is a collection error.DeprecationWarningandPendingDeprecationWarningare listed at the end of the run, once each.PYTEST_CURRENT_TESTis not set.PLAIN_TEST_RUNNING=1is, for the whole run, and it is what Plain's own commands read to leave out color..env.testis loaded by plain.dev, as before, and.env.localis left out of a test run.os.environ's values, a model's password or encrypted field. A secret held in a plain string is printed.17. The last run
plain checkruns the tests as one of its steps, and treats exit4(no tests) as nothing to do.Then look for what is left over:
18. What the release notes must say
Written by the release skill. This is what each package's notes have to carry, since
plain-testing's own changelog starts empty and the runner explains none of it.plain (core)
plain.testis no longer part of Plain. What it held isplain.testing, a package of its own and a dev dependency:uv add plain.testing --dev.from plain.test import Clientbecomesfrom plain.testing import Client.plain requestmoved toplain.dev. The command, its flags and its output are the same, and it needsplain.devinstalled.Request(body=...): a request can be constructed with a body.Request(...).bodyisb""when none was given, where it raised.plain.server.inprocess:InProcessServer().handle(request).send()handles a request on the calling thread and returns what would be sent.plain checkrunsplain testand reads its exit codes:4is "no tests", where5was.plain docs <name> --apishows annotated attributes.plain agent installgoes by what the files hold, not when they were written, and names each rule and skill it wrote or removed.PLAIN_TEST_RUNNINGis set, where it readPYTEST_CURRENT_TEST.plain.cli.runtime.set_running_command()/get_running_command(): code that runs during setup can ask which command is running.--fail-fast --match.plain-testing (new,
0.1.0)plain.pytest. pytest is no longer supported, and there is no release where both run.plain.server.inprocess. The lower bound onplainhas to be raised to it.plain-pytest
plain.testingreplaces it.plain-dev
plain requestlives here now.PLAIN_POSTGRES_URLis needed to keep them apart.plain db clean --dry-runlists every database of the project with the reason it would be dropped or is left.plain db cleanasks before it drops and has no flag that skips the question..envfiles were loaded.plain testwith noplain.testinginstalled, starts none.plain-postgres
plain.postgres.testtoplain.postgres.testing, to match the package they serve.plain.postgres.test.pytestis gone, and with it thedbandisolated_dbfixtures. Every test runs in a rolled-back transaction with nothing asked for, and@isolated_dbis a decorator fromplain.postgres.testing.capture_querieshands back a read-only sequence read after the block. Each query has.sqlas sent and.sql_with_params, andqueries.sql_statements(table=)lists statements.queries[0]["sql"]isqueries[0].sql_with_params.max_queriesandcapture_queriesnest.value_is_secret = True. Encrypted fields do.test_<database>_t<schema>) that the first run builds and leaves for the next. One template stays per checkout's database; a changed migration or model is a new one, and the old one goes on the next run. One left by a killed run, and a template of no use, is found byplain db clean.plain-email
plain.email.testtoplain.email.testing.plain.email.test.pytestand themailoutboxfixture are gone.from plain.email.testing import outbox. During a test run the backend is the in-memory one and the outbox is cleared around each test, with nothing asked for.plain-auth
plain.auth.testisplain.auth.testing.login_client(client, user)andlogout_client(client)there replaceclient.force_login()andclient.logout().login_clientwrites the session once.logout_clienton a client that has no session writes nothing.plain-sessions
plain.sessions.testisplain.sessions.testing.get_client_session(client)there replacesclient.session.plain-connect
PLAIN_CONNECT_EXPORT_ENABLED=falsefor tests can be removed.plain-passwords
PasswordFieldhasvalue_is_secret = True: a failed test's report prints a user without the hash.plain-code
plain code checkpassestests/to the type checker as a search path, so a helper imported by its path fromtests/resolves.PTlint rules (pytest style) are no longer in the default set.Every other package
plain.pytesttoplain.testing. Nothing for a user to do.19. What
/plain-upgradehas to doThe skill is what carries an app across, since the runner doesn't. In order:
pyproject.toml, anduv sync.plain agent install, and read what it wrote.conftest.pyfor what guarded every test, and writetests/lifecycle.py(step 4). Before anything is run.plain testonce for the list of what is wrong, and the total of the parameters tests take (step 5).__init__.pyundertests/, moving code out of any that has some (step 6).conftest.py, sort the rest of what it holds by the table in step 7, write the helper modules, and delete it.plain test. Every collection error names its file and says what is wrong. Fix and repeat until there are none, then fix failures.plain check(step 17).xfail, plugins of their own.