Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Mocking Filesystem, Subprocess, and HTTP Interactions with Pytest

Hello! Your scripts can now read repository files, run build commands, and call an authenticated API. The next step is making those scripts testable without depending on a particular repository, an installed compiler, or a live server. In this lesson, you’ll use pytest to replace each external interaction with a controlled result, then check both what your code returns and what it asked the dependency to do. Allow about 40–45 minutes to watch, read, and run the examples.


A test needs a boundary

Suppose a repository helper reads a build manifest, invokes CMake, and fetches account information. If a test launches the real build or contacts an API, a failure might mean your helper is wrong—or merely that the build environment or network is unavailable.

A mock replaces one interaction for the duration of a test. You choose its return value or the exception it raises. The rest of your function runs normally, so you can check how it handles success and failure. This is similar to supplying a controlled dependency to a C++ unit test, though Python can also temporarily replace the name your function uses to find that dependency.

First, watch the pytest test-file demonstration and the opening HTTP-mocking demonstration in Tech With Tim’s Please Learn How To Write Tests in Python… • Pytest Tutorial. The presenter uses the optional pytest-mock plugin for mocking; our examples use pytest plus Python’s standard-library unittest.mock instead, so you do not need that plugin.

Please Learn How To Write Tests in Python… • Pytest Tutorial

Watch how pytest discovers a test and reports an assertion failure, then how a substituted HTTP response lets application logic run without a network call.

Watch pytest basics for test names, assertions, and running a test file. Then watch mocking HTTP; focus on configuring a response and asserting which request was made, rather than copying the plugin-specific syntax.

In your existing isolated environment, ensure pytest and requests are installed, and record their resolved versions using the pinned-dependency approach from the first lesson. Run tests from the directory containing the two files below with python -m pytest.


Give each interaction a replaceable seam

Save the following as repo_probe.py. It keeps three small operations separate so each test can replace exactly the interaction it does not want to perform.

import json
from pathlib import Path
from subprocess import run

import requests


def read_manifest(path: Path) -> str:
    return path.read_text(encoding="utf-8")


def load_targets(path: Path) -> list[str]:
    data = json.loads(read_manifest(path))
    targets = data.get("targets") if isinstance(data, dict) else None

    if not isinstance(targets, list) or not all(
        isinstance(target, str) for target in targets
    ):
        raise ValueError("Expected a 'targets' list of strings")

    return targets


def compile_check(root: Path) -> tuple[int, str]:
    result = run(
        ["cmake", "--build", str(root / "build")],
        cwd=root,
        capture_output=True,
        text=True,
        timeout=20,
        check=False,
    )
    return result.returncode, result.stderr


def fetch_login(url: str, token: str) -> str:
    response = requests.get(
        url,
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
        },
        timeout=(3, 10),
        allow_redirects=False,
    )
    response.raise_for_status()

    if response.status_code != 200:
        raise ValueError(f"Expected HTTP 200, got {response.status_code}")

    data = response.json()
    if not isinstance(data, dict) or not isinstance(data.get("login"), str):
        raise ValueError("Expected a JSON object with a string 'login'")

    return data["login"]

read_manifest is deliberately thin. It gives tests of load_targets a local name to replace, without altering Path.read_text for other Python code. Notice also the import from subprocess import run: when compile_check executes, it looks up run in repo_probe, not in the test file.

The Python documentation’s unittest.mock — mock object library explains temporary replacement and the lookup rule.

unittest.mock — mock object library

Read the Python documentation to understand why a patch lasts only for its scope and why its target must match the name used by the code under test.

In “Quick Guide,” find the paragraph beginning “The object you specify will be replaced.” Read temporary patching through the example using a with statement. In “Where to patch,” begin at “The basic principle is that you patch where an object is looked up.” Read the lookup rule and the two import-style examples immediately following it. Apply that rule to repo_probe.run and repo_probe.read_manifest.


Mock a filesystem read, but use a temporary file when useful

Save the following tests as the start of test_repo_probe.py:

from pathlib import Path
import subprocess
from unittest.mock import Mock, patch

import pytest
import requests

import repo_probe


def test_load_targets_from_mocked_file():
    path = Path("/virtual/manifest.json")

    with patch(
        "repo_probe.read_manifest",
        return_value='{"targets": ["core", "cli"]}',
    ) as mock_read:
        assert repo_probe.load_targets(path) == ["core", "cli"]
        mock_read.assert_called_once_with(path)


def test_load_targets_propagates_missing_file():
    path = Path("/virtual/missing.json")

    with patch(
        "repo_probe.read_manifest",
        side_effect=FileNotFoundError("missing"),
    ):
        with pytest.raises(FileNotFoundError):
            repo_probe.load_targets(path)


def test_load_targets_rejects_bad_manifest(tmp_path):
    path = tmp_path / "manifest.json"
    path.write_text('{"targets": [42]}', encoding="utf-8")

    with pytest.raises(ValueError, match="targets"):
        repo_probe.load_targets(path)

The first test supplies file contents without opening any file. Its path can be nonexistent because read_manifest has been replaced. The second makes that same boundary raise an exception, checking that load_targets does not silently treat a missing file as an empty manifest. side_effect is the mock setting that causes the exception when called.

The third test makes a different trade-off: tmp_path is a pytest fixture providing a fresh temporary directory. This is a real, isolated file, not a mock. It lets you check pathlib reading and JSON validation together without touching a repository file. Prefer temporary files when real filesystem behavior matters; mock a read when you need to control its result precisely or avoid that interaction altogether.

