Skip to content

Migrating from pytest

voci-migrate takes a pytest suite and turns it into a voci one. It is a separate distribution from voci: the runtime never depends on it, and nothing it installs ends up in the migrated suite.

uv pip install voci-migrate    # or: pip install voci-migrate

A migration is four commands, run in order, each reading what the one before it wrote:

Command Does
voci-migrate extract asks pytest what it resolved for the suite, and writes it down
voci-migrate audit classifies every construct and reports what migrating would cost
voci-migrate convert rewrites the suite in voci's spelling
voci-migrate verify runs both runners and compares them test for test

Everything downstream starts from that first artifact. A migration is only as good as its picture of the suite's fixture wiring, and pytest is the only thing that knows that picture exactly — so the tool asks pytest rather than deducing it from the sources.

How much of pytest carries over

Most of a suite is a spelling change. Fixtures, parametrization, marks, the capture and temporary directory builtins, raises and approx all have a voci counterpart the conversion writes for you:

pytest voci
conftest.py and name-based lookup a module you import, and Annotated[T, Depends(fixture)]
fixture scopes: function, class, module, package, session call, function, module, session — class widens to module, package to session
autouse=True, @pytest.mark.usefixtures one voci.use(...) on the module or package
@pytest.mark.parametrize, pytest.param @voci.parametrize, voci.case, with pytest's ids kept verbatim
indirect parametrization params= on the fixture itself
skip, skipif, xfail, custom marks @voci.skip, @voci.skipif, @voci.xfail, @voci.tag
capsys, caplog, tmp_path, tmpdir voci.capture, voci.log_records, tmp_path, voci.tmpdir
pytest.raises, pytest.approx voci.raises, voci.approx
pytest-asyncio, anyio, event_loop fixtures deleted — voci runs async tests itself
pytest-xdist deleted — voci is concurrent within one process

Some of it has no counterpart, and a suite leaning on one of these has to give it up or keep that part under pytest:

pytest Why voci has nothing for it
unittest.TestCase, doctests, nose-style collection voci collects functions and class Test* methods, and nothing else
conftest.py hooks, pytest_addoption, plugin-provided fixtures and marks there is no hook protocol to plug into: injection is the extension point, and no hook dispatch on the hot path is a design invariant
pytest.warns, recwarn, deprecated_call, filterwarnings the warnings filter is one process-global list, and voci's tests share the process
capfd, capsysbinary, capfdbinary voci captures by replacing sys.stdout and sys.stderr, so a write to file descriptor 1 by a subprocess or a C extension goes uncaptured, and there is no binary variant
pytestconfig, cache, record_property, pytester each is a handle on pytest's own machinery
pytest.importorskip, pytest.xfail() as a statement a decision made partway through a body has no runtime call to make it with — guard the import and use @voci.skipif, or mark the test @voci.xfail outright
a non-asyncio event loop — trio, tornado voci runs the suite on one asyncio loop

The plugin and hook system, and unittest/doctest/nose collection, are absent by design rather than pending.

A smaller group converts only once you have edited the suite by hand — xunit setup_method and friends, a conftest.py override whose specialized chain would fan out past the budget, a request object passed to another function. The audit counts each as blocked and names the file and line, so the work is a list rather than a search.

A last category converts untouched and then costs you concurrency. monkeypatch, writes to os.environ, unittest.mock.patch, mocker, a frozen clock, os.chdir — anything patching state the whole process shares — makes voci schedule that test alone, since nothing else can safely run beside it. The audit reports that share of the suite up front, because it is the part concurrency cannot speed up.

The support matrix is the full table: every construct, its code, what it becomes, and what to do where the answer is nothing.

Extract the ground truth

Collect the suite, run nothing, write the dump:

$ voci-migrate extract tests
42 tests collected in 0.03s
voci-migrate: wrote ground truth for 42 tests to .voci-migrate/ground-truth.json

Anything after -- goes to pytest unchanged, so a suite that needs its own flags in order to collect still works:

$ voci-migrate extract tests -- -p no:randomly --ignore tests/slow

The dump records which fixture each test actually gets and the whole override chain that decided it, where each autouse fixture applies, pytest's own parametrize ids, every mark together with the node it was written on, and the resolved ini configuration and installed plugins.

A dump is ground truth for the environment it was taken in, and records that environment. A suite whose fixtures differ by platform or plugin version needs one extraction per environment.

Where the suite collects somewhere voci-migrate cannot be installed — a container, a locked CI image — voci_migrate/extractor.py is a single file that imports only pytest. Copy it next to the suite, load it as a plugin, and copy the JSON back out:

$ pytest tests -p extractor --collect-only -q --extractor-out ground-truth.json

Audit before converting

audit answers the question that comes before any rewriting: what is this suite made of, and what would migrating it cost? It joins the dump with a static read of the suite's own sources, classifies what it finds, and writes a report. It reads; it changes nothing.

$ voci-migrate audit
42 tests   39 clean (92.9%)   3 need review   0 blocked (0.0%)
serial: 0 of 42 tests run alone, 0.0% of the suite
9 findings over 8 constructs, 4 about the suite itself

VC003  marker      2  fixture scope="class" or scope="package"
VC005  mechanical  1  a fixture overriding one visible from further out
VC103  marker      1  @pytest.mark.skipif with a string condition
VC301  mechanical  1  testpaths
...

types: 6 of 14 fixtures state no return type, costing 11 injected parameters — the report lists them worst first

wrote .voci-migrate/migration-report.md and .voci-migrate/findings.json

