Pytest Interview Questions and Answers
Last updated:
Check out 45 of the most common Pytest interview questions, then take an AI-powered practice interview
Q1What is Pytest and why is it preferred over unittest?
BasicFundamentals
Answer
Pytest is a Python testing framework that emphasizes simplicity, readability, and powerful extensibility. It replaces the ceremony of `unittest` (subclassing `TestCase`, `self.assertEqual`, `setUp`/`tearDown`) with plain functions and the native `assert` statement. The reasons teams prefer it: (1) Less boilerplate, a test is just a function whose name starts with `test_`. (2) Better assertion error messages, Pytest rewrites assertions at import time and shows you the expanded values when an `assert` fails, instead of unittest's generic 'AssertionError'. (3) A fixture system that's composable and dependency-injected rather than a setUp/tearDown method on a class. (4) A massive plugin ecosystem, pytest-cov, pytest-xdist, pytest-django, pytest-asyncio, pytest-mock, each one a small `pip install` away. unittest is still in the standard library and remains useful for tiny projects where adding a dependency isn't desirable, but it's verbose: defining a class, writing `self.assertEqual(a, b)` instead of `assert a == b`, and dealing with rigid setUp/tearDown chains.
Pytest can also run unittest-style tests, so migration is gradual. Mechanically that compatibility works because Pytest's collector recognises `unittest.TestCase` subclasses and hands their lifecycle back to unittest, which is why Pytest fixtures cannot be injected as arguments into `TestCase` methods (only autouse fixtures reach them), a limitation interviewers like to probe. Two more practical differences: Pytest exits with distinct status codes (0 all passed, 1 tests failed, 2 interrupted, 3 internal error, 4 usage error, 5 no tests collected), so CI can tell 'no tests ran' apart from 'tests failed' instead of treating both as red; and the old nose-style bare `setup()`/`teardown()` methods that some migrated suites relied on were removed in Pytest 8.0 after being deprecated in 7.2, so those suites silently lose their setup until it is rewritten as `setup_method` or a fixture.
# unittest style
import unittest
class TestCart(unittest.TestCase):
def setUp(self):
self.cart = Cart()
def test_total(self):
self.cart.add("tea", 120)
self.assertEqual(self.cart.total(), 120)
# pytest style
import pytest
@pytest.fixture
def cart():
return Cart()
def test_total(cart):
cart.add("tea", 120)
assert cart.total() == 120
# CI can distinguish outcomes by exit code
# pytest tests/; echo $?
# 0 = passed, 1 = failures, 5 = NO TESTS COLLECTEDKey Points
- Plain `assert` statements, no `self.assertEqual` boilerplate
- Function-style tests, no class subclassing required
- Assertion rewriting for rich failure output
- Fixture-based dependency injection vs setUp/tearDown
- Huge plugin ecosystem (cov, xdist, mock, django, asyncio)
Q2How does Pytest discover tests automatically?
BasicTest Discovery
Answer
Pytest's default discovery rules: (1) Start from the directory you invoked it in (or `testpaths` in `pyproject.toml`/`pytest.ini`). (2) Recurse into directories looking for files matching `test_*.py` or `*_test.py`. (3) Inside those files, collect functions whose name starts with `test_` and classes whose name starts with `Test` (which must not have an `__init__` method, that's a common mistake). (4) Inside those classes, collect methods starting with `test_`. You can override every part of this via `python_files`, `python_classes`, and `python_functions` config keys. Discovery happens before any test runs, so you can use `pytest --collect-only` (or `--co -q` for a flat list of node IDs) to debug what gets picked up without executing anything.
Details that decide real-world bugs: `testpaths` is only used when you invoke `pytest` with no path arguments, so `pytest .` quietly bypasses it. `norecursedirs` (default `*.egg .* _darcs build CVS dist node_modules venv {arch}`) prunes whole directories, and a `collect_ignore = ["legacy_tests.py"]` list in `conftest.py` skips specific files. Whether your test directories contain `__init__.py` changes how modules are named on import: without it, Pytest inserts the test file's own directory (its 'basedir') into `sys.path` under the default `prepend` import mode and imports the file by its bare basename, so two files both called `test_utils.py` in different folders collide with `import file mismatch: imported module 'test_utils' has this __file__ attribute`. Adding `__init__.py` files, or switching to `--import-mode=importlib`, gives each module a unique dotted name and removes the clash. Finally, a class with an `__init__` method is skipped with a `PytestCollectionWarning` rather than an error, so it disappears from the run silently.
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
# Project layout
# project/
# tests/
# test_auth.py ← collected
# auth_test.py ← collected
# helpers.py ← NOT collected (no test_ prefix)Key Points
- Looks for `test_*.py` / `*_test.py` files
- Inside, picks up `test_*` functions and `Test*` classes
- `pytest --collect-only` lets you preview discovery
- Test classes must NOT define `__init__`
Q3What is the assert statement in Pytest and how does assertion rewriting work?
BasicAssertions
Answer
In Pytest you use Python's native `assert` keyword, no `self.assertEqual`, no `assertIn`, no `assertGreater`. The magic is that Pytest hooks into Python's import system and rewrites every `assert` statement in your test modules at load time. When the assertion fails, Pytest doesn't just say 'AssertionError': it shows the expanded values on each side of the operator, the full diff for sequences and dicts, and even the local variables involved.
This is called assertion rewriting. It's also why you should never write `assert a == b, 'they differ'` with a custom message in Pytest, the rewriter handles introspection better than any string you could write. The rewritten bytecode is cached in `__pycache__` directories alongside `.pyc` files; if rewriting misbehaves, deleting these caches forces a fresh rewrite.
The mechanism matters because it is not universal: Pytest only rewrites test modules it collects, plus `conftest.py` and any plugin registered through the `pytest11` entry point. A shared helper module such as `tests/assertions.py` holding your custom `assert_order_valid()` gets no introspection at all, you just see a bare `AssertionError`, unless you call `pytest.register_assert_rewrite("tests.assertions")` in the root `conftest.py` before that module is first imported. Order matters: if the module is already in `sys.modules`, registration is too late and Pytest emits a warning.
Related flags a senior interviewer expects you to know: `--assert=plain` turns rewriting off entirely when you suspect it of masking behaviour, `-vv` stops Pytest truncating long diffs (the 'Omitting N identical items' line), `--tb=short|line|no` controls traceback verbosity, and `-l` (`--showlocals`) prints local variables at each frame. Writing `assert a == b, "they differ"` suppresses the generated explanation and leaves you with only your own string, which is why custom assert messages are discouraged.
def test_user_dict():
actual = {"name": "Aarav", "age": 30, "role": "engineer"}
expected = {"name": "Aarav", "age": 31, "role": "engineer"}
assert actual == expected
# Pytest output:
# E AssertionError: assert {'age': 30, ...} == {'age': 31, ...}
# E Omitting 2 identical items, use -vv to show
# E Differing items:
# E {'age': 30} != {'age': 31}Q4What is a Pytest fixture and how do you define one?
BasicFixtures
Answer
A fixture is a function decorated with `@pytest.fixture` that produces a value or sets up state for one or more tests. Tests opt-in to fixtures by listing them as parameters, Pytest matches parameter names to fixture names and injects the return value. This is dependency injection: tests declare what they need, fixtures supply it, Pytest wires them together.
Fixtures can `yield` instead of `return` to run teardown code after the test finishes. Compared to `setUp`/`tearDown`, fixtures are more flexible: any test can request any fixture, fixtures can depend on other fixtures, and they can be scoped to a single function, a class, a module, or the whole session. Three behaviours interviewers probe.
First, the return value is cached per scope: if a test requests `sample_user` directly and also uses another fixture that requests it, both get the same object, not two copies, which is what makes fixture graphs cheap but also what makes a mutated dict leak between collaborating fixtures. Second, the code after `yield` only runs if the code before `yield` completed. If setup raises, the test is reported as an ERROR (not a FAILURE) and your teardown never executes, so anything already allocated before the exception leaks; when partial cleanup matters, register each step with `request.addfinalizer(fn)` as you go, because finalizers run in LIFO order even after a later failure.
Third, fixtures are resolved by name at collection time, so a typo in the parameter name produces `fixture 'db_sesion' not found` with a list of available fixtures rather than a `NameError`. Use `pytest --fixtures` to list every fixture visible from a directory with its docstring, and `pytest --setup-show` to watch setup and teardown ordering for a specific test.
import pytest
@pytest.fixture
def sample_user():
user = {"name": "Priya", "email": "priya@example.com"}
yield user
# teardown runs here (e.g. delete user from DB)
def test_user_has_email(sample_user):
assert "@" in sample_user["email"]
def test_user_has_name(sample_user):
assert sample_user["name"] == "Priya"Key Points
- Decorated with `@pytest.fixture`
- Tests opt-in by listing the fixture as a parameter
- `yield` separates setup and teardown
- Composable, fixtures can depend on other fixtures
Q5What are the different fixture scopes and when should you use each?
BasicFixtures
Answer
Pytest fixtures support five scopes: `function` (default, recreated per test), `class` (one per test class), `module` (one per `.py` file), `package` (one per package directory), and `session` (one for the entire test run). The trade-off is isolation vs speed: function scope guarantees a clean state but is the slowest if setup is expensive (database, browser); session scope is fast but shared state between tests can hide bugs. Common pattern: a `session`-scoped fixture creates an expensive resource (DB engine, app instance), and a `function`-scoped fixture uses it to give each test a clean transaction or sandbox.
The scope you choose cascades: a `session` fixture cannot depend on a `function` fixture (Pytest will error), because the inner fixture's lifetime is shorter than the outer's. Scope also drives instantiation order: within a single test Pytest builds higher-scoped fixtures first, autouse fixtures before explicitly requested ones at the same scope, and tears down in reverse. That is why an autouse `function` fixture that truncates tables can wipe data a `session` fixture seeded, a classic ordering bug you can see immediately with `pytest --setup-show`.
Two production caveats. Under pytest-xdist, 'session' means once per worker process, not once per run, so `pytest -n 8` runs your expensive session setup eight times and eight workers race to create the same schema unless you key it on `PYTEST_XDIST_WORKER` or serialise it with a lock file. And `package` scope depends on directory layout: it groups by the package the test module belongs to, so it behaves differently once you add or remove `__init__.py`. Scope can also be computed at runtime by passing a callable `scope=lambda fixture_name, config: "session" if config.getoption("--fast") else "function"`, which lets a suite trade isolation for speed on demand.
import pytest
from sqlalchemy import create_engine
@pytest.fixture(scope="session")
def db_engine():
engine = create_engine("postgresql://test_db")
yield engine
engine.dispose()
@pytest.fixture(scope="function")
def db_session(db_engine):
conn = db_engine.connect()
txn = conn.begin()
yield conn
txn.rollback() # clean state per test
conn.close()Q6What is conftest.py and how is it used?
BasicFixtures
Answer
`conftest.py` is a special file Pytest auto-discovers in each directory of your test tree. Fixtures, hooks, and plugins defined in `conftest.py` are available to every test file in that directory and its subdirectories, without needing imports. The discovery is hierarchical: a fixture in `tests/conftest.py` is visible everywhere; a fixture in `tests/api/conftest.py` is visible only to tests under `tests/api/`.
This makes `conftest.py` the right home for shared fixtures (like `db_session`, `client`, `mock_redis`), pytest plugins (hooks like `pytest_collection_modifyitems`), and command-line options registered via `pytest_addoption`. Never import from `conftest.py` directly, Pytest loads it specially, and importing it as a regular module breaks discovery. Loading order is worth knowing: `conftest.py` files are imported before collection begins, rootdir-first and then downward, which is why a stray `ImportError` in a top-level `conftest.py` aborts the entire run with `errors during collection` and zero tests executed, and why an expensive import there taxes every invocation including `--collect-only`.
Two rules trip people up. `pytest_plugins = ["myplugin"]` is only allowed in the rootdir `conftest.py`; putting it in a nested one raises an error telling you it is no longer supported. And a fixture defined in a nested `conftest.py` shadows a same-named fixture from a parent directory for tests underneath it, which is the sanctioned way to specialise a shared `client` or `settings` fixture per test package. `--confcutdir=tests` stops Pytest walking above a directory when hunting for conftest files, useful in monorepos where a parent folder contains an unrelated `conftest.py`. Use `pytest --fixtures tests/api/test_users.py` to see exactly which conftest each visible fixture came from.
# tests/conftest.py
import pytest
from myapp import create_app
@pytest.fixture(scope="session")
def app():
return create_app(env="test")
@pytest.fixture
def client(app):
return app.test_client()
# tests/api/test_users.py, no import needed!
def test_list_users(client):
response = client.get("/users")
assert response.status_code == 200Q7How do you run a specific test, file, or test method in Pytest?
BasicCLI
Answer
Pytest accepts a node ID with the format `path::class::method` or `path::function`. To run a single file: `pytest tests/test_auth.py`. A single function: `pytest tests/test_auth.py::test_login`.
A method in a class: `pytest tests/test_auth.py::TestLogin::test_invalid_password`. You can also use `-k <expression>` to filter by name pattern (matches any substring) and `-m <marker>` to filter by marker. The `-v` flag shows each test name as it runs, `-vv` adds even more detail on diffs, and `-x` stops at the first failure.
For development, `--lf` (last-failed) and `--ff` (failed-first) are huge time-savers. A few specifics that come up on the job. Parametrized cases get bracketed IDs, so the node ID is `tests/test_pay.py::test_upi[amount-500]`, and in zsh you must quote it or the shell tries to glob the brackets. `-k` matches against the whole node ID and supports `and`, `or`, `not` and parentheses, so `-k "payment and not razorpay"` works, while `-m` evaluates marker expressions only. `--deselect tests/test_slow.py::test_etl` removes one case from an otherwise full run, and `--ignore=tests/e2e` skips a directory. `--lf`, `--ff` and `--sw` (stepwise, stop at the first failure and resume there next time) all read the `.pytest_cache` directory, so they silently do nothing in a fresh CI container or when you pass `-p no:cacheprovider`; pair `--lf` with `--lfnf=none` if you want an empty run rather than the full suite when no failures were recorded. `--durations=10` prints the slowest tests, and `--co -q` lists node IDs you can paste straight back onto the command line.
# Run everything
pytest
# Run one file
pytest tests/test_auth.py
# Run one test
pytest tests/test_auth.py::test_login_succeeds
# Run by name pattern
pytest -k "login and not slow"
# Run by marker
pytest -m smoke
# Stop at first failure, verbose
pytest -xv
# Re-run only failures from last run
pytest --lfQ8What is parametrization in Pytest and how do you use @pytest.mark.parametrize?
BasicParametrization
Answer
Parametrization lets you run the same test function with multiple sets of inputs and expected outputs, effectively generating one test case per parameter set, each with its own pass/fail status. Use `@pytest.mark.parametrize(argnames, argvalues)` above the test function. `argnames` is a string of comma-separated parameter names, `argvalues` is a list of tuples (one per case). Pytest will generate `test_name[arg1-arg2]` style IDs by default, you can override with the `ids` keyword for cleaner output.
Parametrization is far better than a `for` loop inside a single test, because each case is a separate test in the report: you see exactly which inputs failed, and pytest's `-x` / `--lf` works correctly. Beyond the basics: wrap a single case in `pytest.param(value, marks=pytest.mark.xfail(reason="open bug"), id="leap-year")` to mark or rename one row without touching the rest. Stacking two `parametrize` decorators produces the cartesian product, so two decorators of 3 and 4 values give 12 tests, which is how parameter counts explode past what CI can afford. `ids` accepts a callable that receives each value and returns a string (or `None` to fall back to the default), which is the clean way to name object parameters, because otherwise Pytest labels non-primitive values `phone0`, `phone1` and the report tells you nothing.
An empty `argvalues` list generates a single skipped test rather than an error; set `empty_parameter_set_mark = fail_at_collect` in the ini file if a dynamically built empty list should be treated as a bug. Finally, argvalues are evaluated at import time, so calling a database or reading a file inside the decorator runs during collection for every invocation, including `--collect-only`.
import pytest
@pytest.mark.parametrize("phone, expected", [
("+919876543210", True),
("9876543210", True),
("+91 9876 543 210", True),
("12345", False),
("", False),
], ids=["with-country-code", "without-code", "with-spaces", "too-short", "empty"])
def test_validate_indian_phone(phone, expected):
assert is_valid_indian_phone(phone) == expectedKey Points
- One test function, many test cases
- Each case has its own pass/fail status
- Use `ids=` for readable test IDs
- Far better than `for` loops inside a test
Q9What are markers in Pytest? Explain skip, skipif, and xfail.
BasicMarkers
Answer
Markers are decorators that attach metadata to a test. The three built-in markers you'll use constantly: `@pytest.mark.skip(reason=...)` always skips a test (useful for temporarily disabling broken tests with a TODO); `@pytest.mark.skipif(condition, reason=...)` skips conditionally, common for Windows-only or Python-version-only tests; `@pytest.mark.xfail(reason=...)` marks a test as expected-to-fail. xfail is special: if the test fails, it's reported as 'XFAIL' (expected, no problem); if it unexpectedly passes, it's 'XPASS' (worth investigating, maybe the bug is fixed and the marker should be removed). You can also define custom markers like `@pytest.mark.slow` or `@pytest.mark.smoke` to organize tests by category, then run subsets via `pytest -m slow`.
Register custom markers in `pyproject.toml` to avoid warnings. The details that separate a junior from a senior answer: `skipif` conditions are evaluated at collection time, so `@pytest.mark.skipif(REDIS.ping() is False, ...)` opens a connection during collection of every run, and the imperative forms `pytest.skip(reason)` or `pytest.importorskip("psycopg")` inside the test body are the right tool when the decision depends on runtime state. `xfail` by default still executes the test; add `run=False` when the failure crashes the interpreter or hangs, and `strict=True` (or set `xfail_strict = true` in the ini) so an unexpected XPASS fails the build instead of quietly passing, which is how a fixed bug gets a stale marker left on it for a year. `pytest --runxfail` reports xfail tests as ordinary passes or failures, handy when you want to see the real traceback. Module-wide or class-wide markers go through `pytestmark = pytest.mark.slow` at module level or `pytestmark = [pytest.mark.django_db]` as a class attribute. Always run CI with `--strict-markers` so a typo like `@pytest.mark.smoek` is a hard error rather than a marker that matches nothing.
import sys
import pytest
@pytest.mark.skip(reason="Pending API change")
def test_old_endpoint():
pass
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX only")
def test_unix_socket():
...
@pytest.mark.xfail(reason="Known bug: PR #1234 in progress")
def test_timezone_dst_edge_case():
assert convert_to_ist("2026-03-12 02:30") == "2026-03-12 08:00"
@pytest.mark.slow
def test_long_running_etl():
...
# pyproject.toml
# [tool.pytest.ini_options]
# markers = ["slow: marks long-running tests", "smoke: minimal sanity tests"]Q10How do you test that a function raises an exception?
BasicAssertions
Answer
Use `pytest.raises` as a context manager. The block should contain the code that's expected to throw; if it doesn't, the test fails. You can also assert on the exception's message via the `match` keyword (a regex), or inspect the captured exception via the context variable.
Avoid catching exceptions in a `try/except` and calling `pytest.fail()`, `pytest.raises` is shorter and clearer. The subtleties interviewers chase: `match` runs `re.search`, not `re.fullmatch`, so `match="invalid"` passes on any message containing that substring and a test that looks strict is not. Anchor it with `^...$` when the exact text matters, and wrap literals in `re.escape()` because messages full of brackets, dots and parentheses are regex metacharacters (`match="Invalid id (42)"` will not match, since the parentheses become a group).
Keep the block to the single call under test, or an unrelated line raising the same exception type will make the test pass for the wrong reason. `pytest.raises` also matches subclasses, so `pytest.raises(Exception)` accepts almost anything and is close to worthless as an assertion; name the narrowest class. After the block, `exc_info` gives you `.value` (the exception object, so you can assert on `.status_code` or `.args`), `.type` and `.traceback`. For Python 3.11+ `ExceptionGroup` use `exc_info.group_contains(ValueError)`, and use the sibling helper `pytest.warns(DeprecationWarning, match=...)` for warnings, which behaves the same way.
import pytest
def divide(a, b):
if b == 0:
raise ValueError("division by zero")
return a / b
def test_divide_by_zero_raises():
with pytest.raises(ValueError, match="division by zero"):
divide(10, 0)
def test_inspect_exception():
with pytest.raises(ValueError) as exc_info:
divide(10, 0)
assert "zero" in str(exc_info.value)
# match uses re.search, so anchor when you mean exact
def test_exact_message():
with pytest.raises(ValueError, match=r"^division by zero$"):
divide(10, 0)
# escape metacharacters in literal messages
import re
def test_literal_message():
with pytest.raises(KeyError, match=re.escape("missing key (order_id)")):
lookup("order_id")Q11What is autouse in fixtures and when should you use it?
BasicFixtures
Answer
`@pytest.fixture(autouse=True)` makes a fixture run automatically for every test in its scope, without the test needing to declare it as a parameter. Use cases: resetting a global counter before each test, seeding a random number generator, clearing a cache, or configuring logging. The danger is invisibility: a developer reading a test won't know there's hidden setup happening, and debugging becomes hard.
Rule of thumb: use `autouse=True` for things that are truly cross-cutting (like 'every test should have a clean Redis'), and prefer explicit fixtures for anything tied to specific test behavior. A common bug: an `autouse` fixture in `conftest.py` that does network setup makes every test slower, even ones that don't need it. Mechanically, autouse fixtures are requested for every test in the scope where they are defined, so an autouse fixture in the root `conftest.py` applies to the entire suite, one in a class applies only to that class, and one inside a plugin applies to every project that installs the plugin.
Ordering is the part people get wrong: at a given scope, autouse fixtures are instantiated before explicitly requested ones, and higher scopes come first regardless of autouse. So a session-scoped autouse fixture that seeds reference data runs before a function-scoped autouse fixture that truncates tables, and your seed data is gone by the time the test body runs. `pytest --setup-show tests/test_x.py::test_y` prints the exact SETUP/TEARDOWN sequence and is the fastest way to prove what is running. Two alternatives to reach for first: `@pytest.mark.usefixtures("clean_redis")` applies a fixture to one test, class or module explicitly, and the ini key `usefixtures = clean_redis` applies it suite-wide but at least leaves a searchable declaration in config rather than hiding it in a decorator three directories up.
import pytest
import random
@pytest.fixture(autouse=True)
def reset_random_seed():
"""Every test starts with a deterministic random state."""
random.seed(42)
yield
# could also reset here
# tests don't list reset_random_seed, but it still runs
def test_shuffle_is_deterministic():
items = [1, 2, 3, 4, 5]
random.shuffle(items)
assert items == [4, 2, 3, 5, 1]Q12How do you capture stdout/stderr in a Pytest test?
BasicBuilt-in Fixtures
Answer
Pytest provides the `capsys` fixture which captures `sys.stdout` and `sys.stderr`. Use `capsys.readouterr()` to get a named tuple with `.out` and `.err` strings. By default Pytest captures all output and only shows it on failure; pass `-s` (or `--capture=no`) to disable capture and see live output.
For capturing at the OS file-descriptor level (when subprocess output needs to be captured), use `capfd` instead, useful for testing CLI tools that write to FD 1/2 directly. There's also `caplog` for capturing log records emitted via the `logging` module. The gotchas are what get asked about. `readouterr()` drains the buffer, so calling it twice returns the second call's output as empty strings and an assertion written after a debugging `print(captured.out)` mysteriously fails.
Running with `-s` disables capture entirely, which means `capsys` yields empty strings and every test asserting on output fails, so never bake `-s` into the CI command; use `with capsys.disabled():` around the one block you want to see live. Choose the right variant: `capsys` swaps `sys.stdout`/`sys.stderr` at the Python level and therefore cannot see output from a `subprocess.run()` child or a C extension, `capfd` captures at file descriptors 1 and 2 and can; `capsysbinary` and `capfdbinary` give you `bytes` when you are testing binary output. For logs, `caplog.records` gives you `LogRecord` objects so you can assert on `record.levelname` and `record.args` instead of substring-matching `caplog.text`, and `caplog.set_level(logging.DEBUG, logger="myapp.payments")` targets one logger. The classic miss: `caplog` attaches a handler to the root logger, so a logger configured with `propagate = False` produces nothing at all.
def greet(name):
print(f"Hello, {name}!")
def test_greet(capsys):
greet("Sneha")
captured = capsys.readouterr()
assert captured.out == "Hello, Sneha!\n"
assert captured.err == ""
# Capturing logs
import logging
def test_warns(caplog):
with caplog.at_level(logging.WARNING):
logging.warning("low disk space")
assert "low disk space" in caplog.textQ13How do you compare floating point numbers in Pytest, and what does pytest.approx actually do?
BasicAssertions
Answer
`assert 0.1 + 0.2 == 0.3` fails in Python because binary floats cannot represent those decimals exactly, so Pytest ships `pytest.approx` for tolerant comparison. `assert 0.1 + 0.2 == pytest.approx(0.3)` passes. The default tolerance is relative 1e-6 combined with absolute 1e-12, and the comparison succeeds if EITHER tolerance is satisfied, which is the part candidates usually get wrong. That combination exists because relative tolerance is meaningless near zero: `approx(0.0)` with only a relative tolerance would accept nothing, so the absolute floor takes over.
When your expected value is zero or very small, set `abs=1e-9` explicitly; when you are comparing large magnitudes, `rel=1e-3` is more useful than an absolute number. `approx` also handles containers: lists, tuples, sets, dicts and numpy arrays compare element-wise, but a dict comparison fails if the key sets differ at all, tolerance only applies to the values. `float('nan')` never equals itself, so pass `nan_ok=True` if NaN is a legitimate expected value. One thing `approx` should not be used for is money. Rupee amounts, GST splits and invoice totals belong in `decimal.Decimal` with exact equality and an explicit quantize step, because hiding a rounding bug behind a tolerance is exactly how a payment reconciliation defect reaches production.
import pytest
def test_float_addition():
assert 0.1 + 0.2 == pytest.approx(0.3)
def test_near_zero_needs_abs():
# relative tolerance is useless around 0
assert compute_drift() == pytest.approx(0.0, abs=1e-9)
def test_containers():
assert [0.1 + 0.2, 1.0] == pytest.approx([0.3, 1.0])
assert {"cgst": 8.9999999, "sgst": 9.0} == pytest.approx(
{"cgst": 9.0, "sgst": 9.0}, rel=1e-6
)
# Money: do NOT use approx
from decimal import Decimal
def test_invoice_total_is_exact():
assert invoice_total(Decimal("1180.00")) == Decimal("1392.40")Key Points
- Default tolerance is rel=1e-6 OR abs=1e-12, either one passing is enough
- Set `abs=` explicitly when the expected value is at or near zero
- Works element-wise on lists, dicts and numpy arrays; dict keys must match exactly
- `nan_ok=True` is required for NaN comparisons
- Use `Decimal` with exact equality for currency, not `approx`
Q14How does Pytest decide the rootdir, and which config file wins if a repo has several?
BasicConfiguration
Answer
Pytest prints `rootdir:` and `configfile:` in the first lines of every run, and reading those two values solves a surprising share of 'it works on my machine' problems. The algorithm: Pytest takes the command line arguments, finds their common ancestor directory, then walks upward looking for a config file. The precedence order is `pytest.ini` (wins even when empty, which makes an empty `pytest.ini` the standard way to pin the rootdir), then `pyproject.toml` containing a `[tool.pytest.ini_options]` table, then `tox.ini` containing a `[pytest]` section, then `setup.cfg` containing a `[tool:pytest]` section.
A `pyproject.toml` WITHOUT that table does not count as a config file, though it can still fix the rootdir as a fallback along with `setup.py`. Only one config file is ever used; settings are not merged across files, so a stale `setup.cfg` at the repo root can silently override the `pyproject.toml` you have been editing. rootdir matters because node IDs, `testpaths`, `cache_dir` and `norecursedirs` are all resolved relative to it, and because `addopts` from that file is prepended to every invocation. rootdir is NOT added to `sys.path`; import resolution is a separate mechanism driven by `conftest.py` location and `--import-mode`. Override with `-c path/to/pytest.ini` or `--rootdir=.` when CI invokes Pytest from an unexpected directory.
# pyproject.toml
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
addopts = "-ra --strict-markers --strict-config"
markers = [
"slow: long-running tests",
"smoke: minimal sanity checks",
]
filterwarnings = ["error", "ignore::DeprecationWarning:botocore.*"]
# Check what pytest actually picked up:
# $ pytest --collect-only -q | head -1
# rootdir: /srv/app, configfile: pyproject.toml, testpaths: tests
# Force a config file explicitly (useful in CI containers)
# pytest -c ci/pytest.ini tests/Q15What does @pytest.mark.usefixtures do, and how is it different from requesting the fixture as an argument?
BasicFixtures
Answer
`@pytest.mark.usefixtures("name")` activates a fixture for a test without binding its value to a parameter. Use it when the fixture is pure side effect and the return value is irrelevant: clearing Redis, applying a database seed, changing the working directory, installing a signal handler. Requesting it as an argument (`def test_x(clean_redis):`) is the right call whenever you need the object the fixture produces, and it is also more discoverable, so prefer the argument form by default.
The real advantage of the marker is that it can be attached to a class or a whole module, which the argument form cannot: `@pytest.mark.usefixtures("db", "seed_products")` above a class applies to every method in it, and `pytestmark = pytest.mark.usefixtures("clean_redis")` at module level applies to every test in the file. There is also an ini key, `usefixtures = clean_redis`, which applies a fixture to the entire suite while keeping the declaration in one searchable place, a much better option than scattering `autouse=True`. Two limitations to know: the marker takes fixture NAMES as strings, so a typo surfaces as `fixture 'clean_reids' not found` at setup time rather than at collection, and applying `usefixtures` to a fixture function has no effect at all. If one fixture needs another, it must request it as a parameter.
import pytest
@pytest.fixture
def clean_redis(redis_client):
redis_client.flushdb()
yield
redis_client.flushdb()
# Value not needed: marker form
@pytest.mark.usefixtures("clean_redis")
def test_cache_miss_hits_db(api_client):
assert api_client.get("/jobs/12").json()["source"] == "db"
# Applies to every method in the class
@pytest.mark.usefixtures("db", "seed_products")
class TestCheckout:
def test_gst_split(self): ...
def test_coupon_applies(self): ...
# Whole module
pytestmark = pytest.mark.usefixtures("clean_redis")
# Suite-wide, in pyproject.toml:
# [tool.pytest.ini_options]
# usefixtures = ["clean_redis"]Q16How does fixture scope cascading work, and what happens if scopes mismatch?
IntermediateFixtures
Answer
Fixtures can depend on other fixtures by listing them as parameters. The scope rule: a fixture can only depend on fixtures with an equal-or-broader scope. So a `session` fixture cannot depend on a `function` fixture, Pytest will raise `ScopeMismatch` because the inner fixture's lifetime is shorter than the outer's.
The cascading pattern is: a `session` fixture creates an expensive resource (a Selenium driver, a database engine); a `module` or `function` fixture uses it to provide cheap, isolated state (a fresh browser tab, a rolled-back transaction). When you mix scopes incorrectly, e.g., a `module`-scoped fixture that depends on a `function`-scoped one, you get a confusing error at collection time. Always design from the longest-lived outwards.
The error text is worth memorising because it names both sides: `ScopeMismatch: You tried to access the function scoped fixture 'db_session' with a session scoped request object`. Two escape hatches and their costs. `request.getfixturevalue("db_session")` resolves a fixture dynamically by name and bypasses the static scope check, so the mismatch turns from a collection-time error into a runtime one, and the fixture is no longer visible to `--setup-show` or to xdist's scheduling; use it only for genuinely conditional dependencies. Passing a callable to `scope=` lets the same fixture be session-scoped in a fast local run and function-scoped in CI.
The second-order effect nobody mentions until you hit it: a parametrized higher-scope fixture makes Pytest REORDER tests, grouping every test that needs `db[postgres]` together so the expensive fixture is built once rather than torn down and rebuilt per file. That reordering is why your tests suddenly run in a different sequence after you parametrize a session fixture, and why an order-dependent test that had been passing for months starts failing.
import pytest
@pytest.fixture(scope="session")
def db_engine():
return create_engine("postgresql://test")
@pytest.fixture(scope="function")
def db_session(db_engine):
# OK: function-scoped depends on session-scoped
conn = db_engine.connect()
txn = conn.begin()
yield conn
txn.rollback()
# This WOULD raise ScopeMismatch:
# @pytest.fixture(scope="session")
# def cached_query(db_session): # function-scoped dep!
# return db_session.execute("SELECT ...")Key Points
- Fixture can only depend on equal-or-broader-scope fixtures
- ScopeMismatch error at collection time when violated
- Design from broadest (session) inward to narrowest (function)
Q17How do you use pytest-mock and what's the difference from unittest.mock?
IntermediateMocking
Answer
`pytest-mock` is a thin wrapper around `unittest.mock` that exposes a `mocker` fixture. The functionality is identical, `mocker.patch()`, `mocker.MagicMock()`, `mocker.spy()`, but the lifecycle is managed automatically: every patch is undone after the test finishes. With raw `unittest.mock`, you either use `@patch` decorators (stacking them is messy) or `with patch():` blocks (extra indentation), and you have to remember to clean up.
With `mocker`, you call `mocker.patch('module.func', return_value=42)` once and Pytest handles teardown. The functions are the same: `patch`, `patch.object`, `patch.dict`, etc. Most Python teams in 2026 use pytest-mock as the default mocking interface, it's shorter and harder to leak mocks across tests. Beyond `patch`, the `mocker` fixture adds a few things worth naming in an interview. `mocker.spy(obj, "method")` wraps the REAL implementation while recording calls, so you can assert `spy.call_count == 1` and still exercise production behaviour, which is the right tool when you want to prove a cache was consulted without stubbing it out. `mocker.stub(name="on_success")` creates a named callable for callback assertions. `mocker.patch(..., autospec=True)` builds the mock from the real signature, so if someone renames a parameter from `amount` to `amount_paise`, calls with the old keyword raise `TypeError` instead of passing silently; without autospec a mock accepts any arguments and your test keeps passing against an API that no longer exists.
There is one safety net you should not rely on: `Mock` raises `AttributeError` for attributes starting with `assert` or `assret`, so `mock.assert_called_onec_with(...)` errors, but `mock.called_once_with(...)` (no `assert` prefix) is just an attribute access that creates a child mock and passes silently. Reviewers should grep for that pattern.
# With unittest.mock
from unittest.mock import patch
def test_pay_old(self):
with patch("app.razorpay.charge") as mock_charge:
mock_charge.return_value = {"status": "captured"}
result = checkout(amount=500)
assert result == "success"
# With pytest-mock, cleaner
def test_pay_new(mocker):
mock_charge = mocker.patch("app.razorpay.charge", return_value={"status": "captured"})
result = checkout(amount=500)
assert result == "success"
mock_charge.assert_called_once_with(amount=500)Q18Where should you patch, at the source or at the import site? Why is this a common bug?
IntermediateMocking
Answer
This is the single biggest mocking gotcha in Python. Rule: patch at the location where the name is LOOKED UP, not where it was defined. If `mymodule.py` does `from utils import send_email`, then inside `mymodule` the name `send_email` is a local reference.
Patching `utils.send_email` does nothing, `mymodule` already imported its own reference. You must patch `mymodule.send_email`. Engineers fall into this trap constantly: the patch silently does nothing (the test 'passes' but the real function ran).
To avoid it, prefer `import utils` and then `utils.send_email(...)`, now there's only one name to patch (`utils.send_email`). When debugging a mock that 'isn't being applied,' the first thing to check is: where does the code I'm testing look up that name? That's the patch target.
A quick diagnostic: in a debugger, print `orders.send_email` inside the test. If it is not a `MagicMock`, your patch string is pointing at the wrong module. `mocker.patch.object(orders, "send_email")` avoids the whole class of typo bugs because you pass the module object itself and an `AttributeError` fires immediately if the name is wrong, whereas a misspelled string target gives you `ModuleNotFoundError` or `AttributeError: <module> does not have the attribute` only if you are lucky. Two related cases.
Patching a name that a decorator captured at import time does not work at all: once `@retry(send_email)` has run, the reference is baked into the closure, and you must patch before import or restructure the code. And patching a method on a class (`mocker.patch.object(PaymentGateway, "charge")`) affects every instance including ones created before the patch, because attribute lookup goes through the class; patching `instance.charge` affects only that object. Combine either form with `autospec=True` so signature drift in the real function breaks the test instead of hiding behind a permissive mock.
# utils.py
def send_email(to, body): ...
# orders.py
from utils import send_email # local binding!
def place_order(user):
send_email(user.email, "Order placed")
# WRONG, does nothing because orders.py already has its own binding
mocker.patch("utils.send_email")
# RIGHT, patch where it's looked up
mocker.patch("orders.send_email")
# Even better: import the module, not the function
# orders.py
import utils
def place_order(user):
utils.send_email(user.email, "Order placed")
# Now you can patch "utils.send_email" and it works in both places.Key Points
- Patch where the name is LOOKED UP, not where it was DEFINED
- `from utils import send_email` creates a local binding in the importer
- Prefer `import utils; utils.send_email(...)` to avoid the trap
Q19How do you write async tests with pytest-asyncio?
IntermediateAsync
Answer
Install `pytest-asyncio` and either mark each async test with `@pytest.mark.asyncio` or set `asyncio_mode = "auto"` in your `pyproject.toml` to auto-detect every `async def` test function. With auto mode, you just write `async def test_thing():` and Pytest awaits it inside an event loop. For fixtures that need to be async (e.g., creating an `AsyncClient` for an httpx test, or an `AsyncSession` for SQLAlchemy), use `@pytest_asyncio.fixture` instead of `@pytest.fixture`.
Common gotcha: the default event loop is function-scoped, so a session-scoped async fixture won't work without configuring `event_loop_scope`. In 2026, FastAPI / aiohttp / async SQLAlchemy projects almost universally use pytest-asyncio. The loop-scope story changed materially in the 1.x line, and this is the question that separates people who have actually maintained an async suite.
Overriding the `event_loop` fixture, which every older Stack Overflow answer recommends, was deprecated and then removed; the supported replacements are the ini key `asyncio_default_fixture_loop_scope` and explicit `loop_scope="session"` arguments on `@pytest_asyncio.fixture` and `@pytest.mark.asyncio`. If you leave `asyncio_default_fixture_loop_scope` unset, Pytest emits a deprecation warning on every run telling you the default will change. The failure you will actually hit in production code is `RuntimeError: Task ... attached to a different loop` or `Future attached to a different loop`: it happens when a session-scoped resource such as an asyncpg pool or an aiohttp session is created on one loop and awaited on a per-test loop.
The fix is to match scopes, `loop_scope="session"` on both the fixture and the tests that use it. Also remember that `asyncio_mode = "strict"` is the default, so an unmarked `async def test_x()` is collected, skipped with a warning, and reports as passing without ever running. `anyio`'s pytest plugin is the alternative if you need to run the same suite on both asyncio and trio.
# pyproject.toml
# [tool.pytest.ini_options]
# asyncio_mode = "auto"
import pytest_asyncio
import httpx
@pytest_asyncio.fixture
async def client():
async with httpx.AsyncClient(base_url="http://test") as c:
yield c
async def test_get_user(client):
r = await client.get("/users/1")
assert r.status_code == 200Q20How do you measure test coverage with pytest-cov?
IntermediateCoverage
Answer
`pytest-cov` integrates `coverage.py` into Pytest. Install it, then run `pytest --cov=myapp` to get a coverage report for the `myapp` package. Add `--cov-report=html` for a clickable HTML report, `--cov-report=term-missing` to show uncovered lines inline, and `--cov-fail-under=80` to fail the run if coverage drops below 80%.
In CI, the `--cov-fail-under` flag is what stops new code from regressing coverage. Configuration goes in `.coveragerc` or `pyproject.toml`: you typically exclude `tests/`, `__main__.py`, and any auto-generated code. Note that 100% line coverage doesn't mean 100% branch coverage, pass `--cov-branch` to track if both sides of every `if` were tested.
Aim for >80% in business logic; testing one-line getters is usually not worth it. Three operational details. Under pytest-xdist each worker measures its own data, so you need `parallel = true` in the coverage config and the plugin combines the `.coverage.*` files at the end; forget it and your reported percentage collapses to whatever one worker happened to run.
Code executed in a subprocess (a Celery worker, a `subprocess.run` of your own CLI) is invisible unless you set `COVERAGE_PROCESS_START` and call `coverage.process_startup()` from a `.pth` file. And anything imported before measurement starts is counted as missed at the module level, which is why `pytest --cov=myapp` (the plugin starts coverage before collection) reports differently from `coverage run -m pytest`. Two flags worth adopting: `--cov-context=test` records which test covered each line, so the HTML report tells you WHO exercised a branch, invaluable when deleting dead code; and `--no-cov` when you are dropping into `pdb`, because the tracer makes stepping painfully slow. On Python 3.12+ set `COVERAGE_CORE=sysmon` to use the `sys.monitoring` backend, which cuts coverage overhead substantially compared with the classic `settrace` tracer.
# Run with coverage
pytest --cov=myapp --cov-report=term-missing --cov-fail-under=80
# Output:
# Name Stmts Miss Cover Missing
# --------------------------------------------------
# myapp/__init__.py 1 0 100%
# myapp/auth.py 45 3 93% 33-35
# myapp/payments.py 70 7 90% 54-60
# --------------------------------------------------
# TOTAL 116 10 91%
# pyproject.toml
[tool.coverage.run]
source = ["myapp"]
branch = true
[tool.coverage.report]
exclude_lines = ["pragma: no cover", "raise NotImplementedError"]Q21How do you run tests in parallel with pytest-xdist?
IntermediatePerformance
Answer
`pytest-xdist` distributes tests across multiple CPU cores (or even remote machines). Install it and run `pytest -n auto` to use one worker per CPU core, or `pytest -n 4` for a fixed count. Tests are distributed in chunks; the scheduler balances load.
Caveats: (1) Tests must be independent, any shared mutable state (a session-scoped fixture that mutates a counter, a global SQLite file) will cause flakiness. (2) Use `--dist=loadgroup` with `@pytest.mark.xdist_group(name='db')` to keep a group of tests on the same worker, useful when you have a real shared resource. (3) Database-using tests usually need a unique schema/database per worker, read `PYTEST_XDIST_WORKER` env var to derive the name. In CI, xdist is the single biggest win for fast feedback on test suites with hundreds of tests. Know the distribution modes, because picking the wrong one is the usual cause of a suite that gets slower under `-n`: `--dist=load` (default) hands out individual tests round-robin, `loadfile` keeps a whole file on one worker, `loadscope` keeps a class or module together, `loadgroup` honours `@pytest.mark.xdist_group`, and `worksteal` lets idle workers pull work from busy ones, which beats `load` when test durations vary wildly.
Session-scoped fixtures run once PER WORKER, so `-n 16` means sixteen browser launches or sixteen schema creations, and the setup cost can swamp the parallelism win on a suite of two hundred fast tests. Operationally: `-s` is effectively useless because worker output is captured and replayed at the end, `--pdb` cannot attach, and `--maxfail` is approximate since in-flight tests on other workers still finish. `PYTEST_XDIST_WORKER` (`gw0`, `gw1`, ...) and `PYTEST_XDIST_WORKER_COUNT` are the environment variables to key per-worker databases, temp directories and port numbers off. When a run is green serially and red under `-n auto`, reproduce with `-n 2 -p no:randomly` first, it is far easier to read than sixteen interleaved workers.
# Run on all cores
pytest -n auto
# Run on 4 workers, distribute by file
pytest -n 4 --dist=loadfile
# Per-worker database setup
import os
import pytest
@pytest.fixture(scope="session")
def db_url():
worker = os.environ.get("PYTEST_XDIST_WORKER", "main")
return f"postgresql://localhost/test_{worker}"
# Keep related tests on same worker
@pytest.mark.xdist_group(name="db_migrations")
def test_migration_1(): ...Q22How do you parametrize a fixture?
IntermediateParametrization
Answer
A fixture can take the `params` argument: every test that uses the fixture is run once per parameter value. Access the current value inside the fixture via `request.param`. This is powerful for testing across multiple backends, e.g., running the same suite against MySQL, PostgreSQL, and SQLite without duplicating the tests.
The `ids` parameter customizes the test ID for each value. Difference from `@pytest.mark.parametrize`: that decorates a test function (multiple inputs); fixture params parametrize the FIXTURE (so any test using it gets multiplied). You can combine both, multiplying the cases.
Mechanics worth knowing: `request.param` only exists when the fixture is parametrized, so a fixture that reads it unconditionally raises `AttributeError: 'SubRequest' object has no attribute 'param'` the moment someone uses it without params; guard with `getattr(request, "param", default)` if the fixture must work both ways. Individual param values accept `pytest.param("mysql", marks=pytest.mark.skipif(not has_mysql(), reason="no server"))`, which is how you keep a backend in the matrix without failing local runs. Because the multiplication happens at collection, `pytest --collect-only -q | wc -l` is the honest way to see what you just created: a parametrized session fixture with three backends crossed with a parametrized test of five inputs is fifteen cases, and if the fixture spins up a container each time, your CI bill notices.
Pytest also groups tests by higher-scoped param value so each backend is set up once rather than once per test, which changes execution order. To trim the matrix at runtime, `-k "not mysql"` filters on the generated IDs, which is another reason to give params readable `ids`.
import pytest
@pytest.fixture(params=["sqlite", "postgresql", "mysql"], ids=["sqlite", "pg", "mysql"])
def db(request):
backend = request.param
conn = create_connection(backend)
yield conn
conn.close()
def test_insert_and_read(db):
db.execute("INSERT INTO users VALUES (1, 'Aarav')")
row = db.execute("SELECT * FROM users").fetchone()
assert row == (1, "Aarav")
# This single test produces 3 cases:
# test_insert_and_read[sqlite]
# test_insert_and_read[pg]
# test_insert_and_read[mysql]Q23What is indirect parametrization and when is it useful?
IntermediateParametrization
Answer
Normally `@pytest.mark.parametrize("x", [1, 2, 3])` passes `x` directly to the test function. With `indirect=True`, the parameter values are passed through a fixture first, Pytest sees the parameter name `x`, finds a fixture also named `x`, and calls it with `request.param` set to each value. The fixture transforms or wraps the raw param.
Use case: you want to parametrize over usernames but each test needs the full User object. Without indirect, you'd have to construct the User inside the test (boilerplate); with indirect, the fixture does the construction once per parameter set. Three things to get right. `indirect` can be a list rather than a boolean, so `@pytest.mark.parametrize("user, expected", [...], indirect=["user"])` routes only `user` through its fixture while `expected` is passed straight to the test; that partial form is what you actually use most of the time.
If no fixture of that name is visible, you get `fixture 'user' not found` at setup, and the reverse case is nastier: leave `indirect` off when a fixture of the same name exists and the parametrize value silently SHADOWS the fixture, so your test receives the string `"admin"` instead of a `User` object and fails with a confusing `AttributeError`. Test IDs are generated from the raw parameter values, not from whatever the fixture returns, which is a feature: `test_user_permissions[admin]` stays readable even though the object is a full ORM row. The cost is that the fixture body runs once per parameter per test, so if it inserts a database row, indirect parametrization multiplies your write volume, and combining it with a session-scoped resource needs the usual scope discipline.
import pytest
@pytest.fixture
def user(request):
role = request.param
return User(role=role, email=f"{role}@example.com")
@pytest.mark.parametrize("user", ["admin", "member", "viewer"], indirect=True)
def test_user_permissions(user):
assert user.email.endswith("@example.com")
if user.role == "admin":
assert user.can_delete
# Generates: test_user_permissions[admin], [member], [viewer]
# Each gets a real User object from the fixture, not just the string.Q24How do you share state between tests deliberately? When is it OK?
IntermediateFixtures
Answer
The default assumption is that tests should be independent, any shared state leaks bugs from one test to another. But there are legitimate cases: a step-by-step integration test where step 2 depends on step 1's side effects (rare and usually a smell), or accumulating data across many parametrized cases for a final summary assertion. Pytest offers two tools: (1) higher-scope fixtures (`module`/`session`) that hold state across tests in their scope, but mutations are dangerous. (2) The `pytest-ordering` plugin or `@pytest.mark.dependency` from `pytest-dependency` to enforce order.
The right answer in 99% of cases: refactor to remove the coupling, use a setup fixture per test, and let xdist parallelize freely. Shared mutable state across tests is technical debt that makes flaky tests harder to debug. When you genuinely need a channel, Pytest gives you two sanctioned ones rather than a module-level global.
The `cache` fixture (`request.config.cache.set("key", value)` / `.get("key", default)`) writes JSON into `.pytest_cache` and survives across RUNS, which is what `--lf` itself is built on; clear it with `--cache-clear` and inspect it with `--cache-show`. For plugin and hook code, `item.stash[MY_KEY]` with a module-level `MY_KEY = pytest.StashKey[dict]()` is the type-safe way to attach data to a test item without colliding with other plugins. The failure mode people miss is process boundaries: under pytest-xdist every worker is a separate Python process, so module-level globals, class attributes and session fixtures are NOT shared.
An accumulator test that asserts 'we saw 50 records' passes serially and fails under `-n 4` because each worker only saw a fraction, and the `cache` directory is written by whichever worker finishes last. If ordering really is required, `pytest-order` and `pytest-dependency` express it explicitly, and both should come with a ticket to remove them.
import pytest
# Sanctioned cross-run store: .pytest_cache
def test_records_baseline(request):
latency = measure_p95()
previous = request.config.cache.get("perf/p95", None)
request.config.cache.set("perf/p95", latency)
if previous is not None:
assert latency < previous * 1.2
# Type-safe per-item storage for plugin code (conftest.py)
START_KEY = pytest.StashKey[float]()
def pytest_runtest_setup(item):
item.stash[START_KEY] = time.time()
# ANTI-PATTERN: module global, invisible to xdist workers
SEEN = []
def test_a():
SEEN.append(1)
def test_b():
assert len(SEEN) == 1 # fails under -n 2Key Points
- Default: tests should be independent and order-agnostic
- Higher-scope fixtures for SHARED READ-ONLY resources only
- Mutable shared state breaks pytest-xdist parallelism
- If you need order, you probably need a refactor
Q25How do you test database code with Pytest?
IntermediateDatabase Testing
Answer
Three common patterns in production Python codebases: (1) **Transactional rollback**, wrap each test in a transaction that rolls back at the end. Fastest for SQLAlchemy/Django, but fails if your code under test commits explicitly. (2) **Per-test database / schema**, create a fresh schema per worker (combined with pytest-xdist) and run migrations once at session start. Slower but completely isolated. (3) **Truncate after each test**, let tests commit, then truncate all tables.
Useful when testing code that uses explicit transactions. Avoid SQLite as a stand-in for Postgres or MySQL, their behavior diverges enough that 'green tests, red production' bugs are common (case sensitivity, JSON support, isolation levels). Use Testcontainers or `pytest-postgresql` for a real Postgres in CI.
The rollback pattern has one well-known hole: if the code under test calls `session.commit()`, the outer transaction is gone and later tests see the data. The fix is nested transactions, run the test inside a SAVEPOINT so an inner commit only releases the savepoint. In SQLAlchemy 2.0 you get this by binding the session to an existing connection with `join_transaction_mode="create_savepoint"`; older code did the same with an `after_transaction_end` event listener that restarts the savepoint.
Two things rollback does NOT undo: sequence and identity counters keep advancing, so never assert `order.id == 1`, and anything your code pushed to Redis, S3 or a message queue during the test stays there. For parallel runs, `CREATE DATABASE test_gw0 TEMPLATE test_template` is much faster than re-running migrations per worker, since you migrate once into the template at session start. Also pin `poolclass=NullPool` in tests so a leaked connection surfaces immediately instead of being recycled, and keep timezone handling explicit: store UTC and compare against timezone-aware datetimes, because a suite that passes on a UTC CI runner and fails on a developer machine set to IST is a weekly occurrence otherwise.
import pytest
from sqlalchemy.orm import sessionmaker
@pytest.fixture(scope="session")
def engine():
e = create_engine("postgresql://test_user@localhost/test_db")
Base.metadata.create_all(e)
yield e
Base.metadata.drop_all(e)
e.dispose()
@pytest.fixture
def db_session(engine):
conn = engine.connect()
txn = conn.begin()
Session = sessionmaker(bind=conn)
session = Session()
yield session
session.close()
txn.rollback() # back to clean state
conn.close()Q26What is pytest-django and how does it differ from Django's TestCase?
IntermediatePlugins
Answer
`pytest-django` is the Pytest plugin that adds first-class Django support: fixture-based DB access (`db`, `transactional_db`, `django_db`), a `client` fixture that's a Django test client, `settings` override fixtures, and `--reuse-db` to avoid migrating between runs. The big differences from Django's built-in `TestCase`: you use `@pytest.mark.django_db` instead of subclassing, you can use Pytest fixtures alongside Django ones, and `--reuse-db` makes test runs much faster after the first one. Common gotcha: tests that hit the DB must be marked with `@pytest.mark.django_db` (or use the `db` fixture), otherwise Django blocks DB access to prevent accidents.
For full isolation, `transactional_db` rolls back at end of test; for tests that need their own transactions, `transactional_db` recreates the DB instead. Practical configuration: `DJANGO_SETTINGS_MODULE = "myproj.settings.test"` goes under `[tool.pytest.ini_options]` so you never have to export it, `--reuse-db` keeps the test database between runs and `--create-db` forces a rebuild after a migration lands, and `--no-migrations` builds the schema straight from the models, which on a project with several hundred migrations turns a two-minute startup into seconds (at the cost of not testing the migrations themselves, so keep one CI job that runs them). The fixtures that earn their keep: `settings` for per-test overrides that are restored afterwards, `rf` for a bare `RequestFactory` when you are unit-testing a view function, `admin_client` for a pre-authenticated superuser session, `live_server` for Selenium or Playwright, `mailoutbox` for asserting on sent email, and `django_assert_num_queries(5)` which is the cheapest N+1 regression guard you can add to a checkout or listing endpoint. The error every newcomer hits is `RuntimeError: Database access not allowed, use the "django_db" mark, or the "db" or "transactional_db" fixtures to enable it`, and it usually means a module-level query is running at import time.
import pytest
from myapp.models import Order
@pytest.mark.django_db
def test_create_order(client):
response = client.post("/orders", {"amount": 500})
assert response.status_code == 201
assert Order.objects.count() == 1
@pytest.fixture
def premium_user(db, django_user_model):
return django_user_model.objects.create_user(
username="priya", plan="premium"
)
def test_premium_discount(client, premium_user):
client.force_login(premium_user)
response = client.get("/checkout?total=1000")
assert response.context["discount"] == 100Q27How do you use tmp_path and tmp_path_factory?
IntermediateBuilt-in Fixtures
Answer
`tmp_path` is a function-scoped fixture that returns a unique `pathlib.Path` object, a fresh temporary directory just for this test. Use it for any test that needs to read/write files. The directory is automatically cleaned up after the test (Pytest keeps the last 3 runs by default for debugging, configurable via `tmp_path_retention_count`).
For session/module-scoped temp directories, use `tmp_path_factory` and call `.mktemp("name")` to create subdirectories. This replaces the older `tmpdir` fixture (which used `py.path.local`); `tmp_path` is preferred in modern Pytest because it uses standard `pathlib.Path`. Tests using `tmp_path` are safely parallel-friendly because each test gets its own directory.
Where the files actually land matters when you are debugging a failure: the base is `/tmp/pytest-of-<user>/pytest-<N>/<sanitised-test-name><index>`, where `<N>` increments per run and a `pytest-current` symlink points at the newest one, so after a failing run you can go and inspect the artefacts the test left behind. Retention is controlled by `tmp_path_retention_count` (default 3) and `tmp_path_retention_policy`, which takes `all`, `failed` (keep only directories from failing tests) or `none`; setting it to `failed` is the sensible CI default so a long run does not fill the disk. The test name in the path is truncated to 30 characters, so two long parametrized IDs can produce confusingly similar directories. `--basetemp=DIR` relocates everything, but be careful: Pytest CLEARS that directory at startup, so pointing it at a real folder deletes its contents. One macOS-specific trap: `/tmp` is a symlink to `/private/tmp`, so a test that compares `str(tmp_path)` against a path the code under test resolved with `os.path.realpath()` fails locally while passing on Linux CI; compare `Path` objects after `.resolve()` on both sides.
import json
def test_writes_config_file(tmp_path):
config_path = tmp_path / "settings.json"
config_path.write_text(json.dumps({"debug": True}))
assert json.loads(config_path.read_text())["debug"] is True
@pytest.fixture(scope="session")
def shared_workdir(tmp_path_factory):
return tmp_path_factory.mktemp("workdir")
def test_uses_shared(shared_workdir):
(shared_workdir / "data.txt").write_text("shared")Q28How does monkeypatch work and when should you use it over mocker?
IntermediateMocking
Answer
`monkeypatch` is a built-in Pytest fixture for temporarily patching attributes, environment variables, dict entries, or `sys.path`. It cleans up automatically at test end. Common uses: `monkeypatch.setenv('API_KEY', 'test-123')`, `monkeypatch.setattr('mymodule.CONFIG', {...})`, `monkeypatch.chdir(tmp_path)`.
Difference from `mocker`: monkeypatch is best for replacing values, env vars, and simple attribute swaps; `mocker` (pytest-mock) is best when you need a full `MagicMock` with `.assert_called_with(...)` introspection. Many teams use monkeypatch for plumbing (env, config) and mocker for behavior (function/class mocking). Both clean up automatically, never use `os.environ['X'] = ...` in a test, because that leak escapes test isolation.
The full API is small and worth memorising: `setattr`, `delattr`, `setitem`, `delitem`, `setenv`, `delenv`, `syspath_prepend` and `chdir`. `setattr` and `delattr` take `raising=False` when the attribute may legitimately not exist yet, which also disables the typo protection, so use it sparingly. `setenv` requires a string value and raises `TypeError` if you hand it an int, and it accepts `prepend=os.pathsep` to push onto `PATH` rather than replace it. The scope limitation catches everyone: `monkeypatch` is function-scoped, so requesting it from a session-scoped fixture raises `ScopeMismatch`. The supported workaround is to instantiate your own, `with pytest.MonkeyPatch.context() as mp:` inside the fixture, or construct `mp = pytest.MonkeyPatch()` and call `mp.undo()` in the teardown.
Remember that restoration re-binds the original object rather than deep-copying it, so `monkeypatch.setattr(settings, "FEATURES", settings.FEATURES)` followed by an in-place `FEATURES["beta"] = True` mutation is not undone; use `monkeypatch.setitem(settings.FEATURES, "beta", True)` for dict entries. And `monkeypatch.setattr("orders.send_email", fake)` obeys the same 'patch where the name is looked up' rule as `mocker.patch`.
def get_api_key():
import os
return os.environ["GOODSPACE_API_KEY"]
def test_get_api_key_uses_env(monkeypatch):
monkeypatch.setenv("GOODSPACE_API_KEY", "test-key-123")
assert get_api_key() == "test-key-123"
def test_temp_chdir(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
# all code under test now runs as if cwd is tmp_path
def test_swap_attribute(monkeypatch):
import mymodule
monkeypatch.setattr(mymodule, "MAX_RETRIES", 1)
# mymodule.MAX_RETRIES is back to original after testQ29How does fixture overriding work across conftest.py levels, and how do you extend a fixture instead of replacing it?
IntermediateFixtures
Answer
Fixture lookup walks from the test outward: a fixture defined in the test module beats one in the nearest `conftest.py`, which beats one in a parent directory's `conftest.py`, which beats one supplied by an installed plugin. Same name, closest definition wins. That is the sanctioned way to specialise shared setup, for example a root `client` fixture returning an anonymous HTTP client while `tests/admin/conftest.py` defines a `client` that is already logged in as staff.
The trick most people do not know is that an overriding fixture can REQUEST the fixture it overrides by the same name as a parameter: Pytest resolves that request to the next definition up the chain rather than recursing forever. So `def client(client): client.headers['Authorization'] = token; return client` extends the parent instead of duplicating it, which keeps the base definition as the single source of truth. Two hazards.
The override may declare a different scope, and Pytest will not warn you, so a session-scoped parent quietly becomes function-scoped for everything under that directory and setup cost jumps. And overriding a fixture that a plugin's other fixtures depend on changes behaviour for those too, which is how a customised `event_loop` or `db` fixture breaks half a plugin. `pytest --fixtures-per-test tests/admin/test_users.py` prints, per test, which fixtures are in play and the exact file each came from; use it before assuming which definition is active.
# tests/conftest.py
import pytest
@pytest.fixture
def client(app):
return app.test_client()
# tests/admin/conftest.py, EXTEND rather than duplicate
@pytest.fixture
def client(client, staff_token): # 'client' here = parent fixture
client.headers["Authorization"] = f"Bearer {staff_token}"
return client
# tests/admin/test_users.py
def test_admin_can_list_users(client):
assert client.get("/admin/users").status_code == 200
# Which definition is actually used?
# pytest --fixtures-per-test tests/admin/test_users.py
# client -- tests/admin/conftest.py:6Q30What is pytest_generate_tests and when do you need it instead of @pytest.mark.parametrize?
IntermediateParametrization
Answer
`pytest_generate_tests(metafunc)` is a collection hook called once for every test function Pytest finds, giving you a programmatic way to parametrize. Inside it you inspect `metafunc.fixturenames` to see what the test asked for and call `metafunc.parametrize("argname", values, ids=..., indirect=...)` to expand it. Reach for it when the parameter set is not known when the file is written: values coming from a CLI option (`metafunc.config.getoption("--browser")`), from a directory of golden files on disk, from an enum you do not want to restate, or from a marker you read via `metafunc.definition.get_closest_marker("cases")`.
The decorator is better for anything static, because it keeps the data next to the test. Two rules keep this out of trouble. Always guard with `if "argname" in metafunc.fixturenames`, otherwise the hook tries to parametrize every test in the suite and you get `uses no argument 'browser'` errors.
And the generated set must be DETERMINISTIC: iterate a sorted list, not a `set`, and never query a live database or a remote API from the hook. Non-deterministic generation is the classic cause of `Different tests were collected between gw0 and gw1` under pytest-xdist, because each worker collects independently and the two lists must match exactly. Unstable IDs also break `--lf` and `--deselect`, since both are keyed on node ID strings.
# conftest.py
def pytest_addoption(parser):
parser.addoption("--browser", action="append", default=[])
def pytest_generate_tests(metafunc):
if "browser" in metafunc.fixturenames:
chosen = metafunc.config.getoption("browser") or ["chromium"]
metafunc.parametrize("browser", chosen, indirect=True)
if "golden_file" in metafunc.fixturenames:
# sorted() keeps collection identical across xdist workers
files = sorted(Path("tests/golden").glob("*.json"))
metafunc.parametrize(
"golden_file", files, ids=[f.stem for f in files]
)
# pytest --browser=chromium --browser=firefox
# test_login[chromium]
# test_login[firefox]Q31How do you test code that depends on the current date and time?
IntermediateTime
Answer
The cheapest fix is design: give the code a seam, either a `now: Callable[[], datetime] = datetime.now` default argument or a `Clock` dependency, so the test injects a fixed instant and no patching is needed. When you cannot change the code, `freezegun` (`@freeze_time("2026-04-01T09:30:00+05:30")`, or `with freeze_time(...) as frozen: frozen.tick(60)`) patches `datetime.now`, `date.today` and `time.time` globally, and `time-machine` does the same job faster by hooking at the C level, which matters if you are freezing time inside a tight loop. Know the limits, because they are what break real suites.
Freezing the Python clock does not touch the DATABASE clock, so a column with `server_default=func.now()` still records the real timestamp and your assertion comparing the two fails. C extensions and Rust-backed libraries that captured their own clock reference are unaffected. And code that polls with a timeout can hang forever under a frozen clock because the deadline never arrives, so pair time freezing with `pytest-timeout`.
On the assertion side, the recurring India-specific bug is naive datetimes: `datetime.now()` returns the runner's local time, so a test passes on an IST laptop and fails on a UTC CI box, or vice versa at the 18:30 IST boundary where the UTC date rolls over. Store and compare timezone-aware UTC, converting with `ZoneInfo("Asia/Kolkata")` only at the presentation edge. Note that `datetime.utcnow()` is deprecated from Python 3.12; use `datetime.now(timezone.utc)`.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from freezegun import freeze_time
import pytest
# Preferred: an injectable clock, no patching at all
def is_business_hours(now=None):
now = (now or datetime.now(timezone.utc)).astimezone(ZoneInfo("Asia/Kolkata"))
return 9 <= now.hour < 19 and now.weekday() < 6
@pytest.mark.parametrize("iso, expected", [
("2026-04-01T04:30:00+00:00", True), # 10:00 IST Wednesday
("2026-04-01T15:00:00+00:00", False), # 20:30 IST Wednesday
])
def test_business_hours(iso, expected):
assert is_business_hours(datetime.fromisoformat(iso)) is expected
# Fallback when the code has no seam
@freeze_time("2026-04-01T09:30:00+05:30")
def test_invoice_due_date():
assert invoice_due_date().isoformat() == "2026-04-16"Q32How do you use Hypothesis with Pytest for property-based testing?
IntermediateProperty-Based Testing
Answer
Hypothesis generates inputs instead of you enumerating them. Decorate a test with `@given(st.integers(min_value=0), st.text())` and it runs the body many times with generated values, and when it finds a failure it SHRINKS the input to the smallest reproducing case before reporting it, so you get `amount=0` rather than `amount=847362`. It is the right tool for invariants rather than examples: round-trip properties (`parse(render(x)) == x`), ordering and idempotence, money splits that must sum back to the total, and parsers and validators where the interesting inputs are the ones you would never think to write. `@settings(max_examples=500, deadline=timedelta(milliseconds=400))` tunes the run, and `@example("+919876543210")` pins a specific regression case so it is always tried first.
Two integration details matter with Pytest. Hypothesis stores failing examples in a local `.hypothesis/examples` database and replays them on the next run, which is why a fixed test sometimes still fails locally and why CI containers, starting empty, do not reproduce your failure. And function-scoped fixtures are set up ONCE for the whole `@given` block, not per generated example, so a `db_session` fixture leaks state between examples; Hypothesis raises `HealthCheck.function_scoped_fixture` to tell you. Either move the setup inside the test body or suppress the check deliberately once you understand it. `DeadlineExceeded` failures on a loaded CI runner are usually noise, so raise or disable `deadline` there rather than chasing them.
from decimal import Decimal
from hypothesis import given, settings, example, strategies as st
@given(
total=st.decimals(min_value=Decimal("0.01"), max_value=Decimal("1000000"),
places=2),
parts=st.integers(min_value=1, max_value=12),
)
@example(total=Decimal("0.03"), parts=2) # pin a known edge case
@settings(max_examples=300)
def test_emi_split_sums_to_total(total, parts):
instalments = split_emi(total, parts)
assert len(instalments) == parts
assert sum(instalments) == total # no paisa may be lost
assert all(i > 0 for i in instalments)
# Round-trip property
@given(st.text())
def test_slug_roundtrip(raw):
assert unslugify(slugify(raw)) == raw.strip().lower()Q33How do you keep a test suite off the network while still catching API contract drift?
IntermediateExternal APIs
Answer
Layer it. First, make accidental network access impossible: `pytest-socket` with `--disable-socket --allow-unix-socket` in `addopts` turns any real connection into `SocketBlockedError` naming the test, which is the only reliable way to find the one test that has been quietly calling Razorpay's sandbox for a year. Second, stub at the HTTP boundary rather than at your own function, so the serialisation and error handling in your client code is still exercised: `responses` for `requests`, `respx` for `httpx`, `aioresponses` for aiohttp.
Registering a specific status and body lets you test the paths that matter, a 429 with `Retry-After`, a 502, a truncated JSON payload, which mocking your own wrapper would skip entirely. Third, for contract drift use `vcrpy` through `pytest-recording`: mark a test `@pytest.mark.vcr` and the first run records the real exchange into `tests/cassettes/<test_name>.yaml`, subsequent runs replay it. Run CI with `--record-mode=none` so an unmatched request fails instead of silently hitting the internet.
Two rules for cassettes: scrub credentials with `filter_headers=["authorization", "x-api-key"]` and `filter_post_data_parameters`, because otherwise you commit a live key to the repo, and schedule a nightly job with `--record-mode=all` against the real sandbox, since a replayed cassette will happily keep passing for months after the provider changed its response shape. That nightly job is the part teams skip, and it is the entire point of recording.
# pyproject.toml
# [tool.pytest.ini_options]
# addopts = "--disable-socket --allow-unix-socket"
import pytest, respx, httpx
@respx.mock
def test_retries_on_429():
route = respx.post("https://api.razorpay.com/v1/orders").mock(
side_effect=[
httpx.Response(429, headers={"Retry-After": "1"}),
httpx.Response(200, json={"id": "order_XYZ", "status": "created"}),
]
)
order = create_order(amount=50000)
assert order["id"] == "order_XYZ"
assert route.call_count == 2
# Recorded contract test
@pytest.mark.vcr(filter_headers=["authorization"])
def test_fetch_payment_contract():
payment = fetch_payment("pay_123")
assert payment["currency"] == "INR"
# CI: pytest --record-mode=none
# Nightly: pytest --record-mode=all -m vcrQ34What do --strict-markers, --strict-config and filterwarnings = error actually do, and why turn them on?
IntermediateConfiguration
Answer
All three convert silent no-ops into loud failures, which is the highest-leverage configuration change most Python repos can make. `--strict-markers` makes an unregistered marker a collection error, so `@pytest.mark.smoek` fails the build instead of creating a marker that matches nothing; without it, `pytest -m smoke` cheerfully runs zero tests and reports success, and your smoke gate has been dead for months. `--strict-config` does the same for the ini file: a misspelled key like `testpath` or `filterwarning` becomes `Unknown config option` rather than being ignored. `filterwarnings = ["error"]` promotes every warning to an exception at the point it is raised, so the traceback points at the line that triggered it. This is how you catch library deprecations while there is still time to act, `datetime.utcnow()` deprecated in Python 3.12, SQLAlchemy 2.0 `RemovedIn20Warning`, pandas chained-assignment warnings, instead of discovering them the day an upgrade removes the API. You will need targeted escapes for third-party noise you cannot fix, using the `action:message:category:module` form, and you should pin them narrowly (`ignore::DeprecationWarning:botocore.*`) rather than blanket-ignoring a category.
Per-test overrides go through `@pytest.mark.filterwarnings("ignore::UserWarning")`, and `pytest.warns(DeprecationWarning)` asserts that a warning IS raised. Pair the lot with `-ra`, which prints a summary of every skip, xfail and error at the end of the run so nothing hides in the dots.
# pyproject.toml
[tool.pytest.ini_options]
addopts = "-ra --strict-markers --strict-config"
markers = [
"smoke: fast sanity checks run on every push",
"slow: excluded from the PR gate",
]
filterwarnings = [
"error",
"ignore::DeprecationWarning:botocore.*",
"ignore:unclosed <socket:ResourceWarning",
]
# Per-test escape hatch
import pytest
@pytest.mark.filterwarnings("ignore::DeprecationWarning")
def test_legacy_path():
call_deprecated_helper()
# Assert a warning IS emitted
def test_v1_endpoint_warns():
with pytest.warns(DeprecationWarning, match=r"use /v2/jobs"):
legacy_jobs_endpoint()Q35What is the difference between a test FAILURE and an ERROR, and how do fixture finalizers behave when something raises?
IntermediateFixtures
Answer
Pytest runs three phases per test: setup, call, teardown. An exception during the call phase is a FAILURE (`F`), your assertion or your code was wrong. An exception during setup or teardown is an ERROR (`E` in the summary, shown as `ERROR at setup of test_x`), meaning the test never got a fair chance to run.
The distinction matters in CI triage: a wall of errors almost always points at one broken fixture or a missing service, not at hundreds of real regressions. The finalizer rules follow from that. In a yield fixture, everything after `yield` is teardown, and it runs ONLY if the code before `yield` completed; if setup raised halfway through, whatever you already allocated leaks.
When setup has several acquisition steps, register each one with `request.addfinalizer(fn)` immediately after it succeeds, since finalizers run in LIFO order and are still executed if a later step blows up. Teardown failures are reported separately from the test result, so a single test can show up as both `PASSED` and `ERROR at teardown`, and the run exits non-zero even though nothing failed. For higher-scoped fixtures, teardown happens after the LAST test in that scope, so a session fixture's cleanup error is attributed to a test that looks unrelated. Calling `pytest.skip()` inside a fixture skips every test that requests it, which is the clean way to express 'no Postgres available here'.
import pytest
@pytest.fixture
def stack(request):
container = start_container()
request.addfinalizer(container.stop) # runs even if the next line raises
conn = connect(container.dsn) # if THIS raises, container still stops
request.addfinalizer(conn.close)
return conn
# Equivalent yield form is NOT safe: if connect() raises,
# container.stop() after the yield never runs.
@pytest.fixture
def unsafe_stack():
container = start_container()
conn = connect(container.dsn) # raises -> leak
yield conn
conn.close()
container.stop()
# Skipping from a fixture skips every dependent test
@pytest.fixture(scope="session")
def pg(request):
if not postgres_reachable():
pytest.skip("no local Postgres on :5432")
return connect_pg()Q36How do you write a Pytest plugin and what are the most useful hooks?
AdvancedPlugins
Answer
A Pytest plugin is just a Python module exposing hook functions. Two ways to ship one: (1) **Local plugin**, define hooks in `conftest.py` for project-only customization. (2) **Installable plugin**, a package that registers itself via the `pytest11` entry point in `pyproject.toml`. The most useful hooks: `pytest_collection_modifyitems(config, items)`, reorder, deselect, or annotate tests after collection (e.g., apply a marker based on path). `pytest_runtest_setup(item)`, runs before each test, useful for global skip logic. `pytest_runtest_makereport(item, call)`, observe test outcomes for custom reporting. `pytest_addoption(parser)`, register CLI flags / config values. `pytest_configure(config)`, initialization at session start.
Real-world examples: pytest-cov uses `pytest_sessionstart` and `pytest_terminal_summary`; pytest-xdist uses collection hooks to distribute tests. Plugins are how Pytest scales, almost every team eventually writes a small one for company-specific patterns. Two mechanics to have ready.
Hook wrappers changed in Pytest 8: the old style is `@pytest.hookimpl(hookwrapper=True)` with `outcome = yield` and `outcome.get_result()`, while the new style is `@pytest.hookimpl(wrapper=True)` where the `yield` expression evaluates to the result directly and you must `return` it (or raise to replace it). The new form is cleaner and lets you transform the result, but it will not run on Pytest 7, so a published plugin needs a floor in its metadata. Ordering between competing implementations is controlled by `tryfirst=True` / `trylast=True`, and `firstresult` hooks stop at the first non-`None` return, which is why two plugins implementing `pytest_collection` can silently shadow each other.
Debug what is loaded with `pytest --trace-config`, disable one with `-p no:randomly`, force-load with `-p myplugin`. Finally, test your plugin with the built-in `pytester` fixture (enable it with `pytest_plugins = ["pytester"]` in the root conftest): it writes a throwaway project into a tmp dir, runs Pytest inside it, and gives you `result.assert_outcomes(passed=1, skipped=1)` plus `result.stdout.fnmatch_lines([...])`.
# conftest.py, local plugin example
import pytest
def pytest_addoption(parser):
parser.addoption("--env", default="dev", help="Test environment")
def pytest_collection_modifyitems(config, items):
# auto-mark anything under tests/integration/ as slow
for item in items:
if "tests/integration" in str(item.fspath):
item.add_marker(pytest.mark.slow)
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
if rep.when == "call" and rep.failed:
# log to internal observability
send_to_signoz(item.name, str(rep.longrepr))Key Points
- Local plugin in conftest.py vs installable plugin via `pytest11` entry point
- `pytest_collection_modifyitems` for reordering / auto-marking
- `pytest_addoption` for custom CLI flags
- `pytest_runtest_makereport` for custom reporting / observability
Q37How would you design tests for an async LLM-streaming API in 2026?
AdvancedAsync
Answer
Modern LLM APIs (used heavily at AI-Recruiter-Server and ChatGPT-style backends) stream tokens over Server-Sent Events. Test design has three layers: (1) **Unit tests** with `pytest-asyncio` + `httpx.AsyncClient`, mock the LLM provider (OpenAI, Anthropic) at the SDK level so you control the token stream. Use `mocker.patch` on the async iterator. (2) **Integration tests** that hit a real test endpoint but with deterministic prompts.
Use `pytest-recording` or `vcrpy` to record real API calls once and replay them in CI (cheaper than hitting OpenAI on every PR). (3) **End-to-end** smoke tests in staging only, too expensive and slow for every commit. Specific gotchas: async streaming tests can hang forever if the stream isn't closed, always set a `pytest.timeout` (via `pytest-timeout` plugin). Use `pytest.approx` for floating-point comparisons of token probabilities.
And cache the LLM responses aggressively, paying $50 in OpenAI bills per CI run is a real anti-pattern. Push further and there are four properties worth asserting that most candidates miss. (1) SSE framing itself: that each chunk is a well-formed `data: {...}\n\n` line, that the terminal sentinel (`data: [DONE]` for OpenAI-compatible endpoints) is emitted exactly once, and that a mid-stream provider error surfaces as an error event rather than a truncated but 200-status body, since a client that only checks the status code will treat a half-response as success. (2) Cancellation: simulate the browser disconnecting partway and assert the upstream request was closed, otherwise you leak provider connections and keep paying for tokens nobody reads. With anyio, catch `anyio.get_cancelled_exc_class()` and check your cleanup ran. (3) Determinism: even at `temperature=0` providers do not guarantee identical text, so assert on structure (the JSON parses into your Pydantic model, required fields present, tool-call name matches) rather than exact strings. (4) Timeouts: `pytest-timeout` with `--timeout=30 --timeout-method=thread` prevents a hung async generator from occupying a CI runner for an hour, because the default signal-based method does not interrupt a blocked `await`. Keep quality evaluation (LLM-as-judge, rubric scoring) in a nightly job with its own budget, never in the PR gate.
import pytest
import httpx
async def fake_token_stream():
for t in ["Hello", " ", "world", "!"]:
yield f"data: {{\"token\": \"{t}\"}}\n\n"
@pytest.mark.asyncio
@pytest.mark.timeout(10) # never hang
async def test_chat_stream(mocker, client):
mocker.patch("app.llm.openai_stream", return_value=fake_token_stream())
async with client.stream("POST", "/chat", json={"prompt": "Hi"}) as resp:
tokens = []
async for chunk in resp.aiter_text():
tokens.append(chunk)
assert "Hello world!" in "".join(tokens)Q38How do you debug a flaky test in Pytest?
AdvancedDebugging
Answer
Flaky tests are the most expensive bug class, they erode trust in CI and waste hours. Diagnostic playbook: (1) **Reproduce locally** with `pytest --count=20 path::flaky_test` (via `pytest-repeat`). If it never fails locally but fails in CI, the issue is environmental (parallelism, resource contention, timing). (2) **Check ordering dependency**: run with `pytest-randomly` to shuffle test order.
If reordering makes it fail, you have hidden state leaking between tests. (3) **Disable xdist temporarily** (`pytest -n0`). If parallel runs fail but serial passes, you have a shared mutable resource, find every `module`/`session`-scoped fixture that mutates. (4) **Inspect time-dependent assertions**, `assert response_time < 0.1` is asking for trouble; use percentiles or `pytest-benchmark`. (5) **Check for unawaited coroutines** in async tests, common cause of flakiness. (6) **Random seeds**, any `random` or `np.random` should be seeded in an autouse fixture. (7) **Date/time**, never compare `datetime.now()` directly; use a clock fixture or `freezegun`. Once identified, mark it `@pytest.mark.flaky` with `pytest-rerunfailures` ONLY while fixing, never as a permanent solution.
Make the investigation reproducible before you start guessing. `pytest-randomly` prints `Using --randomly-seed=1839472` at the top of every run, so copy that seed from the failing CI log and pass `-p randomly --randomly-seed=1839472` locally to replay the exact order; `-p no:randomly` pins the order back to file order when you want a stable baseline. Set `PYTHONHASHSEED` explicitly too, since dict and set iteration order over strings varies per process and is a real source of order-dependent behaviour in code that iterates a set. Bisect an order dependency with `pytest --lf` plus `-p no:randomly` and then halve the test list by hand, or use `pytest-random-order`'s bucket options.
Operationally, the important move is measurement: parse the JUnit XML your CI already produces (`--junitxml=report.xml`), count failures per node ID over a fortnight, and quarantine anything above a threshold into a `@pytest.mark.flaky` job that does not block merges but does file a ticket. If you use `pytest-rerunfailures`, scope it with `--only-rerun 'ConnectionError'` so genuine assertion failures are never retried into a false green.
# 1. Replay the exact CI ordering (seed comes from the CI log header)
pytest --randomly-seed=1839472 -x
# 2. Is it order-dependent at all?
pytest -p no:randomly # stable baseline
pytest -p randomly # shuffled
# 3. Is it a parallelism problem?
pytest -n0 tests/test_billing.py # serial
pytest -n2 -p no:randomly tests/ # minimal parallel repro
# 4. Repeat one test until it fails (pytest-repeat)
pytest --count=50 -x tests/test_billing.py::test_settlement
# 5. Deterministic environment for the run
PYTHONHASHSEED=0 TZ=UTC pytest
# 6. Quarantine while you fix, never permanently
# pytest -m "not flaky" <- PR gate
# pytest -m flaky --reruns 2 --only-rerun ConnectionError
# conftest.py: kill nondeterminism at the source
import random, pytest
@pytest.fixture(autouse=True)
def _deterministic():
random.seed(0)Key Points
- Reproduce with --count from pytest-repeat
- Detect order-dependency with pytest-randomly
- Disable xdist to find shared-state bugs
- Seed all RNGs in an autouse fixture
- Freeze time with freezegun for date-sensitive tests
- Never permanently mark flaky, fix or delete
Q39How do you organize a large test suite (10,000+ tests) for fast feedback?
AdvancedArchitecture
Answer
A test suite that takes 30 minutes is a productivity tax, engineers stop writing tests and rely on staging. Strategies (in order of impact): (1) **Tier tests with markers**, `smoke` (< 1 min, runs on every push), `unit` (< 5 min, runs on every PR), `integration` (< 15 min, runs nightly + on main), `e2e` (slow, runs in staging). Configure CI to require only the appropriate tier per stage. (2) **Parallelize with pytest-xdist**, `pytest -n auto` is mandatory at scale.
Combined with per-worker DB schemas, you can get linear speedup up to ~16 cores. (3) **Cache test artifacts**, use `--cache-show` and `--lf` aggressively during development; ship `pytest-testmon` to skip tests whose covered code didn't change. (4) **Profile the slow tests**, `pytest --durations=20` lists the 20 slowest. Often a single misbehaving fixture (slow DB seeding, real HTTP calls) dominates. Replace with mocks or in-memory equivalents. (5) **Split by domain**, Razorpay, Swiggy, and Postman all separate `tests/unit/`, `tests/integration/`, `tests/e2e/` and run them in parallel pipelines. (6) **Coverage gate by directory**, require 90% coverage in `core/` but only 60% in `experimental/`. Don't try to keep everything at the same bar.
# pyproject.toml: name the tiers once
[tool.pytest.ini_options]
addopts = "-ra --strict-markers --strict-config"
markers = [
"smoke: under 60s, runs on every push",
"integration: needs Postgres/Redis, runs on main",
"e2e: staging only",
]
# conftest.py: auto-tier by directory so nobody forgets the marker
import pytest
def pytest_collection_modifyitems(config, items):
for item in items:
path = str(item.path)
if "/tests/integration/" in path:
item.add_marker(pytest.mark.integration)
elif "/tests/e2e/" in path:
item.add_marker(pytest.mark.e2e)
# CI stages
# push : pytest -m smoke -x -q
# PR : pytest -m "not integration and not e2e" -n auto
# main : pytest -m "not e2e" -n auto --dist=loadfile
# night: pytest -n auto --durations=25 --junitxml=report.xmlQ40What's the difference between Pytest hooks `pytest_collection_modifyitems`, `pytest_runtest_setup`, and `pytest_runtest_call`? When does each fire?
AdvancedPlugins
Answer
Pytest's test lifecycle has three phases per test: setup (fixtures created), call (the test function runs), teardown (fixtures torn down). Each phase has matching hooks. `pytest_collection_modifyitems(config, items)` fires ONCE after all tests are collected but before any run, best for filtering, deselecting, reordering, or applying markers in bulk. `pytest_runtest_setup(item)` fires before each individual test's setup phase, good for global skip logic (e.g., 'skip everything tagged @gpu if no GPU available'). `pytest_runtest_call(item)` fires when the test function itself runs, almost never overridden because Pytest handles it. `pytest_runtest_teardown(item)` fires after the test, regardless of pass/fail. The 'wrapper' versions (with `@pytest.hookimpl(hookwrapper=True)` and `yield`) let you see the outcome, common for custom reporting.
Use the right hook level: per-collection vs per-test makes a 1000x performance difference at scale. The full session order is `pytest_addoption` then `pytest_configure`, then `pytest_collection` and `pytest_collection_modifyitems` and `pytest_collection_finish`, then per test `pytest_runtest_protocol` wrapping `pytest_runtest_logstart`, setup, call, teardown and `pytest_runtest_logreport`, and finally `pytest_sessionfinish` and `pytest_terminal_summary`. Two facts make or break real plugin code.
First, `pytest_runtest_makereport` fires THREE times per test, once per phase, so any implementation that does not branch on `rep.when in ("setup", "call", "teardown")` will triple-count failures or attribute a setup error to the call phase. Second, a fixture's teardown has no direct way to know whether the test passed, which is exactly what you need for 'save a screenshot or dump the DB only on failure'. The canonical recipe is a `makereport` wrapper that stashes each phase's report on the item, then a fixture that reads `item.stash` after its `yield`. On Pytest 8 write these as `@pytest.hookimpl(wrapper=True)` and `return` the report; the older `hookwrapper=True` with `outcome.get_result()` still works but is the legacy spelling.
import pytest
# Once, after collection
def pytest_collection_modifyitems(config, items):
for item in items:
if "slow" in item.keywords and not config.getoption("--run-slow"):
item.add_marker(pytest.mark.skip(reason="--run-slow not set"))
# Before EACH test's setup
def pytest_runtest_setup(item):
if "gpu" in item.keywords and not has_gpu():
pytest.skip("No GPU available")
# Around EACH test's call, wrapper sees the outcome
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item):
start = time.time()
yield
duration = time.time() - start
if duration > 1.0:
item.warn(pytest.PytestWarning(f"Slow test: {duration:.2f}s"))Q41What changed in Pytest 8.x that can break a suite or plugin written for Pytest 7?
AdvancedVersions
Answer
Four changes account for most upgrade breakage. (1) Nose support was removed in 8.0 after deprecation in 7.2, so bare `setup()` and `teardown()` methods on a test class are no longer called. They do not error, they simply stop running, and your tests keep passing against unconfigured state. Rename them to `setup_method`/`teardown_method` or convert to fixtures. (2) The collection tree was reworked: directories are now collected as `Dir` nodes and `Package` is no longer a subclass of `Module`, so plugin or conftest code doing `isinstance(node, pytest.Package)` or reaching for a package's module attributes needs updating. (3) `--import-mode=importlib` was substantially reworked in 8.0 and now, together with `consider_namespace_packages = true`, resolves test modules under their real dotted package name instead of an anonymous one; this fixes long-standing duplicate-basename problems but changes module identity, which matters if your code compares `__module__` strings. (4) Hook wrappers gained the `@pytest.hookimpl(wrapper=True)` style, where the `yield` evaluates to the result and you `return` it; the old `hookwrapper=True` with `outcome.get_result()` still works but new code should use the new form, and a plugin using it cannot support Pytest 7.
Also note the `py.path`-based `item.fspath` is legacy in favour of the `pathlib` `item.path`. Practical hygiene: pin `minversion` in the ini file, and run periodically with `-W error::DeprecationWarning` so Pytest's own `PytestRemovedIn*Warning` entries surface before the major that removes them.
# Removed in pytest 8.0 (nose style) - silently stops running
class TestOrders:
def setup(self): # NOT called any more
self.cart = Cart()
# Correct for pytest 8
class TestOrders:
def setup_method(self, method):
self.cart = Cart()
# Hook wrapper: old vs new
@pytest.hookimpl(hookwrapper=True) # pytest 7 style, still works
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
...
@pytest.hookimpl(wrapper=True) # pytest 8 style
def pytest_runtest_makereport(item, call):
rep = yield
...
return rep
# pyproject.toml
# [tool.pytest.ini_options]
# minversion = "8.0"
# consider_namespace_packages = trueKey Points
- Nose-style `setup()`/`teardown()` removed in 8.0, they fail silently
- `Dir` nodes added and `Package` is no longer a `Module` subclass
- `--import-mode=importlib` reworked, pairs with `consider_namespace_packages`
- `@pytest.hookimpl(wrapper=True)` replaces `hookwrapper=True` + `get_result()`
- Pin `minversion` and audit `PytestRemovedIn*Warning` before a major bump
Q42Collection fails with 'import file mismatch' or ModuleNotFoundError in CI but works locally. How do Pytest's import modes and sys.path actually work?
AdvancedImports
Answer
Pytest has three import modes. In the default `prepend` mode, Pytest takes a test file, walks up the directory tree as long as each level contains an `__init__.py`, and calls the first directory without one the 'basedir'. That basedir is inserted at the FRONT of `sys.path`, and the module is imported under the dotted name implied by the remaining path. `append` does the same but at the end of `sys.path`. `importlib` mode imports the file directly through the import machinery without mutating `sys.path` at all.
Every symptom follows from that. Two files named `test_utils.py` in directories that lack `__init__.py` both want the top-level module name `test_utils`, so the second one triggers `import file mismatch: imported module 'test_utils' has this __file__ attribute ... HINT: remove __pycache__ / unique basenames`.
A `tests/` directory pushed to the front of `sys.path` can shadow a real installed distribution, which is why a module named `email.py` or `types.py` under tests produces bizarre failures. And `ModuleNotFoundError: No module named 'myapp'` in CI but not locally almost always means the package is importable locally only because you run Pytest from the repo root and CI does not. The durable fix is a `src/` layout plus `pip install -e .` so your code is imported the same way in tests as in production, with `--import-mode=importlib` and `consider_namespace_packages = true` in the ini file. The `pythonpath = ["src"]` ini key is an acceptable shortcut for small repos.
# The error
# E import file mismatch:
# E imported module 'test_utils' has this __file__ attribute:
# E /srv/app/tests/api/test_utils.py
# E which is not the same as the test file we want to collect:
# E /srv/app/tests/jobs/test_utils.py
# E HINT: remove __pycache__ / .pyc files and/or use unique basenames
# Fix A: unique basenames
# tests/api/test_api_utils.py
# tests/jobs/test_jobs_utils.py
# Fix B: make each test dir a package
# tests/api/__init__.py
# tests/jobs/__init__.py
# Fix C (recommended): src layout + editable install
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
consider_namespace_packages = true
addopts = "--import-mode=importlib"
# CI: pip install -e . && pytestQ43A test passes locally but fails only in CI. What is your systematic diagnosis?
AdvancedDebugging
Answer
Treat it as an environment diff, not a test bug, and enumerate the axes. Timezone: CI runners are usually UTC while an Indian developer machine is IST, so any naive `datetime.now()` or date-boundary logic flips after 18:30 IST; reproduce with `TZ=UTC pytest`. Locale: `LC_ALL=C` changes string sorting, number formatting and `str.lower()` on non-ASCII.
Hash seed: `PYTHONHASHSEED` is random per process, so set-iteration order differs run to run. CPU count: `-n auto` spawns a different number of workers, changing scheduling and exposing shared-state bugs. Filesystem: macOS is case-insensitive and Linux is not, so `open("Config.yaml")` works on a laptop and fails in the container.
Missing state: a fresh container has no `.pytest_cache`, so `--lf` silently runs everything, and secrets injected locally from your shell profile are absent. Resource limits: exit code 137 means the OOM killer, not a test failure. Then instrument.
Run CI with `-ra -vv --tb=long -l --junitxml=report.xml` and archive both the XML and `--basetemp` output as artifacts. Set `faulthandler_timeout = 120` in the ini so a hung test dumps every thread's traceback instead of just timing out. Pytest exports `PYTEST_CURRENT_TEST` into the environment, so your application logs can tag which test was running when the crash happened. Finally, reproduce inside the same container image locally before changing any test code.
# Reproduce the CI environment locally
docker run --rm -it -v "$PWD:/app" -w /app python:3.12-slim bash -lc '
pip install -e ".[test]" &&
TZ=UTC LC_ALL=C PYTHONHASHSEED=0 pytest -ra -vv --tb=long -l
'
# pyproject.toml: make hangs and warnings debuggable
[tool.pytest.ini_options]
faulthandler_timeout = 120
addopts = "-ra --strict-markers"
# Application logging can name the running test
import os, logging
logging.info("charge failed during %s",
os.environ.get("PYTEST_CURRENT_TEST", "non-test run"))
# CI step: keep the evidence
# pytest --junitxml=report.xml --basetemp=./_tmp || true
# upload-artifact: report.xml, ./_tmpQ44How do you catch performance regressions with Pytest without producing a flaky timing test?
AdvancedPerformance
Answer
Never assert on wall-clock time on a shared CI runner: `assert elapsed < 0.1` fails the day someone else's job saturates the host, and that single test destroys trust in the suite. Prefer counting deterministic work. Query counts are the highest-value example, `django_assert_num_queries(5)` or a SQLAlchemy `before_cursor_execute` event counter turns an N+1 regression into a precise, hardware-independent failure: 'expected 5 queries, got 143'.
The same idea applies to counting outbound HTTP calls with `respx`/`responses` route `call_count`, cache hits, and serialised payload size. When you genuinely need timing, use `pytest-benchmark`: the `benchmark` fixture runs the callable many times, discards warmup, and reports statistics. Compare against a stored baseline with `--benchmark-autosave` then `--benchmark-compare=0001 --benchmark-compare-fail=min:10%`, and compare on `min` rather than `mean`, because the minimum is the least contaminated by noise from neighbouring processes.
Add `--benchmark-disable-gc` and keep benchmarks in a separate job pinned to consistent hardware, marked so the PR gate skips them. For memory, `pytest-memray` gives you `@pytest.mark.limit_memory("24 MB")` which fails when peak allocation exceeds the budget, and a plain `tracemalloc` snapshot diff catches a leak in a long-lived cache. Finally, keep `--durations=25` in the nightly run: it costs nothing and usually reveals that one badly scoped fixture, not the code, is the real regression.
import pytest
# 1. Deterministic: count the work, not the clock
def test_job_list_has_no_n_plus_one(client, django_assert_num_queries):
with django_assert_num_queries(5):
client.get("/api/jobs?page=1")
# 2. Timing, isolated from the PR gate
@pytest.mark.benchmark(group="parsing")
def test_resume_parser_speed(benchmark, sample_resume):
result = benchmark(parse_resume, sample_resume)
assert result.skills
# pytest --benchmark-autosave
# pytest --benchmark-compare=0001 --benchmark-compare-fail=min:10% \
# --benchmark-disable-gc -m benchmark
# 3. Memory budget (pytest-memray)
@pytest.mark.limit_memory("24 MB")
def test_bulk_import_is_streamed():
import_candidates("fixtures/50k_rows.csv")
# 4. Cheap always-on signal
# pytest --durations=25 --durations-min=0.5Q45Coverage is 92% but bugs keep shipping. How do you evaluate whether the tests actually assert anything?
AdvancedTest Quality
Answer
Line coverage measures execution, not verification. A test that imports a module and calls a function with no assertion covers every line inside it. Three techniques give you a real signal.
First, turn on branch coverage (`--cov-branch`): a function with an `if` reached only on the true path shows 100% line coverage and 50% branch coverage, and the gap is where bugs live. Second, mutation testing. `mutmut` and `cosmic-ray` mutate your source (`>` becomes `>=`, `+` becomes `-`, a return value becomes `None`, a line is deleted) and re-run the tests; a mutant that SURVIVES means no test noticed the behaviour change, which is a direct measurement of assertion quality. The cost is that the suite runs once per mutant, so scope it: `mutmut run --paths-to-mutate src/pricing.py` on the files that changed, in a nightly job, not on every PR.
Third, use `--cov-context=test` so the HTML report shows which test covered each line. Lines covered only incidentally, by a test whose name has nothing to do with them, are effectively untested. Complement all of this with `diff-cover`, which gates on coverage of the CHANGED lines in a pull request rather than the whole repo, so a 92% average cannot hide a completely untested new payment module. A quick manual smell test that costs nothing: delete a function body, replace it with `pass`, and see whether anything goes red.
# Branch coverage exposes the half-tested conditional
pytest --cov=src --cov-branch --cov-report=term-missing
# src/pricing.py 40 0 12 6 85% 22->25, 31->exit
# Mutation testing, scoped to what changed
mutmut run --paths-to-mutate src/pricing.py --runner "pytest -x -q -m 'not slow'"
mutmut results
# Survived mutants:
# src/pricing.py:34 if qty >= 10: -> if qty > 10:
# (no test covers the boundary at exactly 10)
# Who covered this line?
pytest --cov=src --cov-context=test --cov-report=html
# Gate the diff, not the repo average
diff-cover coverage.xml --compare-branch=origin/main --fail-under=90Key Points
- Line coverage proves execution; branch coverage (`--cov-branch`) proves both paths ran
- Mutation testing (mutmut, cosmic-ray) measures whether assertions catch behaviour changes
- Scope mutation runs to changed files in a nightly job, never the PR gate
- `--cov-context=test` reveals lines covered only incidentally
- `diff-cover` gates new code instead of hiding behind a repo-wide average
Frequently Asked Questions
Is Pytest worth learning in 2026 when AI can write tests?
Yes, and arguably more so. AI assistants now generate first-pass test code, but you still need to know the framework deeply to review, debug failures, and design the fixture architecture for a project. Every Python team in India still hires for Pytest fluency in 2026; it's table-stakes for a backend or ML engineering role. What has changed is where the value sits. Generating a test that calls a function is now trivial, so interviews have shifted toward the parts a model cannot infer from a single file: which fixture scope is safe given how your database is used, where the patch target actually is, why a suite is green serially and red under `pytest -n auto`, and what belongs in the pull-request gate versus a nightly run. Those are architecture and debugging questions. If anything, the volume of machine-written tests raises the value of an engineer who can tell a real assertion from one that would pass against a broken implementation.
How much does a Python developer with strong Pytest skills earn in India?
₹5-18 LPA in 2026, depending on experience. Mid-level backend or data engineers at Razorpay, Swiggy, Zomato, PhonePe, and similar startups typically expect Pytest fluency. Strong test design, fixtures, mocking, plugins, is a frequent differentiator at the senior level. As a rough band for Python roles where testing skill is explicitly assessed: freshers and 0-2 years typically land ₹5-9 LPA, 3-5 years ₹9-16 LPA, and 6+ years or SDET-in-test roles ₹16-30 LPA and above at product companies, with services companies paying toward the lower end of each band and funded product startups toward the upper end. Pytest on its own is not what moves the number, it is a required skill rather than a premium one. What does move it is the adjacent evidence: owning a CI pipeline, cutting suite runtime, building the fixture layer for a service other teams depend on. Quote a range rather than a single figure, and be ready to describe a suite you actually improved.
Should I use unittest or Pytest for a new Python project?
Pytest, in almost every case. unittest is fine for tiny scripts where adding a dependency is unwelcome, but for any project with more than ~20 tests, Pytest's fixture system, parametrization, plugin ecosystem, and assertion rewriting save more time than the install cost. Pytest can also run unittest-style tests, so migration is incremental. A practical migration order: point Pytest at the existing suite so `TestCase` classes keep running unchanged, add `pyproject.toml` config with `--strict-markers`, write all NEW tests as plain functions with fixtures, then convert old classes only when you are already editing them. The one thing that does not carry over is fixture injection: Pytest fixtures cannot be passed as arguments into `unittest.TestCase` methods, so classes that need them have to be converted rather than wrapped. Job descriptions in India almost always say Pytest rather than unittest, so it is also the version worth putting on a CV.
How long does it take to prepare for a Pytest interview?
If you already write Python daily, two focused weeks is enough: three or four days on fixtures (scopes, `conftest.py` resolution, autouse, overriding), two days on parametrization and markers, three days on mocking (`mocker`, `monkeypatch`, and the patch-where-it-is-looked-up rule, which is the single most common interview question), and the rest on plugins you will actually be asked about, pytest-cov, pytest-xdist, pytest-asyncio and pytest-django or pytest-flask depending on the stack. If you are coming from `unittest` only, add a week. The fastest way to be credible is not reading documentation but adding tests to an existing repository: write a `conftest.py` with a transactional database fixture, get `pytest -n auto` green, and fix one flaky test. Interviewers can tell within two questions whether you have debugged a real fixture-ordering problem or only read about scopes.
What is expected from a fresher versus someone with 5+ years of experience?
Freshers are assessed on fundamentals: writing a test function, using `assert`, `@pytest.mark.parametrize`, `pytest.raises`, a basic fixture, and running a single test by node ID. Being able to explain why parametrization beats a `for` loop inside one test usually clears the bar. From three years upward the questions move to design: how you structure `conftest.py` across a large repo, how you isolate database state, where you patch and why, and how you keep a suite under ten minutes. At 5+ years or SDET level, expect production scenarios rather than definitions, debugging a test that only fails under `pytest -n auto`, deciding what belongs in unit versus integration versus nightly tiers, writing a `pytest_collection_modifyitems` hook, and justifying a coverage policy that is not just a percentage. Bringing a concrete story (a flaky test you diagnosed, a suite you took from 25 minutes to 6) is worth more than any definition.
Is Pytest alone enough, or should I also learn Selenium, Playwright or Robot Framework?
For backend, data and ML engineering roles in India, Pytest plus one mocking approach plus pytest-cov covers what is actually asked. For QA and SDET roles, Pytest is usually the runner and the browser tool sits on top of it, so the pairing to learn is Pytest with Playwright (via `pytest-playwright`), which has largely replaced raw Selenium in new automation work because of auto-waiting and built-in tracing. Robot Framework still appears in enterprise and services-company job descriptions, but greenfield teams rarely pick it now. If you are choosing where to spend the next month: Pytest fundamentals first, then `pytest-xdist` and CI integration, then Playwright if the role is browser-facing or `pytest-asyncio` and `httpx` if it is API-facing. Knowing how to run a suite in parallel in CI is a more common differentiator than knowing a second browser library.
Introduction
Pytest is the de-facto standard test framework in the Python world in 2026. It has completely displaced the standard-library `unittest` for new projects and is the testing backbone of every major Python codebase, from Instagram and Dropbox to Razorpay and PhonePe. Its appeal: plain `assert` statements, function-style tests (no classes required), and a fixture system that's both powerful and surprisingly easy to misuse.
If you're interviewing for any Python role in India today, backend, data engineering, ML engineering, DevOps, expect Pytest questions. They range from 'what's the difference between a fixture and a parametrized test' to 'how do you mock a class method that's imported into another module' (a classic gotcha that trips up even senior engineers).
This page covers 45 Pytest interview questions asked in 2026, grouped by difficulty: 15 basic, 20 intermediate, 10 advanced. Each answer explains the underlying mechanism, shows a working code example where it adds clarity, and calls out the production failure modes and version-specific behaviour (pytest 8.x collection rules, `--import-mode=importlib`, pytest-asyncio 1.x loop scopes) that senior interviewers follow up on.
Ready to practice Pytest interviews?
Don't just read, practice these Pytest questions live with an AI interviewer that asks follow-ups and scores your answers.