Safe migration to native Testenix¶
An existing suite can use Testenix in three ways:
Goal |
Command |
Execution engine |
Original suite |
|---|---|---|---|
Preserve all pytest behavior |
|
pytest |
Runs directly |
Check whether native conversion is supported |
|
no tests run |
Read-only |
Create a validated native copy |
|
Testenix after conversion |
Kept and used as the baseline |
Migration is conservative. It stops on a construct whose behavior cannot be proven equivalent instead of producing a plausible-looking, incomplete suite.
First migration¶
For pytest, install the optional dependency because validation executes the real source suite:
$ python -m pip install "testenix[pytest]"
$ testenix migrate pytest tests --dry-run
$ testenix migrate pytest tests --check --workers 2 \
--report-json reports/migration-check.json
$ testenix migrate pytest tests --output tests_testenix \
--report-json reports/migration.json
$ testenix run tests_testenix
For a standard-library unittest suite, no extra package is needed:
$ testenix migrate unittest tests --output tests_testenix
Use auto when pytest-style modules and unittest modules live in the same selected directory.
They must be separate modules; a file that mixes both test models is rejected.
The destination defaults to testenix_migrated. It must be a new directory inside the project,
with an existing real parent. There is deliberately no --force and no in-place mode.
An integer --workers value must be at least 2 so the parallel gate cannot silently repeat the
serial command. During --check or publication, a one-module candidate still has one schedulable
affinity unit, which is disclosed as a MIG006 warning; spread tests across independent modules to
exercise multiple workers. The warning is not shown for --dry-run or an already-blocked migration
because no parallel candidate is run in either case. An audit-report path must also be new, inside
the project, and disjoint from every selected source and the output suite. Testenix never replaces
an existing report.
Transaction and rollback contract¶
Testenix uses copy-and-validate rather than edit-and-undo:
preflight paths
-> snapshot source names + SHA-256
-> convert in memory
-> run original suite in disposable project copy
-> run generated suite with one Testenix worker in a fresh copy
-> run generated suite in parallel in another fresh copy
-> compare inventory, totals, and every mapped test outcome
-> recheck every source hash
-> atomically publish a new directory without replacement
If parsing, conversion, the source baseline, either native run, parity comparison, a source hash, or publication fails before the atomic rename, the requested output directory remains absent. A failure to write the optional report after a successful rename instead leaves the validated output published, prints an explicit warning, and returns the successful migration status. Testenix never writes to, renames, deletes, or replaces a selected source file. There are therefore no old files to restore: the old suite was never changed.
--dry-run stops after static analysis and a final source-hash check. --check performs the full
three-run validation but does not publish the destination. The default performs the same checks
and then uses an operating-system no-replace rename. If another process creates the destination
during validation, publication fails closed.
POSIX staging creation, artifact writes, publication, and cleanup are anchored to open directory
descriptors. On a platform without safe descriptor-relative recursive deletion, a non-empty
failed staging transaction is retained under .testenix/migrations for inspection instead of
being removed with a path-based recursive walk.
Validation runs with the shadow as both the current directory and PWD, so ordinary relative-path
writes land in the disposable copy. A shadow is not an operating-system security sandbox: a test
can still write through a hard-coded absolute path or another environment variable, and network,
database, cloud, and subprocess side effects remain external. Selected Python sources are rehashed
before every terminal result, so detected drift stops publication, but Testenix never tries to undo
an independent user or test-process edit. Use test credentials and --dry-run first for suites
with external effects.
Pytest conversion contract¶
The converter subset introduced in v0.2 supports the behavior below:
module-level pytest-default
test*functions and normal Pythonassertstatements;simple
Test*classes with a fresh zero-argument instance per test method, including ordinary helper methods; inheritance, class decorators, custom construction, and pytest class lifecycle hooks remain outside the safe subset;bare
@pytest.mark.asyncioonasync deftests. The generated synchronous wrapper runs every test or parametrized case in a fresh, closedasyncio.Runner, matching pytest-asyncio’s default function-scoped loop isolation;one static
pytest.mark.parametrizewith static names, rows, IDs, and unmarkedpytest.param(..., id=...)values;local fixtures using function or module scope, including a statically boolean
autouse=True;simple fixtures from an adjacent
conftest.pyin the same directory;native
tmp_path, which supplies a freshpathlib.Pathand removes its temporary directory at test teardown;native
monkeypatch.setattrin object/attribute and dotted-import forms, plussetenvand idempotentundo; successful changes are restored in LIFO order during test teardown.monkeypatchmay also flow through statically resolved module-local helpers when every use can be proven to stay inside this supported subset;static
pytest.mark.skipandpytest.mark.skipif;plain argument-free custom markers, converted to Testenix tags;
pytest runtime helpers
approx,deprecated_call,fail,raises, andwarns. Generated modules using these helpers still require pytest at runtime.
It blocks, with a file and line diagnostic:
complex pytest test classes, xfail, runtime skip/xfail/importorskip/exit, and xunit lifecycle hooks;
built-in fixtures other than
tmp_pathandmonkeypatch, such ascapsys,caplog, andrequest; monkeypatch operations outside the documented native subset, imported or dynamically rebound helpers, and values that escape static analysis are also unsupported;dynamically configured autouse fixtures, parametrized fixtures, fixture overrides, and inherited ancestor-
conftestfixtures;session-scoped fixtures, because pytest creates one per run while Testenix session scope is currently worker-local;
stacked, dynamic, indirect, scoped, or per-case-marked parametrization;
usefixtures, module-levelpytestmark, hook functions, plugin registration, and semantic plugin markers such as anyio, configured asyncio, timeout, order, repeat, or flaky;unmarked async tests, async fixtures, custom
event_loop_policy, non-function asyncio loop scopes, and enabled asyncio debug mode. Testenix checks the effective pytest configuration and relevantPYTEST_ADDOPTSoverrides before accepting bare asyncio markers;decorators and required parameters whose execution meaning cannot be established statically.
Any converted pytest file whose name is not already test_*.py is renamed in the generated copy
to a native-discoverable name. Explicit nonstandard files such as specs.py are supported when
they contain static test inventory. Supporting Python modules and package __init__.py files under
the selected source directories are copied to preserve common relative imports. Missing non-Python
assets or path-sensitive __file__ behavior will make candidate validation fail rather than
publish a bad copy.
Pytest plugins are intentionally not emulated. Keep using testenix pytest for modules that need
them and migrate supported modules incrementally.
Unittest conversion contract¶
Rewriting self.assertEqual, lifecycle methods, cleanup stacks, and mock decorators would be
fragile. Instead, Testenix generates one native function wrapper per test method. The wrapper:
resolves the original by the exact wrapper-to-project relative path, independent of
cwd;verifies a manifest containing every selected Python source and SHA-256;
loads the original class by exact path;
executes one method through the standard
TestCase.run(TestResult)protocol;exposes its pass, failure, skip, expected-failure, or unexpected-success outcome to Testenix.
This preserves direct subclasses of unittest.TestCase and
unittest.IsolatedAsyncioTestCase, including per-test setUp/tearDown, async lifecycle,
addCleanup, assert*, context-manager assertions, and unittest.mock.patch. Static skips map to
Testenix SKIP; expectedFailure maps to XFAIL; an unexpected success remains the gating
XPASS outcome.
The generated unittest suite deliberately depends on the unchanged originals. Do not delete or move them, and do not move or rename the published generated directory: wrappers encode the exact relative relationship between both trees. If either path or an original file changes, rerun migration; a changed source makes native collection fail with an explicit diagnostic.
The converter blocks subTest, runtime skipTest/SkipTest, class/module lifecycle and cleanup,
custom run or loader hooks, load_tests, mixins or indirect inheritance, FunctionTestCase,
dynamic/DDT/parameterized method generation, metaclasses, and ambiguous decorators.
What validation compares¶
The source suite must be green. Testenix records and compares:
the exact collected test count against the converter’s source-to-target mapping;
passed, failed, error, skipped, expected-failure, and unexpected-success totals;
the outcome of every source test against its exact generated target mapping;
native serial and native parallel outcome signatures;
every selected Python source path and SHA-256 immediately before publication.
A pre-existing source failure is not treated as proof of equivalent conversion, even when the
candidate happens to fail too. Fix the baseline or use --dry-run to inspect static support.
The JSON audit report uses the versioned testenix.migration-report format. It includes source
hashes, every source-to-target mapping, per-test outcomes, generated files, line diagnostics,
timings and summaries for all validation runs, publication status, and an originals_modified
flag. It is false for a successful transaction and true when a terminal source recheck detects
drift; the flag reports observed state and does not claim that Testenix caused an independent edit.
The console groups repeated diagnostics by code and shows the first location, so large suites do
not produce hundreds of near-identical lines. --report-json FILE and --report-json - always
retain every individual source- and line-addressed diagnostic. On a blocked transaction, the
console calls any safe in-memory result a statically convertible subset rather than implying that
those tests were published.
Performance with thousands of migrated tests¶
Migration unlocks the native scheduler, process supervision, async engine, retries, history, and
Testenix reports. It does not guarantee that every converted suite is faster. By default Testenix
keeps a normal module as one affinity unit so module-scoped fixtures are not duplicated.
Consequently, 3,000 tests in one source module still form one schedulable unit; 3,000 tests spread
across enough independent modules can use multiple workers. A project may later opt eligible native
modules into --shard-modules, but only after validating the generated suite and the documented
static-analysis trust boundary. Run testenix tune on the published native copy before fixing its
CI worker count.
The checked-in migration baseline used an Apple M4 Pro, CPython 3.11, 3,000 generated tests in 64 modules, four native workers, one warm-up, and five measured rounds:
Source runner |
Test body |
Source median |
Native median |
Native result |
One-time migration |
|---|---|---|---|---|---|
sequential pytest |
no-op |
1.539 s |
0.521 s |
2.96x faster |
5.940 s |
sequential unittest outcome probe |
no-op |
0.161 s |
1.192 s |
7.40x slower |
6.742 s |
sequential unittest outcome probe |
1 ms sleep |
4.066 s |
2.577 s |
1.58x faster |
17.251 s |
The migration column is the complete copy, source-baseline, serial-candidate,
parallel-candidate, integrity-check, and publication transaction. It is paid when regenerating a
suite and is deliberately excluded from the recurring execution medians. The unittest adapter
loads the unchanged source and translates TestCase.run() results for every native wrapper. That
fixed cost dominates an empty method; once the generated methods contain 1 ms of synthetic work,
parallel execution across 64 modules outweighs it in this scenario.
These source measurements use sequential pytest and a sequential Testenix probe built on the stdlib unittest loader/result protocol. The probe serializes per-test outcomes for the parity gate, so its small audit overhead is included. They are not comparisons against pytest-xdist or a third-party parallel unittest runner. They are synthetic timing evidence, not a promise for a real project: imports, fixtures, I/O, test-duration distribution, module affinity, worker count, operating system, and CPU can all reverse the result. See the generated benchmark page for raw samples and variance, or inspect the checked-in pytest, unittest no-op, and unittest 1 ms JSON files directly.
Benchmark the generated directory only after parity validation:
$ python benchmarks/run_migration_benchmark.py \
--framework pytest --tests 3000 --modules 64 --workers 4 \
--warmups 1 --repeats 5 --output migration-pytest-3000.json
$ python benchmarks/run_migration_benchmark.py \
--framework unittest --tests 3000 --modules 64 --workers 4 \
--warmups 1 --repeats 5 --output migration-unittest-3000.json
$ python benchmarks/run_migration_benchmark.py \
--framework unittest --tests 3000 --modules 64 --workers 4 --delay-ms 1 \
--warmups 1 --repeats 5 --output migration-unittest-3000-delay-1ms.json
The harness checks the exact count and source hashes before accepting any timing sample. Publish the raw JSON, environment, module count, worker count, variance, and commands with a result. Do not apply the repository’s synthetic native benchmark ratio to a real migrated project without measuring it.
CI rollout¶
A low-risk rollout keeps both suites for several releases and runs the exact newly published copy:
- name: Generate and validate a fresh native copy
run: |
testenix migrate auto tests --output tests_testenix_ci --workers 2 \
--report-json migration-report.json
- name: Existing source suite
run: testenix pytest -q tests
- name: Run the exact validated copy
run: testenix run tests_testenix_ci --no-history
The output and report paths must be absent at job start, which is natural on a fresh CI checkout.
--check validates an ephemeral candidate; it does not prove that a separately committed
tests_testenix directory has identical bytes. Regenerate and publish a fresh CI output as above,
or add a project-specific content-manifest check for a committed copy. Regenerate whenever a pinned
unittest source changes.
Exit codes¶
Code |
Migration meaning |
|---|---|
|
Analysis, check, or publication completed successfully. |
|
Source or candidate execution failed, timed out, or produced different outcomes. |
|
Unsafe/invalid path, existing output, source drift, or command usage error. |
|
Internal process or pre-publication report failure. A report-only failure after publication warns and preserves exit |
|
At least one construct is unsupported by the conservative converter. |
|
Interrupted by the user. |
Diagnostics beginning with PYT describe pytest source, UNIT describes unittest source, and
MIG describes cross-framework inventory or transaction safety.