Pytest’s How to monkeypatch/mock modules and environments shows both the motivation for replacing filesystem-dependent behavior and a small stand-in for an HTTP response. Its examples use monkeypatch.setattr, another way to make a temporary replacement; the patch context manager above serves the same immediate purpose.

How to monkeypatch/mock modules and environments - pytest documentation

Read the pytest documentation for the distinction between the code being tested and the external result supplied to it.

In “Monkeypatching functions,” start at “Consider a scenario where you are working with user directories.” Read filesystem replacement through the explanation that the patch is undone after the test. In “Monkeypatching returned objects: building mock classes,” start at “We need to mock” and read response stand-ins through the explanation after test_get_json. Notice that the fake response implements only what the application calls.


Simulate a compiler result, not a compiler

Add this test to test_repo_probe.py:

def test_compile_check_reports_build_failure():
    root = Path("/virtual/repo")
    completed = subprocess.CompletedProcess(
        args=["cmake", "--build", str(root / "build")],
        returncode=2,
        stdout="",
        stderr="fatal error: missing header\n",
    )

    with patch("repo_probe.run", return_value=completed) as mock_run:
        assert repo_probe.compile_check(root) == (
            2,
            "fatal error: missing header\n",
        )
        mock_run.assert_called_once_with(
            ["cmake", "--build", str(root / "build")],
            cwd=root,
            capture_output=True,
            text=True,
            timeout=20,
            check=False,
        )

CompletedProcess gives the mock a realistic subprocess result: exit code, standard output, and standard error. No CMake process starts. The return-value assertion checks that your helper preserves the failure information; the call assertion checks that it invoked the intended command with captured text output and a timeout. The latter matters for build-log tools: a test that checks only the returned string might miss a change that stops capturing diagnostics.

The patch target is repo_probe.run because that is where compile_check looks up the imported function. Patching a similarly named object elsewhere can leave the real command untouched.


Simulate HTTP success and failure separately

Complete test_repo_probe.py with these tests:

def test_fetch_login_sends_authenticated_request():
    response = Mock(spec=requests.Response)
    response.status_code = 200
    response.json.return_value = {"login": "demo-user"}
    url = "https://example.invalid/user"

    with patch("repo_probe.requests.get", return_value=response) as mock_get:
        assert repo_probe.fetch_login(url, "dummy-token") == "demo-user"
        mock_get.assert_called_once_with(
            url,
            headers={
                "Authorization": "Bearer dummy-token",
                "Accept": "application/json",
            },
            timeout=(3, 10),
            allow_redirects=False,
        )

    response.raise_for_status.assert_called_once_with()
    response.json.assert_called_once_with()


def test_fetch_login_does_not_parse_http_error():
    response = Mock(spec=requests.Response)
    response.status_code = 401
    response.raise_for_status.side_effect = requests.HTTPError(
        "401 Unauthorized", response=response
    )

    with patch("repo_probe.requests.get", return_value=response):
        with pytest.raises(requests.HTTPError):
            repo_probe.fetch_login("https://example.invalid/user", "bad-token")

    response.json.assert_not_called()


def test_fetch_login_rejects_unexpected_json():
    response = Mock(spec=requests.Response)
    response.status_code = 200
    response.json.return_value = {"error": "missing login"}

    with patch("repo_probe.requests.get", return_value=response):
        with pytest.raises(ValueError, match="login"):
            repo_probe.fetch_login("https://example.invalid/user", "dummy-token")


def test_fetch_login_propagates_timeout():
    with patch(
        "repo_probe.requests.get",
        side_effect=requests.Timeout("simulated timeout"),
    ):
        with pytest.raises(requests.Timeout):
            repo_probe.fetch_login("https://example.invalid/user", "dummy-token")

These four cases distinguish failures you encountered in the previous lesson. A successful response must yield a login; an HTTP error must be checked before JSON is treated as account data; an HTTP 200 can still have the wrong JSON shape; and a timeout means there was no response to parse. The .invalid domain and dummy tokens are intentional: the patched call never reaches the network.

Mock(spec=requests.Response) limits the stand-in to attributes and methods available on a Requests response, helping catch misspelled method names. Its raise_for_status method does nothing unless configured otherwise, which is appropriate for the success tests. For the 401 test, side_effect makes that method raise the HTTP error. Notice that an assertion such as response.json.assert_not_called() checks ordering of behavior, not just the final exception.

Run the file:

python -m pytest -q test_repo_probe.py

You should see eight passing tests. If a test fails, read pytest’s assertion diff before changing the mock: it may reveal a genuine mismatch in your command arguments, request options, or expected response handling. A passing mocked test establishes how your code behaves under the supplied conditions; it does not prove that CMake is installed, a real manifest has the expected contents, or an API is available. Those questions require separate integration checks.


Takeaways

Use pytest assertions to state the behavior you expect, and replace external interactions at the name your code actually looks up. A mock’s return_value models a result; its side_effect models a failure; call assertions verify how the dependency was used. For filesystem tests, choose between a mocked read and pytest’s real but isolated tmp_path according to what you need to verify.

This completes the Python foundations module. Next, you’ll shift from automation plumbing to the vectors and tensor shapes used to represent token data inside a language model.

Can't find a good explanation? Sign up and we'll make it for you

Sign up