The first line is the whole verdict, and every collected test lands in exactly one of its three counts. Clean converts with nobody reading the diff line by line. Needs review converts, but something shifted enough that the converted source carries a marker, or the test behaves differently once tests overlap. Blocked has no conversion path as it stands, because the construct needs a human decision.

The serial figure cuts across all three: it is the share of the suite that ends up running alone, which is the part concurrency cannot speed up. A test can convert untouched and still have to run by itself.

Every finding cites a code — VC003, VC401 — that names one row of the support matrix: the pytest construct, what becomes of it, and what to do where the answer is "nothing". The same codes label report sections and the markers left in converted source, so a number in the summary, a paragraph in the report and a marker in a file are one thing seen three ways.

migration-report.md is written to be read and forwarded: the verdict, then the fixture return types worth adding first, then a section per construct that needs a decision with its file-and-line list, then what converts with a caveat, the concurrency hazards, what the conversion rewires, the configuration and plugins, and last what the audit cannot see. findings.json carries the same content for tooling.

Two things collection cannot see, and the audit names rather than counts: what a fixture decides at run time, such as request.getfixturevalue(...), and anything behind application code — test order dependence, teardown timing, shared state.

Annotate before converting

The conversion preserves whatever type information the suite already states, and fabricates none. An injected parameter's type comes from the fixture factory's return annotation, so a fixture written without one is injected into a parameter with nothing to type it: mypy reads such a parameter as Any and checks nothing done with it. The conversion report names every site that lands that way. The exception is a fixture voci itself provides — tmp_path, capsys and the rest — whose type comes from voci's own declaration, since your suite never said what they return either way.

That makes the last step before convert a pytest one. Annotate the fixtures, run your type checker, and keep pytest green while you do it, since a return annotation changes no behaviour:

@pytest.fixture
def db_session() -> Session:
    return Session(engine)

The audit's fixture return types section is the worklist. Every fixture whose factory states no return type — no annotation, or one such as -> Any that says nothing a checker can use — with its file and line, ordered by how many injections lose their type with it, so the top of the list is worth the most:

- conftest.py:23 (engine) — 5 injections
- conftest.py:28 (client) — 3 injections
- conftest.py:18 (settings) — 2 injections

Convert

convert prints its plan and a diff, and writes nothing until you pass --write:

$ voci-migrate convert
17 fixtures translated, 0 left as pytest wrote them
12 files change, 3 markers written

fixture modules:
  api/conftest.py -> api/fixtures.py
  conftest.py -> fixtures.py
  api/test_api.py: from api.fixtures import payload as api_payload

translated:
  VC104  6  @pytest.mark.skip, @pytest.mark.skipif(bool)
  VC101  4  @pytest.mark.parametrize
  ...

[tool.voci]:
  testpaths = ["."]
  ignore = ["scratch"]

diff --git a/api/conftest.py b/api/fixtures.py
...

nothing written; pass --write to apply

Each conftest.py becomes an ordinary module, and the tests that used to pick its fixtures up by name import them instead. A directory's fixtures go from this:

# api/conftest.py
import pytest


@pytest.fixture
def payload():
    return {"kind": "order", "qty": 2}


@pytest.fixture
def route(payload):
    return f"/v1/{payload['kind']}s"

to this:

# api/fixtures.py
import voci
from typing import Annotated, Any
from voci import Depends


@voci.fixture()
def payload():
    return {"kind": "order", "qty": 2}


@voci.fixture()
def route(payload: Annotated[Any, Depends(payload)]):
    return f"/v1/{payload['kind']}s"

Where a construct converts but the result deserves a look, the rewrite leaves a marker naming the code and what to check:

# VOCI-TODO[VC003]: Confirm the fixture tolerates being shared more widely, or split it.

Grepping for VOCI-TODO is the review queue the conversion leaves behind.

Verify against pytest

verify runs both runners and compares them test for test: pytest on the tree that still holds the pytest suite, voci on the converted one. Ids survive the conversion verbatim, so the two verdicts line up by id, and every id they disagree about is the review queue.

$ voci-migrate verify --before ../service-pytest --after .
1188 tests under pytest   1188 under voci   1183 agreed   5 diverged
outcome (5) — ran under both and ended differently
  tests/test_registry.py::test_serializer_must_have_meta            passed -> failed
  ...

wrote .voci-migrate/verify-report.md and .voci-migrate/verify.json

The exit status is 0 when everything agreed and 1 when anything did not, so the comparison can be the gate on a migration branch. verify-report.md lists the whole queue — the terminal summary caps each kind — and verify.json carries the same content for tooling.

convert --write rewrites the tree in place, which leaves no pytest suite to run afterwards. Record the pytest half first, then compare against the recording:

$ voci-migrate verify --record --before .   # -> .voci-migrate/pytest-outcomes.json
$ voci-migrate convert --write
$ voci-migrate verify --after .

Both halves run in one environment, since one command runs both. Where pytest's half has to run somewhere voci-migrate cannot be installed, voci_migrate/outcomes.py is a single file that imports only pytest — the same arrangement as the extractor. Copy it in, run the suite, and pass the JSON back as --baseline:

$ pytest -p outcomes --outcomes-out pytest-outcomes.json

Then raise the concurrency

voci runs at --concurrency 1 under verify unless -c says otherwise. A serial run is what separates "the conversion changed what the suite does" from "the suite does not survive tests overlapping". Once the serial comparison agrees, raise it:

$ voci-migrate verify --after . -c 16

Anything that diverges now is state shared between tests, and the audit's hazard census is the index to triage it with.