Make CI reject a missing regression test

Require exact discovered test IDs before running the same suite, so CI rejects an omitted regression even when ordinary discovery passes.

OpenThunder Editorial · 2026-11-05

For example, a one-unit price test passes while discovery omits the three-unit regression that would expose a bug. For a small set of critical regressions, require their exact discovered test IDs before running that same suite.

A green exit and a stable test count cannot establish that those particular tests were included.

Build a fixture that hides a failing regression

This fixture uses only Python’s standard library. Create a new, isolated directory:

mkdir -p ci-discovery-demo/tests
cd ci-discovery-demo
python3 -c "from pathlib import Path; Path('tests/__init__.py').touch()"

Add the following files. Leave tests/__init__.py empty so that tests is an importable package with stable module-qualified test IDs.

ci-discovery-demo/
├── app.py
├── check_tests.py
└── tests/
    ├── __init__.py
    ├── test_smoke.py
    └── regression_case.py

In app.py, deliberately ignore the quantity:

def total_cents(unit_cents, quantity):
    return unit_cents

In tests/test_smoke.py, exercise the case that happens to work:

import unittest

from app import total_cents


class SmokeTests(unittest.TestCase):
    def test_one_unit(self):
        self.assertEqual(total_cents(125, 1), 125)

In tests/regression_case.py, express the intended behavior for multiple units:

import unittest

from app import total_cents


class TotalTests(unittest.TestCase):
    def test_multiple_units(self):
        self.assertEqual(total_cents(125, 3), 375)

The mismatch is the filename. regression_case.py does not match the explicit test_*.py pattern used below.

Run ordinary discovery from the fixture root:

python3 -m unittest discover -s tests -t . -p 'test_*.py' -v

Expected outcome: only test_one_unit runs, and the command exits with status 0. The multiple-unit assertion would fail if executed, but this command never discovers it.

A test file can exist without participating in CI.

Check required IDs, then execute that same suite

A unittest test ID includes its module, class, and method. Once the regression file has its intended discoverable name, the ID will be:

tests.test_regression.TotalTests.test_multiple_units

Save this guard as check_tests.py:

from pathlib import Path
import sys
import unittest


ROOT = Path(__file__).resolve().parent
REQUIRED_IDS = {
    "tests.test_regression.TotalTests.test_multiple_units",
}


def iter_cases(suite):
    for item in suite:
        if isinstance(item, unittest.TestSuite):
            yield from iter_cases(item)
        else:
            yield item


def main():
    loader = unittest.TestLoader()
    suite = loader.discover(
        start_dir=str(ROOT / "tests"),
        pattern="test_*.py",
        top_level_dir=str(ROOT),
    )

    if loader.errors:
        print("Test loading failed:", file=sys.stderr)
        for error in loader.errors:
            print(error, file=sys.stderr)
        return 2

    discovered_ids = {case.id() for case in iter_cases(suite)}
    missing = REQUIRED_IDS - discovered_ids

    if missing:
        print("Required tests were not discovered:", file=sys.stderr)
        for test_id in sorted(missing):
            print(f"  {test_id}", file=sys.stderr)
        return 2

    result = unittest.TextTestRunner(verbosity=2).run(suite)
    return 0 if result.wasSuccessful() else 1


if __name__ == "__main__":
    sys.exit(main())

The guard discovers one suite, inspects it, then executes that same suite. This avoids checking one selection and accidentally running another. It also rejects test-loading errors rather than accepting an incomplete load.

“Before execution” means before test methods run. Python discovery imports test modules, so this is not an isolation boundary for import-time code.

Run the guard while the regression still has the wrong filename:

python3 check_tests.py

Then rename the regression file and run it again:

mv tests/regression_case.py tests/test_regression.py
python3 check_tests.py

Finally, correct app.py:

def total_cents(unit_cents, quantity):
    return unit_cents * quantity

Run the guard once more:

python3 check_tests.py

These steps distinguish four states:

Fixture state Command Expected result
Regression filename is wrong Ordinary discovery Smoke test passes; exit 0
Regression filename is wrong Guarded discovery Required ID missing; exit 2 before test execution
Filename is corrected, function remains broken Guarded discovery Regression returns 125 rather than 375; assertion fails; exit 1
Filename and function are corrected Guarded discovery Both tests pass; exit 0

The assertion failure after the rename is useful: it confirms that the intended regression participates in the command CI will run.

Run the failure cases individually in your terminal. This sequence demonstrates the guard; it is not a sequence for the CI job to repeat. In CI, replace the ordinary discovery command with python3 check_tests.py. Retain that single guarded test command, and make its nonzero result stop the job rather than continuing to a later successful command.

Counts, identities, and exit codes protect different things

If one required test disappears while an unrelated test is added, the count stays unchanged.

A stable count can hide a changed suite.

Use each check for the property it establishes:

Check What it establishes What it misses
Minimum discovered count The suite is not smaller than a chosen floor A particular regression can still be absent
Required test IDs Named regression cases were discovered Those cases can still be skipped or contain weak assertions
Runner exit-code propagation Test failures reach the CI job Undiscovered tests never produce failures

A required test marked as skipped still has an ID. If execution is mandatory, add a separate policy for skipped required tests.

Also verify that your CI wrapper preserves the Python process’s nonzero status. A correct guard cannot help if a surrounding script discards its failure.

Keep the discovery contract small and reviewable

I would require IDs for explicitly protected regressions rather than immediately pinning every test in a large suite. Pinning every ID creates maintenance work whenever tests are legitimately renamed or reorganized.

Treat changes to the required set as changes to the protection itself. A rename should update the file and the contract together, after confirming that the same behavior remains covered. Removing an entry from REQUIRED_IDS deserves explicit review: the guard cannot protect a regression if the same change silently deletes its requirement.

A five-minute discovery check

For an existing unittest case, run verbose discovery with the same start directory, top-level directory, and filename pattern as CI. In this fixture, that is:

python3 -m unittest discover -s tests -t . -p 'test_*.py' -v

Read the module, class, and method from the verbose output, then record the exact ID in module.Class.method form. If the case is absent, resolve discovery before adding its ID to the guard. Match the guard’s discovery settings to the CI selection you just checked.

Use this discovery checklist on one important regression: