Skip to content

Port the unit and end-to-end tests to pytest - #482

Merged
negz merged 7 commits into
modelplaneai:mainfrom
negz:fixture-upper
Oct 8, 2026
Merged

negz merged 7 commits into
modelplaneai:mainfrom
negz:fixture-upper

Conversation

@negz

@negz negz commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Description of your changes

Fixes #473.

#473 asks how we should write e2e tests, and the bake-off pointed at pytest. This PR moves the e2e tests and the function unit tests to pytest, so the repo has one test runner, and rewrites every unit test to one consistent pattern, which CONTRIBUTING's Tests section now describes.

To judge the pattern, read compose-model-cache's tests. No unit test's input or expectation changed: every distinct request and response is the same as before. Mutation testing ran about 18,000 injected bugs in the function code against main's tests and this branch's. The branch's tests catch about 1,400 more than main's, and miss one main's catch. A function-test-style flake check now enforces the pattern's mechanical rules.

nix run .#test runs every function's unit tests. Name a function to run only its tests, and pass pytest arguments after it. Each case is a test of its own, with a short name:

$ nix run .#test -- compose-model-cache
functions/compose-model-cache/tests/test_fn.py::test_compose[GKEFirstPass] PASSED
functions/compose-model-cache/tests/test_fn.py::test_compose[RevisionAndAuthSecret] PASSED
functions/compose-model-cache/tests/test_fn.py::test_compose[EKSFirstPass] PASSED
functions/compose-model-cache/tests/test_fn.py::test_compose[JobComplete] PASSED
...
14 passed in 0.12s

When a case fails, pytest prints a sentence saying what the case checks, then the whole response, with the expected lines as - and the actual lines as +, the same way round as Go's cmp.Diff(want, got). Here's one case with an expected value broken on purpose:

$ nix run .#test -- compose-model-cache -k JobComplete
functions/compose-model-cache/tests/test_fn.py::test_compose[JobComplete] FAILED
...
E       AssertionError: With its PVC bound and its hydration Job complete, a ModelCache drops the Job and reports Ready.
...
E         -                             'phase': 'Hydrating',
E         ?                                       ^ -------
E         +                             'phase': 'Ready',
E         ?                                       ^^^^
...

nix run .#e2e -- --verify brings up both kind clusters as before, then runs the e2e tests, passing any further arguments to pytest:

$ nix run .#e2e -- --verify
...
e2e/test_auth.py::test_refused[NoKey] PASSED
e2e/test_auth.py::test_refused[UnknownKey] PASSED
e2e/test_cluster_gateway.py::test_refused[NoClientCertificate] PASSED
e2e/test_cluster_gateway.py::test_refused[Plaintext] PASSED
e2e/test_metering.py::test_usage_record PASSED
e2e/test_routing.py::test_openai PASSED
e2e/test_routing.py::test_anthropic PASSED
e2e/test_routing.py::test_served_model PASSED
e2e/test_routing.py::test_unclaimed PASSED
e2e/test_routing.py::test_models PASSED
e2e/test_telemetry.py::test_exported[RequestsWaiting] PASSED
e2e/test_telemetry.py::test_exported[GPUMemoryUsed] PASSED
e2e/test_telemetry.py::test_exported[MiBToBytes] PASSED
e2e/test_telemetry.py::test_exported[Deployment] PASSED
e2e/test_telemetry.py::test_exported[Engine] PASSED
e2e/test_telemetry.py::test_exported[Role] PASSED
e2e/test_telemetry.py::test_exported[Cluster] PASSED
e2e/test_telemetry.py::test_exported[EngineNames] PASSED
18 passed in 444.37s (0:07:24)

--test reruns the e2e tests against clusters that are already up, and --clean tears them down:

$ nix run .#e2e -- --test -k telemetry
...
8 passed, 10 deselected in 0.10s
$ nix run .#e2e -- --clean

I have:

  • Read and followed Modelplane's contribution process.
  • Run nix flake check (or ./nix.sh flake check) and made sure it passes.
  • Added or updated tests covering any composition function changes. This PR changes only tests.
  • Signed off every commit with git commit -s.

@negz negz changed the title Port the function unit tests to pytest and write their cases out in full Port the unit and end-to-end tests to pytest Oct 1, 2026
@negz negz added the test-e2e label Oct 1, 2026
Comment thread e2e/test_serving.py Outdated
{"model": serving.model, "messages": [{"role": "user", "content": "ping"}]},
)
assert r.status == 200, r
assert r.json()["model"] == "ml-team/mock-demo"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much more powerful and flexible assertion capabilities for E2E test now. Looks great to me.

