Skip to content

Testing ML Code

What you’ll learn

  • the six kinds of test that apply to ML code, written out, not described
  • which bugs each one actually catches — a measured 7 × 7 matrix, not an opinion
  • why two of the most commonly written ML tests caught zero of the six bugs
  • the one-second test that caught 4 of 6 and localises the failure to the training loop
  • why the leaky scaler (accuracy 0.8500, identical to correct) needed a structural test
  • how to arrange these tests so CI stays fast enough to actually run

Seven bugs

Each one is a plausible single-line mistake, and each produces a model that runs, returns well-formed probabilities and reports a number.

Injected bugWhat it stands forTest accuracy
(none — correct)0.8500
scaler fitted on all dataStandardScaler().fit(X)StandardScaler().fit(X) before the split0.8500
labels shuffleda join that lost its ordering0.4833
target leaked as a featurea helper column absent at serving time0.5092
scaler missing at servingtrained on scaled input, served raw0.5000
one class in traininga filter that removed the positives0.5000
feature rows permutedX.iloc[order]X.iloc[order] without reordering yy0.4592

Note the second row. Leakage through the scaler changed the test accuracy by 0.0000. That is not a mistake in the experiment; on this data, scaling statistics estimated from 4,000 rows instead of 2,800 are almost identical, so the bug is real, undetectable by any metric, and would become dangerous the day the test distribution differs. Hold onto it — it is why the last test in the matrix exists.

Seven tests, measured

figureTest accuracy in brackets. The 'metric floor (0.75)' test caught 5 of 6 bugs.matplotlib
A 7 by 7 grid of bugs against tests. The output-contract and row-order-invariance columns are entirely 'pass'. The metric floor column catches five bugs, memorise-24-rows catches four, directional expectation catches four, column-order safety catches one, and 'everything in a Pipeline' catches four including the leaky scaler.A 7 by 7 grid of bugs against tests. The output-contract and row-order-invariance columns are entirely 'pass'. The metric floor column catches five bugs, memorise-24-rows catches four, directional expectation catches four, column-order safety catches one, and 'everything in a Pipeline' catches four including the leaky scaler.
Two whole columns caught nothing: the output contract and row-order invariance passed for every bug, including the ones that reduced accuracy to 0.4592. They are regression guards, not bug detectors. The metric floor caught the most — but it needs a full training run and tells you nothing about where the fault is.
TestBugs caught (of 6)CostWhat it localises
output contract01.1 msshape/range regressions only
row-order invariance02.1 msorder-dependent inference
column-order safety1under 1 mspositional serving paths
metric floor (0.75)510.3 ms (a full fit)nothing — just “something is wrong”
memorise 24 rows45.6 msthe training loop
directional expectation42.1 msthe learned relationship
everything in a PipelinePipeline410.3 mspreprocessing structure

Timings are on this small dataset, where a full fit is 10.3 ms. The ratios are what transfer: a fit on 24 rows costs a fraction of a fit on 2,800, and on a model that takes twenty minutes to train that difference is the whole argument for the memorisation test.

1. The output contract

test_contract.py
def test_output_contract(model, X):
    proba = model.predict_proba(X)
    assert proba.shape == (len(X), 2)
    assert np.all((proba >= 0) & (proba <= 1))
    assert np.allclose(proba.sum(axis=1), 1.0)
    assert set(np.unique(model.predict(X))) <= {0, 1}
test_contract.py
def test_output_contract(model, X):
    proba = model.predict_proba(X)
    assert proba.shape == (len(X), 2)
    assert np.all((proba >= 0) & (proba <= 1))
    assert np.allclose(proba.sum(axis=1), 1.0)
    assert set(np.unique(model.predict(X))) <= {0, 1}

Caught 0 of 6. Every broken model above produced perfectly valid probabilities. This test is still worth having — it catches the day someone returns log-odds instead of probabilities, or a column order flip in a multiclass output — but it is a contract, not a bug detector, and writing it should not feel like having tested the model.

2. Invariance tests

test_invariance.py
def test_row_order_invariance(model, X):
    """Predictions must not depend on the order rows arrive in."""
    base = model.predict_proba(X)[:, 1]
    order = np.random.default_rng(7).permutation(len(X))
    assert np.allclose(base[order], model.predict_proba(X.iloc[order])[:, 1])
 
 
def test_column_order_safety(model, X):
    """Reordering columns must either raise or produce identical predictions."""
    columns = list(X.columns)
    columns[0], columns[7] = columns[7], columns[0]
    try:
        other = model.predict_proba(X[columns])[:, 1]
    except Exception:
        return                                   # raising is the correct behaviour
    assert np.allclose(model.predict_proba(X)[:, 1], other)
test_invariance.py
def test_row_order_invariance(model, X):
    """Predictions must not depend on the order rows arrive in."""
    base = model.predict_proba(X)[:, 1]
    order = np.random.default_rng(7).permutation(len(X))
    assert np.allclose(base[order], model.predict_proba(X.iloc[order])[:, 1])
 
 
def test_column_order_safety(model, X):
    """Reordering columns must either raise or produce identical predictions."""
    columns = list(X.columns)
    columns[0], columns[7] = columns[7], columns[0]
    try:
        other = model.predict_proba(X[columns])[:, 1]
    except Exception:
        return                                   # raising is the correct behaviour
    assert np.allclose(model.predict_proba(X)[:, 1], other)

Row-order invariance caught 0 of 6. Column-order safety caught 1 — the serving path that converted to numpy and therefore indexed positionally, which is exactly the failure from the previous page. Note the shape of that assertion: raising is a pass. A test that demands no exception would have rewarded the buggy implementation.

3. The metric floor

test_metric.py
def test_beats_floor(seed=0, floor=0.75):
    """A fixed seed, a fixed split, and a number the model must clear."""
    model = train(X_train, y_train)
    assert accuracy_score(y_test, model.predict(X_test)) >= floor
test_metric.py
def test_beats_floor(seed=0, floor=0.75):
    """A fixed seed, a fixed split, and a number the model must clear."""
    model = train(X_train, y_train)
    assert accuracy_score(y_test, model.predict(X_test)) >= floor

Caught 5 of 6 — everything except the leaky scaler. It is the single most valuable test on the list and also the least diagnostic: a failure tells you the pipeline is broken and nothing about where. Two practical notes:

  • Set the floor from a baseline, not from your best run. “Beats the majority class by 15 points” is a floor that survives; “at least 0.9187” is a floor that fails every time the seed changes.
  • It cannot catch a bug that makes the metric better. Leakage is the whole family of such bugs, which is why the structural test below exists.

4. Memorise a tiny batch

test_memorise.py
def test_can_memorise_small_batch(n=24):
    """A correct training path fits 24 rows almost perfectly. A broken one cannot."""
    small_X, small_y = X_train.iloc[:n], y_train[:n]
    model = train(small_X, small_y)
    assert accuracy_score(small_y, model.predict(small_X)) >= 0.95
test_memorise.py
def test_can_memorise_small_batch(n=24):
    """A correct training path fits 24 rows almost perfectly. A broken one cannot."""
    small_X, small_y = X_train.iloc[:n], y_train[:n]
    model = train(small_X, small_y)
    assert accuracy_score(small_y, model.predict(small_X)) >= 0.95

Caught 4 of 6, in 5.6 ms, and it is the only test that points at the training loop specifically: shuffled labels, permuted rows, a single class and a missing transform all make memorisation impossible. This is the ML equivalent of a smoke test, it is standard practice in deep learning (“overfit one batch first”), and it is under-used everywhere else.

5. Directional expectations

test_direction.py
def test_directional_expectation(model, X, feature, sign):
    """Pushing a feature in its known direction must raise the predicted probability."""
    base = model.predict_proba(X)[:, 1]
    nudged = X.copy()
    nudged[feature] = nudged[feature] + sign * 2 * X[feature].std()
    assert np.median(model.predict_proba(nudged)[:, 1] - base) > 0.01
test_direction.py
def test_directional_expectation(model, X, feature, sign):
    """Pushing a feature in its known direction must raise the predicted probability."""
    base = model.predict_proba(X)[:, 1]
    nudged = X.copy()
    nudged[feature] = nudged[feature] + sign * 2 * X[feature].std()
    assert np.median(model.predict_proba(nudged)[:, 1] - base) > 0.01

Caught 4 of 6. This is the test that encodes domain knowledge: more income raises approval, more failed logins raises fraud risk, more tenure lowers churn. Two cautions:

  • Assert a median increase, not “no decrease”. The weaker version passed for the shuffled-label model, whose coefficients were noise, because noise does not systematically decrease anything either.
  • A violated directional expectation is not automatically a bug — it may be a real interaction, as on the PDP page. Treat a failure as a question, not a verdict.

6. The structural test

test_structure.py
def test_all_preprocessing_inside_the_pipeline():
    """The returned object must be a Pipeline, so CV cannot leak through a transform."""
    model = train(X_train, y_train)
    assert isinstance(model, Pipeline)
    assert not isinstance(model[-1], TransformerMixin)      # last step predicts
test_structure.py
def test_all_preprocessing_inside_the_pipeline():
    """The returned object must be a Pipeline, so CV cannot leak through a transform."""
    model = train(X_train, y_train)
    assert isinstance(model, Pipeline)
    assert not isinstance(model[-1], TransformerMixin)      # last step predicts

Caught 4 of 6, including the leaky scaler that no behavioural test could see. This is the only test in the set that checks how the model was built rather than what it does, and it is the answer to the class of bug that improves your metrics. It generalises:

  • assert that every transformer is a pipeline step, so cross_val_scorecross_val_score refits it per fold
  • assert that the fitted feature names match the schema artefact
  • assert that no code path calls fitfit on data outside the training fold

Structural tests feel like cheating because they inspect the code rather than the results. They are the only defence against a bug whose symptom is a better number.

Arranging them so CI stays usable

diagram Diagram mermaid

Two rules make the difference between a suite people keep and one they delete:

