{"slug": "pytest-in-practice", "title": "Pytest in Practice", "summary": "A developer learning Python by building a video clipping CLI application shared practical lessons on pytest, covering test discovery conventions, the use of Python's built-in assert with pytest's assertion rewriting, and pytest.approx for floating-point comparisons. The writeup illustrates these concepts with examples from the CLI's FFmpeg, video metadata, audio extraction, and transcription code, including golden-output regression tests.", "body_md": "I've been learning Python by building a video clipping CLI application, and along the way I've ended up learning a lot more than I expected.\n\nSome of it was straightforward. Some of it made me stop and think, especially when I first started writing tests.\n\nThings like:\n\n`conftest.py`?` patch` and `monkeypatch`?\nThese are the kinds of things that can feel like arbitrary rules when you're first encountering them.\n\nOnce I understood the reasoning behind them, though, pytest started making a lot more sense.\n\nSo this isn't meant to be a complete pytest reference or an explanation of every feature pytest has. It's more like a collection of the things that finally \"clicked\" for me while testing a real Python project.\n\nThe examples are based on my video clipping CLI, so you'll see things like FFmpeg, video metadata, audio extraction, transcription, temporary files, external dependencies, and slow tests.\n\nHopefully, if you're also learning Python by building something rather than just following tutorials, some of these lessons save you a few of the \"why does it work like *that*?\" moments I had.\n\nIf you're using `uv`, installing pytest and coverage support is straightforward:\n\n```\nuv add --dev pytest pytest-cov\nuv run pytest --version\n```\n\nYou now have everything needed to start writing and running tests.\n\nA pytest test can be nothing more than a function:\n\n``` python\n# tests/core/test_video_inspector.py\n\nfrom clipper.core.video_inspector import get_video_metadata\n\ndef test_get_video_metadata_returns_none_on_missing_file():\n    result = get_video_metadata(\"does_not_exist.mp4\")\n    assert result is None\n```\n\nRun it with:\n\n```\nuv run pytest\n```\n\nThat's it.\n\nThere is no base class, no registration, and no decorator required.\n\npytest finds the test because:\n\n`test_`\nThat naming convention is the mechanism.\n\npytest automatically walks the current directory and its subdirectories looking for test files.\n\nBy default, it looks for files matching patterns such as:\n\n```\ntest_*.py\n*_test.py\n```\n\nInside those files, it looks for functions matching:\n\n```\ntest_*\n```\n\nand, inside classes whose names start with `Test*`, methods that also match `test_*`.\n\nSo this:\n\n```\ntests/\n└── core/\n    └── test_video_inspector.py\n```\n\nis enough for pytest to discover the file automatically. You don't need to register the test anywhere.\n\nThis is also why commands such as:\n\n```\nuv run pytest --cov=src\n```\n\nwork without explicitly listing every test file. pytest's discovery mechanism does the work.\n\n`assert`\nOne of the nicest things about pytest is that you use Python's built-in `assert`.\n\n``` python\ndef test_fps_math():\n    assert round(29.970029970029969, 2) == 29.97\n```\n\nYou don't need a collection of methods like:\n\n```\nassertEqual(...)\nassertTrue(...)\nassertFalse(...)\nassertIn(...)\n```\n\nInstead, pytest rewrites `assert` statements when running tests so that failures contain useful information about what each side evaluated to. That makes a failed assertion much more informative than simply:\n\n```\nAssertion failed\n```\n\n`pytest.approx`\nDon't compare floating-point calculations using exact equality.\n\nInstead:\n\n``` python\nimport pytest\n\ndef test_duration_seconds():\n    assert 125.5 == pytest.approx(float(\"125.500001\"))\n```\n\nFloating-point calculations rarely land exactly where you expect. This matters especially when dealing with things like frame rates, durations, timestamps, or calculations involving `Fraction`.\n\nWithout `pytest.approx`, you might end up writing this everywhere:\n\n```\nassert abs(actual - expected) < 0.001\n```\n\n`pytest.approx` gives you the tolerance without repeating that boilerplate.\n\n`pytest.approx` isn't limited to individual numbers. You can use it when comparing nested structures:\n\n``` python\ndef test_transcribe_matches_golden_output(\n    sample_speech_path,\n    sample_transcript,\n):\n    result = transcribe(str(sample_speech_path))\n\n    assert result == pytest.approx(sample_transcript)\n```\n\nThis is particularly useful for regression or \"golden file\" tests. Imagine a transcript like:\n\n```\n{\n    \"segments\": [\n        {\n            \"start\": 0.123456,\n            \"end\": 1.987654,\n            \"words\": [\n                {\"word\": \"hello\", \"start\": 0.123},\n                {\"word\": \"world\", \"start\": 0.456},\n            ],\n        }\n    ]\n}\n```\n\nYou don't want to write a loop comparing every timestamp manually. `pytest.approx` can recursively compare numeric values with tolerance while still using normal equality for strings and other values — so it's practical to compare an entire expected transcript while allowing for harmless floating-point differences.\n\nWhen the expected behavior is for a function to raise an exception, use `pytest.raises`.\n\n``` python\nimport pytest\n\ndef test_extract_audio_raises_on_bad_codec():\n    with pytest.raises(ffmpeg.Error):\n        extract_audio(\n            \"in.mp4\",\n            \"out.wav\",\n            codec=\"not_a_real_codec\",\n        )\n```\n\nThis test verifies two things:\n\nThe test fails if the function doesn't raise anything. It also fails if it raises a different exception type. Both outcomes are useful information.\n\n`pytest.raises` Only Works When the Exception Escapes\nThere's an important distinction here. Suppose your function catches its own exception:\n\n``` python\ndef extract_audio(...):\n    try:\n        ...\n    except ffmpeg.Error:\n        logger.error(...)\n        return None\n```\n\nThe exception never reaches the caller. Therefore, this won't work:\n\n```\nwith pytest.raises(ffmpeg.Error):\n    extract_audio(...)\n```\n\nThere's nothing left for `pytest.raises` to catch. Instead, test the function's actual contract:\n\n```\nresult = extract_audio(...)\n\nassert result is None\n```\n\nAnd, if logging is part of the behavior you care about, use `caplog` to verify that the expected error was logged.\n\nA useful rule of thumb:\n\nIf the function returns `None` from its `except` block, test the return value rather than expecting an exception.\n\nSuppose you need to test several timestamp formats. You could write three tests:\n\n``` python\ndef test_timestamp_one():\n    ...\n\ndef test_timestamp_two():\n    ...\n\ndef test_timestamp_three():\n    ...\n```\n\nBut the test logic is identical. That's exactly what `pytest.mark.parametrize` is for:\n\n```\n@pytest.mark.parametrize(\n    \"timestamp,expected_seconds\",\n    [\n        (\"00:00:01\", 1.0),\n        (\"00:01:00\", 60.0),\n        (\"01:00:00\", 3600.0),\n    ],\n)\ndef test_validate_timestamp_parses_valid_formats(\n    timestamp,\n    expected_seconds,\n):\n    assert validate_timestamp(timestamp) == pytest.approx(expected_seconds)\n```\n\nThink of it like a form letter: the first argument names the blanks to fill in (`timestamp`, `expected_seconds`), and the second argument is a stack of filled-in versions. pytest prints one out and runs the test for each one:\n\n```\ntest_validate_timestamp_parses_valid_formats[00:00:01-1.0]\ntest_validate_timestamp_parses_valid_formats[00:01:00-60.0]\ntest_validate_timestamp_parses_valid_formats[01:00:00-3600.0]\n```\n\nEach case can pass or fail independently.\n\nYou could achieve something similar with a loop:\n\n``` python\ndef test_timestamps():\n    for timestamp, expected in cases:\n        assert validate_timestamp(timestamp) == expected\n```\n\nBut there's a major difference: a normal loop stops at the first failure. With parametrization, pytest runs every case. So one test run tells you exactly which inputs are broken instead of merely telling you:\n\n```\ntest_timestamps failed\n```\n\nThe signal is simple:\n\nIf you're about to write several nearly identical test functions that differ only in their input values, consider `parametrize`.\n\nIt works just as well for valid inputs as it does for malformed or invalid ones.\n\npytest supports test classes:\n\n``` python\nclass TestGetVideoMetadata:\n    def test_returns_none_for_missing_file(self):\n        assert get_video_metadata(\"missing.mp4\") is None\n\n    def test_has_audio_true_when_audio_stream_present(self):\n        ...\n```\n\nBut classes aren't mandatory. Use them when they provide something useful, such as:\n\nDon't use a class purely because it feels more organized. A well-named test file already provides plenty of organization.\n\nEach test method gets a fresh instance of the class. So this:\n\n``` python\nclass TestSomething:\n    def test_one(self):\n        self.value = 1\n\n    def test_two(self):\n        assert self.value == 1\n```\n\nis not valid. `test_two` doesn't receive the instance used by `test_one`. That's intentional — tests should be isolated from one another. Depending on mutable state from another test creates order-dependent and difficult-to-debug failures.\n\nFixtures are one of pytest's most useful features. A fixture is a function that provides something a test needs — think of it like an ingredient a recipe asks for by name. You don't prep it inside the recipe itself; you just say \"I need `sample_video_path`\" and pytest hands it to you already made.\n\n``` python\n# tests/conftest.py\n\nimport pytest\nfrom pathlib import Path\n\n@pytest.fixture\ndef sample_video_path() -> Path:\n    return Path(__file__).parent / \"fixtures\" / \"sample.mp4\"\n```\n\nA test can then request it simply by putting the fixture name in its parameters:\n\n``` python\n# tests/core/test_video_inspector.py\n\ndef test_metadata_against_real_file(sample_video_path):\n    result = get_video_metadata(str(sample_video_path))\n\n    assert result is not None\n```\n\npytest sees `sample_video_path` in the test signature, finds the fixture with that name, runs it, and passes the result to the test. No import is required.\n\n`conftest.py` is automatically discovered by pytest. Fixtures defined there are available to test files in the same directory and its subdirectories — it works like a shared pantry that any test file in that part of the tree can pull from without asking for a delivery.\n\nFor example:\n\n```\ntests/\n├── conftest.py\n├── core/\n│   └── test_video_inspector.py\n└── api/\n    └── test_transcription.py\n```\n\nA fixture defined in `tests/conftest.py` is available to both `tests/core/` and `tests/api/`.\n\nYou can also have a more specific `conftest.py` deeper in the tree:\n\n```\ntests/\n├── conftest.py\n└── core/\n    ├── conftest.py\n    └── test_video_inspector.py\n```\n\nThe deeper fixture configuration can then be used specifically by that subtree. A useful convention is: root `conftest.py` for shared fixtures, nested `conftest.py` files for fixtures that are genuinely local to a particular test area.\n\nFixtures don't necessarily have to run for every test. You can control their lifetime:\n\n```\n@pytest.fixture(scope=\"function\")  # default\n@pytest.fixture(scope=\"module\")    # once per test file\n@pytest.fixture(scope=\"session\")   # once per entire test run\n```\n\nThe default is `scope=\"function\"`, which means a fresh fixture is created for every test. Prefer the default unless there is a good reason to reuse the fixture.\n\nA broader scope can make sense when setup is genuinely expensive and safe to share. For example, generating a synthetic sample video once per session may be much cheaper than generating it once for every test.\n\npytest provides several useful fixtures out of the box.\n\n`tmp_path`\nProvides a unique temporary directory for each test. Great for anything that needs to write files.\n\n`caplog`\nCaptures logging output. Useful when you want to verify that something logged an error or warning.\n\n`capsys`\nCaptures stdout and stderr. Useful for testing CLI output.\n\nTemporarily changes attributes, environment variables, dictionary entries, and other values. pytest automatically restores the changes after the test.\n\nYou can inspect available fixtures with:\n\n```\nuv run pytest --fixtures\n```\n\nSuppose you want to verify that audio extraction creates an actual file:\n\n``` python\ndef test_extract_audio_writes_real_file(\n    sample_video_path,\n    tmp_path,\n):\n    output_path = tmp_path / \"output.wav\"\n\n    result = extract_audio(\n        str(sample_video_path),\n        str(output_path),\n    )\n\n    assert result == str(output_path)\n    assert output_path.exists()\n```\n\nThis is an integration-style test. It actually runs FFmpeg and checks that a real file was created. But because `tmp_path` is temporary, the test doesn't leave generated files scattered around your project — pytest creates the directory and handles its cleanup.\n\nPython gives you `unittest.mock`. pytest gives you `monkeypatch`. They overlap, but each one shines in a different situation.\n\n`unittest.mock.patch`\nUse `patch` when you want a more capable mock, particularly when you need to inspect how something was called.\n\n``` python\nfrom unittest.mock import patch\n\ndef test_metadata_no_audio():\n    fake_probe = {\n        \"format\": {\n            \"filename\": \"silent.mp4\",\n            \"duration\": \"10.0\",\n        },\n        \"streams\": [\n            {\n                \"codec_type\": \"video\",\n                \"r_frame_rate\": \"25/1\",\n            },\n        ],\n    }\n\n    with patch(\n        \"clipper.core.video_inspector.ffmpeg.probe\",\n        return_value=fake_probe,\n    ):\n        result = get_video_metadata(\"silent.mp4\")\n\n    assert result[\"has_audio\"] is False\n```\n\n`patch` temporarily replaces the target with a mock. It is particularly useful when you want to assert things like:\n\n```\nmock_input.assert_called_once_with(\"in.mp4\")\n```\n\n`monkeypatch` is pytest's own fixture. It's particularly convenient for simple temporary substitutions.\n\n``` python\ndef test_uses_default_bitrate_env_var(monkeypatch):\n    monkeypatch.setenv(\n        \"CLIPPER_DEFAULT_BITRATE\",\n        \"256k\",\n    )\n\n    assert get_default_bitrate() == \"256k\"\n```\n\nThere's no context manager and no manual cleanup — pytest automatically restores the environment variable after the test. You can also replace attributes with `monkeypatch.setattr(...)` or dictionary entries with `monkeypatch.setitem(...)`.\n\nImagine your code is a toy robot and you want to test what happens when one of its components behaves differently. You don't want to actually break the component — you just want to temporarily pretend that it behaves differently.\n\n`monkeypatch` is like putting a temporary sticky note over the component: \"For this test, pretend this value is different.\" pytest removes the note when the test finishes.\n\n`patch` is another version of the same idea, but with a much more powerful mock object behind it. `patch` can easily track call count, arguments, return values, exceptions, and chained calls.\n\nBoth clean themselves up reliably when used correctly. With `patch`, the common form in this project is:\n\n```\nwith patch(...):\n    ...\n```\n\nThe mock is restored automatically when the block ends, even if the test fails.\n\nA practical rule:\n\n| Need | Use | \n|---|---|\n| Assert how something was called | `patch` | \n| Check call count or exact arguments | `patch` | \n| Mock a chained/fluent API | `patch` | \n| Replace an environment variable | `monkeypatch` | \n| Temporarily change one attribute | `monkeypatch.setattr(...)` | \n| Temporarily change a dictionary entry | `monkeypatch.setitem(...)` | \n| Replace an external dependency and inspect the mock | `patch` | \n\nThe important thing isn't to memorize a strict rule. It's to recognize the distinction:\n\nIf you need to inspect how the dependency was used, `patch` is usually the natural choice. If you just need to temporarily replace a value or attribute, `monkeypatch` is often simpler.\n\n`side_effect`: Make a Mock Fail\nMocks aren't only useful for returning fake successful results. They can also simulate failures.\n\n`return_value` means \"always return this.\" `side_effect` means \"raise this exception instead.\"\n\n```\nwith patch(\n    \"clipper.core.video_inspector.ffmpeg.probe\",\n    side_effect=ffmpeg.Error(\n        cmd=\"ffprobe\",\n        stdout=b\"\",\n        stderr=b\"bad file\",\n    ),\n):\n    result = get_video_metadata(\"bad.mp4\")\n```\n\nThis lets you exercise failure paths without needing to create a genuinely broken video file.\n\nThis is probably the most important mocking rule in the entire guide:\n\nMock the boundary, not the logic you're trying to test.\n\nSuppose your code does this:\n\n```\nprobe = ffmpeg.probe(path)\n```\n\nThe interesting logic is what your application does with the result. So mock `ffmpeg.probe` and provide realistic fake data — don't mock the parsing logic itself. A test that mocks everything, including the thing you're trying to test, can end up proving nothing more than: \"my mocks returned what I told them to return.\"\n\nPut another way — you're testing your recipe, not reinventing the oven. External boundaries are good mocking candidates:\n\nYour own business logic is usually what you want to execute for real.\n\nThis catches a lot of people. Suppose your module contains:\n\n``` python\n# video_inspector.py\n\nimport ffmpeg\n\ndef get_video_metadata(path):\n    return ffmpeg.probe(path)\n```\n\nYou might think you should patch `ffmpeg.probe`. But that's not necessarily the correct target — the code under test looks up `ffmpeg` in its own module namespace. Therefore, patch where the name is looked up:\n\n```\npatch(\n    \"clipper.core.video_inspector.ffmpeg.probe\"\n)\n```\n\nThe general rule is:\n\nPatch where the object is looked up, not where it was originally defined.\n\nIf you patch the wrong path, the real dependency may still run. That can make a test unexpectedly hit FFmpeg, the network, a database, or some other external system.\n\nSome APIs aren't a single function call. For example, `ffmpeg-python` can use a fluent chain:\n\n```\nffmpeg.input(...).output(...).overwrite_output().run(...)\n```\n\nMocking that requires walking the chain. `MagicMock` automatically creates mocks for attributes you access, so `mock_input.return_value` represents the result of `ffmpeg.input(...)`, `.output.return_value` represents the next link in the chain, and so on until you reach `.run` — the actual operation you want to make fail.\n\n``` python\ndef test_extract_audio_sad_path(caplog):\n    fake_error = ffmpeg.Error(\n        cmd=\"ffmpeg\",\n        stdout=b\"\",\n        stderr=b\"encoding failed\",\n    )\n\n    with patch(\n        \"clipper.core.video_inspector.ffmpeg.input\"\n    ) as mock_input:\n        mock_run = (\n            mock_input\n            .return_value\n            .output\n            .return_value\n            .overwrite_output\n            .return_value\n            .run\n        )\n        mock_run.side_effect = fake_error\n\n        result = extract_audio(\n            \"in.mp4\",\n            \"out.wav\",\n        )\n\n    assert result is None\n    assert \"error\" in caplog.text.lower()\n```\n\nWalking the chain into its own variable (`mock_run`) before setting `side_effect` keeps the assignment itself simple and unambiguous — trying to set an attribute in the middle of a long parenthesized chain is a common source of syntax mistakes.\n\nA simple and predictable test layout goes a long way. If your source looks like:\n\n```\nsrc/\n└── clipper/\n    └── core/\n        └── video_inspector.py\n```\n\nmirror it under `tests/`:\n\n```\ntests/\n└── core/\n    └── test_video_inspector.py\n```\n\nThis makes it immediately obvious where the tests for a source module live.\n\n`tests/` Outside `src/`\nUse:\n\n```\nproject/\n├── src/\n├── tests/\n└── pyproject.toml\n```\n\nrather than:\n\n```\nsrc/\n├── clipper/\n└── tests/\n```\n\nKeeping tests outside the package helps avoid accidentally shipping them with your built distribution and keeps the distinction between application code and test code clear.\n\n`__init__.py`\npytest doesn't require your test directories to be Python packages. So this is perfectly fine:\n\n```\ntests/\n├── core/\n│   └── test_video_inspector.py\n└── api/\n    └── test_transcription.py\n```\n\nNo `__init__.py` required. There can be edge cases where you intentionally want package semantics, but don't add `__init__.py` to test directories just because you think pytest requires it. It doesn't.\n\n```\ntests/\n├── conftest.py\n├── fixtures/\n│   └── sample.mp4\n└── core/\n    └── test_video_inspector.py\n```\n\nThis makes the distinction obvious: `test_*.py` contains test logic, `fixtures/` contains test data.\n\nNot every test costs the same amount to run. A test that calls real FFmpeg is different from a test that checks a pure Python function.\n\nYou can mark integration tests:\n\n``` python\n@pytest.mark.integration\ndef test_extract_audio_against_real_file(...):\n    ...\n```\n\nThen register the marker in `pyproject.toml`:\n\n```\n[tool.pytest.ini_options]\nmarkers = [\n    \"integration: calls real ffmpeg/ffprobe against a fixture file\",\n]\n```\n\nNow you can run the fast suite without integration tests:\n\n```\npytest -m \"not integration\"\n```\n\nAnd run everything with:\n\n```\npytest\n```\n\nThis gives you a useful distinction between fast unit tests and slower integration tests.\n\n`print()` Everywhere\npytest captures output by default. That can be surprising when you're debugging — a `print(result)` that produces nothing on screen. Here are the options you'll reach for most often.\n\n`-s`: Show stdout/stderr\n\n```\nuv run pytest -s\n```\n\nThis disables output capturing, so your `print()` statements become visible. It's useful, although there are usually better debugging tools than adding prints everywhere.\n\n`-k`: Run Tests by Name\nIf you're working on audio extraction, there's no reason to run 200 unrelated tests.\n\n```\nuv run pytest -k \"extract_audio\"\n```\n\npytest runs tests whose names match the expression. This is one of the most useful options during active development.\n\n`-x`: Stop at the First Failure\n\n```\nuv run pytest -x\n```\n\nInstead of getting a wall of failures, pytest stops after the first one. This is particularly useful when fixing a cascading failure.\n\n`--pdb`: Drop Into the Debugger\n\n```\nuv run pytest --pdb\n```\n\nWhen a test fails, pytest drops you into an interactive debugger at the point of failure. Inside the debugger, you can inspect variables and control execution — `c` continues, `q` quits. This can be much faster than trying to predict where a bug is before running the test.\n\n`-vv`: More Verbose Output\n\n```\nuv run pytest -vv\n```\n\nUseful when assertions involve large dictionaries, long strings, or parametrized tests and you want more detail in the output.\n\n`--lf`: Run Only the Last Failures\n\n```\nuv run pytest --lf\n```\n\n`--lf` means \"last failed.\" It reruns tests that failed during the previous run instead of running the entire suite. This is particularly useful when you're fixing several failures one by one.\n\nThese options can be combined:\n\n```\nuv run pytest -k \"extract_audio\" -x --pdb\n```\n\nThis means: run only tests matching `extract_audio`, stop at the first failure, and open the debugger at the failure. That's a very practical development loop.\n\npytest doesn't natively use line numbers to select a test. This isn't standard pytest test selection:\n\n```\npytest test_file.py:29\n```\n\nInstead, select by node ID:\n\n```\npytest tests/core/test_video_inspector.py::test_metadata_no_audio\n```\n\nOr use `-k`:\n\n```\npytest -k \"metadata_no_audio\"\n```\n\nEditors such as VS Code and PyCharm can provide \"run this test\" buttons. They translate your selection into pytest's name-based test selection behind the scenes. There are plugins that provide literal line-number selection, but you generally don't need one.\n\nSometimes you just want to see what a function actually returned. One quick technique is to deliberately make an assertion fail:\n\n``` python\ndef test_scratch(sample_video_path):\n    result = get_video_metadata(str(sample_video_path))\n\n    assert result == {}\n```\n\nIf `{}` isn't the actual result, pytest's failure output will show you the value. Delete the temporary test afterward.\n\nAnother option is:\n\n```\nuv run pytest --showlocals\n```\n\nor the shorthand `-l`, which shows local variables when a test fails.\n\nDon't guess which tests are slow. Measure them.\n\n```\nuv run pytest --durations=10\n```\n\nThis reports the ten slowest tests after the run. You may discover that one or two tests account for most of the delay. Optimizing everything before measuring can waste the very time you're trying to save.\n\nTests that load a real model or perform real inference are good candidates for a `slow` marker:\n\n``` python\n@pytest.mark.slow\ndef test_transcribe_real_audio_produces_valid_shape(\n    sample_speech_path,\n):\n    ...\n```\n\nRegister the marker:\n\n```\n[tool.pytest.ini_options]\nmarkers = [\n    \"integration: calls real ffmpeg/ffprobe against a fixture file\",\n    \"slow: tests that load a real model or run real transcription\",\n]\n```\n\nThen your normal development loop can skip them:\n\n```\npytest -m \"not slow\"\n```\n\nAnd your complete suite can still run with `pytest`.\n\nSuppose your application loads a model once at module level, and subsequent calls reuse it. Then only the first test that needs the model may pay the expensive loading cost — the others can reuse the already-loaded instance.\n\nYou can verify whether this is actually happening with `uv run pytest --durations=10`. If the first model-related test is slow and subsequent tests are much faster, your existing caching may already be doing the job. If every test is slow, something may be reloading the model when it shouldn't. Measure first.\n\nIf the entire suite is slow, rather than just a handful of expensive tests, parallel execution may help.\n\n```\nuv add --dev pytest-xdist\nuv run pytest -n auto\n```\n\n`-n auto` lets xdist determine a useful number of workers. But don't reach for parallelization automatically — for many projects, simply excluding slow tests during development (`pytest -m \"not slow\"`) is enough.\n\nCoverage is useful. It tells you which lines of code weren't exercised by your tests.\n\n```\nuv run pytest --cov=src\n```\n\nThis can help you find code paths you haven't thought about. But coverage doesn't tell you whether your tests are good. You can achieve 100% coverage with tests that assert almost nothing meaningful:\n\n``` python\ndef test_everything():\n    my_function()\n```\n\nThe code executed. Coverage increases. But the test may tell you almost nothing about whether the behavior is correct. So treat coverage as a flashlight — use it to illuminate parts of the code you haven't tested. Don't treat it as a scoreboard you have to maximize.\n\nWhen adding tests for a new function or feature, ask:\n\nWhat should happen when everything is valid?\n\nConsider: empty input, missing fields, boundary values, unexpected but valid combinations.\n\nWhat should happen when input is invalid, an external dependency fails, a file doesn't exist, or an API returns an unexpected result?\n\nUsually mock things that are external, slow, non-deterministic, or side-effecting. But let your own logic run for real.\n\nIf the test name contains the word \"and,\" that's sometimes a sign that you're testing two behaviors at once. A test should ideally answer one clear question.\n\npytest can look like a large collection of features when you're first learning it. But after working with it for a while, I found that most of what I actually needed came down to a few ideas.\n\n**Tests are ordinary Python functions.** pytest discovers them through naming conventions.\n\n**Assertions are ordinary Python `assert` statements.** pytest makes their failures informative.\n\n**Fixtures provide reusable things that tests need.** `conftest.py` makes shared fixtures available without imports.\n\n**Parametrization lets you run the same test logic against multiple inputs.**\n\n**Mocks let you replace boundaries you don't want to exercise for real.** Things like FFmpeg, APIs, databases, and other external systems are often good candidates — while the logic you're actually testing should usually run normally.\n\n**Markers let you separate tests by purpose or cost.** And the debugging tools make it much easier to figure out why something failed without resorting to `print()` everywhere.\n\nMost importantly, I stopped thinking of testing as \"how do I get my coverage number up?\" and started thinking more in terms of \"what question am I trying to answer with this test?\" For me, that shift made pytest much easier to understand.\n\n*The syntax is the easy part. The useful part is learning what behavior is worth testing, what should be isolated, what should run for real, and how to make a failure tell you something useful.*", "url": "https://wpnews.pro/news/pytest-in-practice", "canonical_source": "https://dev.to/nyakio/pytest-in-practice-4oda", "published_at": "2026-09-23 20:31:53+00:00", "updated_at": "2026-09-23 20:58:25.818009+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["pytest", "Python", "FFmpeg", "uv"], "alternates": {"html": "https://wpnews.pro/news/pytest-in-practice", "markdown": "https://wpnews.pro/news/pytest-in-practice.md", "text": "https://wpnews.pro/news/pytest-in-practice.txt", "jsonld": "https://wpnews.pro/news/pytest-in-practice.jsonld"}}