The checks build their virtualenvs from a uv2nix package set that checks.nix
defines for itself. The apps that run the tests need virtualenvs from the same
set, so this commit moves it to nix/python.nix, and flake.nix passes it to the
checks.

Signed-off-by: Nic Cope <nicc@rk0n.org>
@negz
negz force-pushed the fixture-upper branch 3 times, most recently from feababe to 98634e0 Compare October 7, 2026 00:57
@negz
negz marked this pull request as ready for review October 7, 2026 03:06
Copilot AI balanced review requested due to automatic review settings October 7, 2026 03:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Polling expires prematurely, and the style checker does not enforce several mechanical rules it is intended to gate.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)
What changed in this PR

Ports unit and end-to-end testing to pytest, standardizing test structure, execution, and CI validation.

Changes:

  • Adds pytest-based Nix runners, linting, type checks, and test-style enforcement.
  • Rewrites function tests into table-driven, whole-response assertions.
  • Replaces shell-based e2e verification with Python orchestration, fixtures, and focused test modules.
File Description
pyproject.toml Adds pytest/Kubernetes dependencies and configuration.
nix/​python.nix Centralizes the uv2nix Python package set.
nix/​checks.nix Adds pytest, style, and e2e type checks.
nix/​apps.nix Adds unit-test and Python e2e runners.
hack/​check_function_tests.py Enforces function-test structure.
functions/​compose-usages/​tests/​test_fn.py Ports usage tests to pytest.
functions/​compose-usages/​tests/​__init__.py Removes obsolete test package marker.
functions/​compose-telemetry-destination/​tests/​test_fn.py Ports telemetry destination tests.
functions/​compose-telemetry-destination/​tests/​__init__.py Removes obsolete test package marker.
functions/​compose-serving-stack/​tests/​test_stacks.py Reworks serving-stack tests for pytest.
functions/​compose-serving-stack/​tests/​__init__.py Removes obsolete test package marker.
functions/​compose-model-service/​tests/​__init__.py Removes obsolete test package marker.
functions/​compose-model-route/​tests/​__init__.py Removes obsolete test package marker.
functions/​compose-model-replica/​tests/​__init__.py Removes obsolete test package marker.
functions/​compose-model-endpoint/​tests/​test_fn.py Ports endpoint tests to pytest.
functions/​compose-model-endpoint/​tests/​__init__.py Removes obsolete test package marker.
functions/​compose-model-deployment/​tests/​test_semver.py Ports and expands semver tests.
functions/​compose-model-deployment/​tests/​__init__.py Removes obsolete test package marker.
functions/​compose-metric-mapping/​tests/​test_fn.py Ports metric mapping tests.
functions/​compose-metric-mapping/​tests/​__init__.py Removes obsolete test package marker.
functions/​compose-inference-gateway/​tests/​__init__.py Removes obsolete test package marker.
functions/​compose-inference-class/​tests/​test_fn.py Ports inference-class tests.
functions/​compose-inference-class/​tests/​__init__.py Removes obsolete test package marker.
functions/​compose-gke-cluster/​tests/​__init__.py Removes obsolete test package marker.
functions/​compose-eks-cluster/​tests/​__init__.py Removes obsolete test package marker.
flake.nix Wires shared Python packages and test apps.
e2e/​wait.py Adds convergence polling support.
e2e/​test_telemetry.py Adds telemetry e2e coverage.
e2e/​test_routing.py Adds gateway routing tests.
e2e/​test_metering.py Adds usage-metering verification.
e2e/​test_cluster_gateway.py Tests cluster-gateway connection security.
e2e/​test_auth.py Tests caller authentication.
e2e/​run.sh Removes the former shell runner.
e2e/​README.md Documents the pytest workflow.
e2e/​manifests/​30-inference-cluster.yaml Updates orchestration references.
e2e/​manifests/​20-inference-class.yaml Updates orchestration references.
e2e/​manifests/​10-inference-gateway.yaml Updates test-runner references.
e2e/​manifests/​00-namespaces.yaml Updates bring-up documentation.
e2e/​lean-control-plane.yaml Updates orchestration documentation.
e2e/​kube.py Adds Kubernetes API helpers.
e2e/​gateway.py Adds gateway request and log helpers.
e2e/​environment.py Implements Python cluster orchestration.
e2e/​dra-example-driver.yaml Updates bring-up documentation.
e2e/​conftest.py Defines shared e2e fixtures.
e2e/​__init__.py Documents the e2e package.
CONTRIBUTING.md Defines the pytest testing convention.
.github/​workflows/​e2e.yml Updates CI documentation for pytest.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread e2e/wait.py Outdated
Comment thread hack/check_function_tests.py
Copilot AI balanced review requested due to automatic review settings October 7, 2026 17:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Polling can terminate before its deadline, and repeated metering checks process unbounded proxy logs.

2 open findings
Previously missed (1)

In code that hasn't changed since last review

Medium severity Unbounded Envoy log parsing slows repeated usage checks

e2e/​gateway.py:102

Every polling attempt in test_usage_record downloads and reparses the complete Envoy log from every proxy. Because --test is designed for reuse against long-running clusters, these logs grow without bound and can make the one-minute poll increasingly expensive or time out. Keep a bounded tail here, as the previous e2e check did.

🧠 Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 7, 2026 20:50
@negz

negz commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator Author

On Copilot's finding that e2e/gateway.py parses the whole Envoy log on every poll: a bounded tail would break this test. Every request logs an identical record, so the test proves its own request was metered by counting matching records before and after sending it. Once a tail fills with identical records, a new record pushes an old one out and the count never grows. run.sh's tail is also why it could pass on a record left over from an earlier run.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Kubernetes exec timeouts bypass the readiness retry policy and can prematurely fail E2E runs.

1 open finding
2 resolved since last review

🧠 Review effort: Balanced

Comment thread e2e/kube.py Outdated
Copilot AI balanced review requested due to automatic review settings October 7, 2026 21:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Kubernetes exec setup can hang outside its timeout, and metering retries rescan unbounded log history.

2 open findings
1 resolved since last review

🧠 Review effort: Balanced

Comment thread e2e/gateway.py
Comment thread e2e/kube.py Outdated
Copilot AI balanced review requested due to automatic review settings October 7, 2026 21:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

MetalLB address derivation fails for valid non-/16 Docker kind networks.

2 open findings
1 resolved since last review

🧠 Review effort: Balanced

Comment thread e2e/environment.py

@dennis-upbound dennis-upbound left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@negz on board with the pattern. compose-model-cache's tests read well, and the thing that makes it stick is check_function_tests.py, because the rules are enforced rather than aspirational. Approving.

Two directional notes.

The LOC cost is worth writing into CONTRIBUTING rather than leaving for someone to discover. 22k lines of test code to 46.6k, with compose-serving-stack at 9.5k and compose-model-replica at 7.9k. That's the trade "write each case out in full" buys and I think it's the right one, but the next person adding a case to a 9.5k-line file should know it's deliberate.

"No unit test's input or expectation changed" isn't quite true for compose-model-replica. I checked this mechanically: ran both branches under a plugin recording every call into the function package with its args and result, and 18 of 19 functions match exactly. compose-model-replica drops 39 distinct calls and adds none. 29 are old tests computing expectations by calling the function's own helpers, which is the point of the rule. The other 10 are build() inputs that set phase or spec.serving, and build() reads neither, so no coverage is lost. Worth a clause in the description so the next reviewer who checks doesn't stop there.

FYI, impl things, take or leave:

  • nix run .#test -- -k Foo always exits 1. pytest returns 5 when -k selects nothing and the loop treats non-zero as failure, so every function reports failed while the matching test passed.
  • .#test has to run from the repo root. The pytest path is relative and nothing anchors it, so from a subdir every function exits 4.
  • logging.configure(DISABLED) went with setUpModule and nothing replaced it, so structlog is unconfigured and every RunFunction logs. Shows up in -s and in captured stdout ahead of the diff.
  • e2e/tests/test_telemetry.py:104 catches AssertionError, but kube.RETRY is four types now including TimeoutError, and wait.until re-raises the last one. Three escape the fallback, so all eight cases error opaquely instead of printing the per-line diagnostic.
  • e2e/tests/conftest.py:209 gives the first 200 a hard 120s where run.sh looped ten attempts. Downstream tests send one request with no retry of their own.
  • check_function_tests.py only checks helpers named _*, and doesn't run over e2e/tests, which uses the same Case shape.
  • "Tests are plain functions, with no classes, fixtures, or conftest.py" sits in the function-tests section but reads repo-wide, and e2e deliberately uses all three.

negz added 2 commits October 8, 2026 16:30
The e2e tests are moving to pytest, and one test runner for the whole repo is
simpler than two. pytest 9 runs the existing unittest suites unchanged and
reports each subTest on its own, so this commit switches the runner without
touching a test. It configures pytest in strict mode, to list each test, and to
print whole assertion diffs, since the tests compare whole responses.

nix run .#test runs every function's unit tests outside the sandbox, against
the virtualenvs the checks use, or one function's with pytest arguments after
its name.

Towards modelplaneai#473.