Fast tests must not need a trained model. Contract, invariance, column-order and structural tests can all run against a model fitted on 24 rows, or a stub. If your test suite trains a real model, it will be moved to nightly, and then it will not run.

One test per bug class, not per metric. Seven assertions that all fail when accuracy drops are one test. The suite above is small because each entry catches something the others cannot: the matrix has no duplicate columns.

See it move

The matrix is a coverage problem, so it can be played with directly. Click a test to add or remove it from the suite; the sketch shows which of the six bugs the suite catches, which slip through, and what the suite costs. Every cell is the measured matrix from the figure above, not an estimate.

sketch Build a CI suite from the measured matrix p5.js
Seven tests that can be toggled on and off, with the six injected bugs shown as caught or escaped. The panel tracks coverage and total runtime, so you can find the cheapest suite that catches everything and see which single test is irreplaceable.

Two results fall out of the matrix as soon as you can toggle it. The cheapest suite that catches all six is metric floormetric floor + everything in a Pipelineeverything in a Pipeline, 20.6 ms — five bugs from the floor and the leaky scaler from the structural check. And everything in a Pipelineeverything in a Pipeline is irreplaceable: turn it off and the leaky scaler escapes every remaining test, however many you enable, because its symptom is a better number. The other five tests are redundant on this particular bug set — which is not an argument for deleting them, since each exists to catch a bug class this experiment did not inject, but it is an argument for knowing which of your tests are load-bearing and which are habits.

Pitfalls

PitfallWhy it bitesWhat to do
Only writing contract testsCaught 0 of 6 bugsAdd a metric floor and a memorisation test
A metric floor pinned to your best runFails on every seed changeSet it relative to a baseline
Assuming a metric floor catches leakageThe leaky scaler scored an identical 0.8500Structural assertions
Asserting “no decrease” in a directional testPassed for a model trained on shuffled labelsAssert a median increase of a real size
Demanding no exception on reordered columnsRewards the positional serving pathRaising is a pass
Tests that need a full training runThey get moved to nightly and then deletedFit on 24 rows in the fast tier
Testing metrics but never the training loopFour bugs live theretest_can_memorise_small_batchtest_can_memorise_small_batch
No test after an incidentThe same bug returnsEvery postmortem adds one assertion

Recap

  • Seven realistic bugs; accuracy from 0.8500 (leaky scaler, indistinguishable from correct) down to 0.4592.
  • The output contract and row-order invariance tests caught 0 of 6.
  • The metric floor caught 5 of 6 and localises nothing.
  • Memorising 24 rows caught 4 of 6 in 5.6 ms and points straight at the training loop.
  • Directional expectations caught 4 of 6 — with a median-increase assertion, not “no decrease”.
  • The structural test was the only one that caught the leaky scaler, because that bug does not change any metric.
  • Column-order safety caught the positional serving path, and only because raising counts as a pass.
quizCheck yourself
  1. Your ML test suite asserts output shapes, probability ranges and row-order invariance. What did those catch in the measured matrix?

    Show answer

    B — None of the six — every broken model produced well-formed probabilities and was order-invariant — They are contracts and regression guards, worth having and not evidence that the model works. The bugs that mattered were caught by the metric floor, the memorisation test, directional expectations and a structural assertion.

  2. Which single test would you add first to a suite that has none?

    Show answer

    B — A metric floor on a fixed seed and split — it caught 5 of the 6 bugs — It is the most sensitive single test available, at the cost of a full training run and no diagnostic value. Add the memorisation test second: 4 of 6 at a fraction of the cost, and it localises the fault to the training loop.

  3. A scaler fitted on all rows before the split gave test accuracy 0.8500 — identical to the correct pipeline. Which test catches it?

    Show answer

    B — A structural assertion that every transformer is a Pipeline step, so cross-validation refits it per fold — No behavioural test can catch a bug that does not change behaviour on this data — and leakage bugs often improve the metric, which makes floors useless against them. Structural tests are the defence against bugs whose symptom is a better number.

  4. Why must a directional-expectation test assert a median increase rather than 'no decrease'?

    Show answer

    B — Because 'no decrease' passed for a model trained on shuffled labels: its coefficients were noise, and noise does not systematically decrease anything either — The weak form is satisfied by any model that has learned nothing at all. Requiring a median increase of a specific size turns the assertion into a claim about the learned relationship.

  5. Your column-order test asserts that predicting on reordered columns does not raise. What is wrong with it?

    Show answer

    B — It rewards the bug: sklearn raising on reordered DataFrame columns is the desired behaviour, and the positional serving path that silently returns different predictions is what you want to fail — The correct assertion is 'either raise, or return identical predictions'. Written that way it caught the serving path that called to_numpy() and indexed positionally — the one bug in the matrix that no other test found.

🧪 Try It Yourself

Exercise 1 – Build the correct pipeline and two bugs

Exercise 2 – The tests that catch nothing

Exercise 3 – Memorise twenty-four rows

Exercise 4 – Find the batch size where the test breaks

Exercise 5 – The structural test

Next

Phase 11 - ML Engineering — the phase overview, with the four measurements that decide whether a model survives contact with a production codebase.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did