{
    "type": "article",
    "title": "A 30-minute test suite in five minutes",
    "canonical": "https://www.dealmobile.co.uk/writing/a-30-minute-test-suite-in-five",
    "path": "/writing/a-30-minute-test-suite-in-five",
    "url": "https://www.dealmobile.co.uk/writing/a-30-minute-test-suite-in-five",
    "markdown": "https://www.dealmobile.co.uk/writing/a-30-minute-test-suite-in-five.md",
    "json": "https://www.dealmobile.co.uk/writing/a-30-minute-test-suite-in-five.json",
    "summary": "Cutting every test off from the real world let Mullet’s tests (about 17,700 when I wrote this) run in parallel, and flushed out a real Kimi call and a fixture deleting a real file.",
    "published": "2026-10-25T09:40:00+00:00",
    "modified": "2026-10-25T19:08:26+00:00",
    "image": {
        "url": "https://www.dealmobile.co.uk/sites/default/files/content/images/posts/a-30-minute-test-suite-in-five/hero.png",
        "alt": "Two bars: the old Mullet test run of about 30 minutes in series, and the new run of about 5 minutes across 8 workers."
    },
    "fields": {
        "image_light": null
    },
    "tags": [
        {
            "name": "Testing",
            "url": "https://www.dealmobile.co.uk/writing/tag/testing",
            "json": "https://www.dealmobile.co.uk/writing/tag/testing.json"
        },
        {
            "name": "Python",
            "url": "https://www.dealmobile.co.uk/writing/tag/python",
            "json": "https://www.dealmobile.co.uk/writing/tag/python.json"
        },
        {
            "name": "Mullet",
            "url": "https://www.dealmobile.co.uk/writing/tag/mullet",
            "json": "https://www.dealmobile.co.uk/writing/tag/mullet.json"
        }
    ],
    "projects": [
        {
            "name": "Mullet",
            "url": "https://www.dealmobile.co.uk/projects/mullet",
            "json": "https://www.dealmobile.co.uk/projects/mullet.json"
        }
    ],
    "body": {
        "html": "<p><a href=\"/projects/mullet\">Mullet</a>’s test suite used to take about 30 minutes. Now it is about 17,700 tests, run on 8 workers with pytest-xdist, in about 5 minutes. (By the end of that day it was about 18,000, and still about 5 minutes.)</p>\n<p>The speed-up is not the interesting part. Parallel runs only work when tests cannot affect each other or the machine they run on. Making that true was the real work, and it found things I did not know were wrong.</p>\n<h2 id=\"why-it-was-slow-and-why-that-mattered\">Why it was slow, and why that mattered</h2>\n<p>Thirty minutes means you do not run the suite before committing. You run the file you changed and hope. Mullet 2’s gates compare test results against a baseline, so a suite you cannot run quickly is a suite you cannot lean on. Five minutes is short enough to run before a commit that touches several modules.</p>\n<p>My working rule is still to run only the files I changed while I work, and the full suite before committing across modules. The command is:</p>\n<pre><code>python3 -m pytest tests -q -n 8 --timeout 300\n</code></pre>\n<h2 id=\"cutting-tests-off-from-the-real-world\">Cutting tests off from the real world</h2>\n<p>Parallel tests need isolation, and isolation has four parts here. Every test gets:</p>\n<ul>\n<li>No network beyond localhost. A guard on <code>socket.connect</code> fails any test that tries to reach out.</li>\n<li>Provider keys removed from the environment.</li>\n<li>A throwaway <code>HOME</code>.</li>\n<li>Mock providers. Tests that need a real model are marked <code>live</code> and excluded by default.</li>\n</ul>\n<p>The guard is blunt on purpose. It does not try to guess which calls are harmless. A test that connects to anything but localhost fails and tells you where.</p>\n<p>The throwaway <code>HOME</code> matters as much as the network guard. Anything that writes to <code>~</code> writes to a temporary directory that is deleted afterwards. If a test used to write to my real home, it now cannot.</p>\n<h2 id=\"what-the-isolation-found\">What the isolation found</h2>\n<p>I expected a few failures. I did not expect these.</p>\n<p>A test had been calling the real Kimi API. A test that makes a real call passes when the real service answers. With the network guard and no keys, it failed.</p>\n<p>An old fixture deleted my real learned-constraints file. Under a throwaway <code>HOME</code>, the path it deleted no longer resolved to my file. I might never have found that by reading the suite, because a fixture that deletes something is easy to skim past.</p>\n<p>Some tests were writing to my real HUD, the status bar Mullet draws in Claude Code. That explains something that had puzzled me for a while: I kept seeing “Kimi agents” in my status bar when nothing was delegating. The tests had been drawing them.</p>\n<p>All three were the same fault. The tests were reaching out of their own sandbox, and nothing had stopped them. They only showed up when something did.</p>\n<h2 id=\"the-same-idea-in-the-product\">The same idea in the product</h2>\n<p>This is the same principle as the sandbox around the worker, described in <a href=\"/writing/sandboxing-an-ai-coding-worker\">Sandboxing an AI coding worker</a>. Code you did not look at closely should not be able to reach things you did not intend. For the tests, the “worker” was mine.</p>\n<p>It also fits the gates in <a href=\"/writing/gates-not-a-models-word\">Gates, not a model’s word</a>. A G5 comparison against a baseline is only worth anything if the baseline is repeatable. A suite that depends on the network, the clock or the contents of my home directory is not.</p>\n<h2 id=\"what-i-did-not-do\">What I did not do</h2>\n<p>I also have not tried to make the suite faster than about 5 minutes. It is fast enough for now.</p>\n<h2 id=\"what-changed-in-how-i-work\">What changed in how I work</h2>\n<p>Before, a green full run was an event. Now it is a step I take before a commit that touches several modules. The run is cut off from the network, the keys and my home directory, so a pass on my laptop means the same as a pass anywhere else. That was not true when some tests quietly used my real files and my real provider.</p>\n<h2 id=\"doing-the-same-to-yours\">Doing the same to yours</h2>\n<p>If you have a suite you do not trust to run in parallel, the order I would try is:</p>\n<ol>\n<li>Put a guard on <code>socket.connect</code> that fails anything outside localhost.</li>\n<li>Strip provider keys and credentials from the test environment.</li>\n<li>Point <code>HOME</code> at a temporary directory.</li>\n<li>Run it. Read every failure, because each one is a test reaching somewhere it should not.</li>\n<li>Then add <code>-n</code> workers.</li>\n</ol>\n<p>Do the fourth step before the fifth. Parallelism will make the failures harder to read.</p>\n<p>The code is at <a href=\"https://github.com/tomgould/mullet-public\">https://github.com/tomgould/mullet-public</a>.</p>"
    },
    "markdown_text": "---\ntitle: 'A 30-minute test suite in five minutes'\ncanonical: 'https://www.dealmobile.co.uk/writing/a-30-minute-test-suite-in-five'\nurl: 'https://www.dealmobile.co.uk/writing/a-30-minute-test-suite-in-five'\ntype: Post\npublished: '2026-10-25'\nsummary: 'Cutting every test off from the real world let Mullet’s tests (about 17,700 when I wrote this) run in parallel, and flushed out a real Kimi call and a fixture deleting a real file.'\n---\n\n# A 30-minute test suite in five minutes\n\nPublished 2026-10-25\n\n![Two bars: the old Mullet test run of about 30 minutes in series, and the new run of about 5 minutes across 8 workers.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/a-30-minute-test-suite-in-five/hero.png.webp?itok=nyzj1WXT)\n\n[Mullet](https://www.dealmobile.co.uk/projects/mullet.md)’s test suite used to take about 30 minutes. Now it is about 17,700 tests, run on 8 workers with pytest-xdist, in about 5 minutes. (By the end of that day it was about 18,000, and still about 5 minutes.)\n\nThe speed-up is not the interesting part. Parallel runs only work when tests cannot affect each other or the machine they run on. Making that true was the real work, and it found things I did not know were wrong.\n\n## Why it was slow, and why that mattered\n\nThirty minutes means you do not run the suite before committing. You run the file you changed and hope. Mullet 2’s gates compare test results against a baseline, so a suite you cannot run quickly is a suite you cannot lean on. Five minutes is short enough to run before a commit that touches several modules.\n\nMy working rule is still to run only the files I changed while I work, and the full suite before committing across modules. The command is:\n\n```\npython3 -m pytest tests -q -n 8 --timeout 300\n\n```\n\n## Cutting tests off from the real world\n\nParallel tests need isolation, and isolation has four parts here. Every test gets:\n\n- No network beyond localhost. A guard on `socket.connect` fails any test that tries to reach out.\n- Provider keys removed from the environment.\n- A throwaway `HOME`.\n- Mock providers. Tests that need a real model are marked `live` and excluded by default.\n\nThe guard is blunt on purpose. It does not try to guess which calls are harmless. A test that connects to anything but localhost fails and tells you where.\n\nThe throwaway `HOME` matters as much as the network guard. Anything that writes to `~` writes to a temporary directory that is deleted afterwards. If a test used to write to my real home, it now cannot.\n\n## What the isolation found\n\nI expected a few failures. I did not expect these.\n\nA test had been calling the real Kimi API. A test that makes a real call passes when the real service answers. With the network guard and no keys, it failed.\n\nAn old fixture deleted my real learned-constraints file. Under a throwaway `HOME`, the path it deleted no longer resolved to my file. I might never have found that by reading the suite, because a fixture that deletes something is easy to skim past.\n\nSome tests were writing to my real HUD, the status bar Mullet draws in Claude Code. That explains something that had puzzled me for a while: I kept seeing “Kimi agents” in my status bar when nothing was delegating. The tests had been drawing them.\n\nAll three were the same fault. The tests were reaching out of their own sandbox, and nothing had stopped them. They only showed up when something did.\n\n## The same idea in the product\n\nThis is the same principle as the sandbox around the worker, described in [Sandboxing an AI coding worker](https://www.dealmobile.co.uk/writing/sandboxing-an-ai-coding-worker.md). Code you did not look at closely should not be able to reach things you did not intend. For the tests, the “worker” was mine.\n\nIt also fits the gates in [Gates, not a model’s word](https://www.dealmobile.co.uk/writing/gates-not-a-models-word.md). A G5 comparison against a baseline is only worth anything if the baseline is repeatable. A suite that depends on the network, the clock or the contents of my home directory is not.\n\n## What I did not do\n\nI also have not tried to make the suite faster than about 5 minutes. It is fast enough for now.\n\n## What changed in how I work\n\nBefore, a green full run was an event. Now it is a step I take before a commit that touches several modules. The run is cut off from the network, the keys and my home directory, so a pass on my laptop means the same as a pass anywhere else. That was not true when some tests quietly used my real files and my real provider.\n\n## Doing the same to yours\n\nIf you have a suite you do not trust to run in parallel, the order I would try is:\n\n1. Put a guard on `socket.connect` that fails anything outside localhost.\n2. Strip provider keys and credentials from the test environment.\n3. Point `HOME` at a temporary directory.\n4. Run it. Read every failure, because each one is a test reaching somewhere it should not.\n5. Then add `-n` workers.\n\nDo the fourth step before the fifth. Parallelism will make the failures harder to read.\n\nThe code is at <https://github.com/tomgould/mullet-public>.\n\n- **Projects:** [Mullet](https://www.dealmobile.co.uk/projects/mullet.md)\n- **Tags:** [Testing](https://www.dealmobile.co.uk/writing/tag/testing.md), [Python](https://www.dealmobile.co.uk/writing/tag/python.md), [Mullet](https://www.dealmobile.co.uk/writing/tag/mullet.md)\n\n---\n\nIndex of this site: [llms.txt](https://www.dealmobile.co.uk/llms.txt)\n"
}