Signed-off-by: Nic Cope <nicc@rk0n.org>
The previous commit runs the unittest suites under pytest unchanged. This
commit ports them to pytest's own style, so each case in a table is a test of
its own that -k can select. Async tests call RunFunction with asyncio.run
rather than needing a plugin. pytest captures a test's output and shows it only
when the test fails, so the tests no longer disable the functions' logging.

pytest diffs two dicts in insertion order, where unittest sorted their keys.
The Structs a function returns rarely list their keys in the same order as the
test's expected ones, so the tests compare dicts with sorted keys, which keeps
a diff to the fields that differ. No case's request or expected response
changes.

Towards modelplaneai#473.

Signed-off-by: Nic Cope <nicc@rk0n.org>
negz added 4 commits October 8, 2026 16:30
The tests built their cases in several ways. Some derived a case from another
by copying and mutating it, or patched a request after building it. Others
built whole requests and responses with helpers, asserted on individual fields,
or computed expected values with the code under test or the SDK. A reader often
couldn't tell what a case checked without tracing code. Some case names also
claimed conditions their input didn't set up, and as sentences they made long
test IDs.

This commit rewrites the tests to the rules CONTRIBUTING now states, and says
why in a comment wherever a test departs from them. Helpers take everything
that varies as a required keyword argument, because a defaulted argument had
hidden that a case named for an unpinned replica was pinned. A field-level test
became a case in its entry point's table, or was deleted where a case already
sent the same request. Each case has a short CamelCase name, which is its test
ID, and a one-sentence reason, which pytest prints when it fails.

Every distinct RunFunction request and response is unchanged. Writing cases
out in full takes the tests from about 22,000 lines to 46,000.

Towards modelplaneai#473.

Signed-off-by: Nic Cope <nicc@rk0n.org>
CONTRIBUTING's Tests section sets out how a function's unit tests are
written, but only a reviewer checked that a test followed it.

This commit adds a checker that turns the rules an AST can decide into
a pass or fail: a table and the test that runs it, a case's fields,
name and reason, the assertion message, copies, merges and computed
child names, module-level objects other than tables, a helper's
parameters, and test names. It lives in hack/ and uses only the
standard library. A function-test-style flake check type-checks and
runs it, and nix run .#fix now lints its directory.

A test with a reason to depart from a rule escapes it on the reported
line, as in "# noqa: MPT401  # why". That's the form ruff already
reads, so ruff now treats MPT codes as external rather than unknown. A
case name counts an acronym such as GKE as one word, because many
cases name a cloud or protocol that way.

The new check fails on five violations in the tests as they stand.

Towards modelplaneai#473.

Signed-off-by: Nic Cope <nicc@rk0n.org>
The local e2e was a shell script, e2e/run.sh. Most of it checked the running
environment, with polling loops written out by hand, JSON and logs matched as
text, and an exit at the first failed check, so one fault hid every check after
it.

This commit replaces run.sh with a pytest suite that makes the checks run.sh's
--verify made, one test each, and brings the clusters up the way run.sh did.
Each test waits only for what it needs, so a gateway that refuses every caller
fails only the tests that need it to serve. nix run .#e2e keeps its flags, and
gains --test, to rerun the tests without bringing the clusters up again.

Four checks are stricter. The response must name the served model in its
top-level model field, where a model key at any depth passed. An unclaimed model
must get a 404, where any status but 200 passed. /v1/models must list the model
by its exact name, where a substring of another name passed. And the usage
record must carry the full endpoint name, where a prefix of it passed.

Fixes modelplaneai#473.

Signed-off-by: Nic Cope <nicc@rk0n.org>
ty ran in 21 checks: a ty-<function> check per function, ty-e2e, and
function-test-style, which type-checked hack/check_function_tests.py
before running it.

This commit replaces them with one ty check. It still runs ty once per
function, against that function's venv, because every function's
package is named function and one run could resolve one function's
import to another's package. Then it checks e2e/ and hack/. A failed
run doesn't stop the rest, and the build ends by naming every directory
that failed. The runs are serial where the old checks built in
parallel, which costs about a second once the venvs are built.

Towards modelplaneai#473.

Signed-off-by: Nic Cope <nicc@rk0n.org>
Copilot AI balanced review requested due to automatic review settings October 8, 2026 23:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It broadly replaces both unit-test infrastructure and live two-cluster orchestration, warranting final human validation despite no confirmed blocking defect.

0 open findings

2 resolved since last review

🧠 Review effort: Balanced

@negz
negz merged commit b0fe752 into modelplaneai:main Oct 8, 2026
4 of 5 checks passed
@negz
negz deleted the fixture-upper branch October 8, 2026 23:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decide how to write e2e tests

4 participants