Skip to content

pytest Essentials Interview Questions & Answers

15 questions Updated 2026-06-18 Share:

Python interview questions on pytest — pytest vs unittest, fixtures and scope, parametrize, mocking with monkeypatch and unittest.mock, pytest.raises, and conftest.py.

Read the in-depth guidePython pytest Explained — assert, Fixtures, Parametrize, and Testing Exceptions(opens in new tab)
15 of 15

unittest is the standard-library framework, modeled on xUnit: tests are methods on a TestCase subclass and you use self.assertEqual, self.assertTrue, etc. pytest is a third-party framework that runs plain functions using the bare assert statement, with rich failure introspection, fixtures, and parametrize.

# unittest
import unittest
class TestMath(unittest.TestCase):
    def test_add(self):
        self.assertEqual(1 + 1, 2)

# pytest — just a function and assert
def test_add():
    assert 1 + 1 == 2

pytest can also run existing unittest tests, so adopting it is low-risk. Rule of thumb: prefer pytest for new code — less boilerplate and better output — while unittest is fine when you must avoid dependencies.

A fixture is a function decorated with @pytest.fixture that provides setup (and teardown) for tests. A test requests it simply by naming it as a parameter, and pytest injects the return value. Using yield lets code after the yield run as teardown. The scope controls how often the fixture is created: function (default), class, module, or session.

import pytest

@pytest.fixture(scope="module")   # created once per module
def db():
    conn = connect()
    yield conn                    # value handed to tests
    conn.close()                  # teardown after tests finish

def test_query(db):               # db injected by name
    assert db.ping()

Wider scopes share expensive resources (DB connections, servers) across many tests for speed; narrower scopes give better isolation. Rule of thumb: default to function scope and only widen when setup is costly.

@pytest.mark.parametrize runs the same test function multiple times with different arguments, generating one separate test case per row. Each case passes or fails independently, so a single bad input doesn't hide the others — far cleaner than a loop inside one test.

import pytest

@pytest.mark.parametrize("value, expected", [
    (2, 4),
    (3, 9),
    (4, 16),
])
def test_square(value, expected):
    assert value ** 2 == expected

pytest reports each as test_square[2-4], test_square[3-9], etc., and you can stack decorators to get the cross product of inputs. Rule of thumb: use parametrize for the same logic across many inputs instead of copy-pasted tests or in-test loops.

The built-in monkeypatch fixture replaces attributes, dict items, or env vars for the duration of a test and auto-restores them afterward — ideal for swapping out a function or setting os.environ. unittest.mock (Mock, patch) creates mock objects that record calls and let you set return values or side effects — best when you need to assert how a dependency was called.

import requests, mymodule

def test_with_monkeypatch(monkeypatch):
    monkeypatch.setattr(requests, "get", lambda url: {"ok": True})
    assert mymodule.fetch() == {"ok": True}

from unittest.mock import patch
def test_with_mock():
    with patch("mymodule.requests.get") as mock_get:
        mock_get.return_value = {"ok": True}
        mymodule.fetch()
        mock_get.assert_called_once()   # verify the interaction

A key gotcha: patch where the name is looked up (mymodule.requests.get), not where it's defined. Rule of thumb: monkeypatch for simple replacements, unittest.mock when you need to inspect calls.

Use the pytest.raises context manager: the test passes only if the expected exception is raised inside the with block, and fails if no exception (or the wrong one) occurs. You can capture the exception via as excinfo to assert on its message, and use match= for a regex check.

import pytest

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        1 / 0

def test_message():
    with pytest.raises(ValueError, match="invalid"):
        int("not a number")   # ValueError: invalid literal...
    # or: assert "invalid" in str(excinfo.value)

It cleanly replaces a try/except/pytest.fail dance. Rule of thumb: always assert on the specific exception type (and ideally the message) so the test can't pass for the wrong reason.

conftest.py is a special pytest file for shared fixtures and hooks. pytest auto-discovers it — no import needed — and any fixture defined there is available to every test in that directory and its subdirectories. It's the standard place to put fixtures used across multiple test files.

# tests/conftest.py
import pytest

@pytest.fixture
def client():
    return create_test_client()

# tests/test_users.py — no import required
def test_login(client):           # 'client' resolved from conftest
    assert client.login("ada")

You can also register plugins, define hooks like pytest_addoption, and place a conftest.py at multiple levels for scoped sharing. Rule of thumb: put a fixture in conftest.py once more than one test file needs it, instead of importing it around.

Use yield: code before yield is setup, the yielded value is injected, and code after yield is teardown (runs even if the test fails). This replaces unittest's setUp/tearDown with a single cohesive function.

import pytest

@pytest.fixture
def temp_file(tmp_path):
    p = tmp_path / "data.txt"
    p.write_text("hi")        # setup
    yield p                   # test runs here
    p.unlink()                # teardown — always runs

def test_read(temp_file):
    assert temp_file.read_text() == "hi"

Rule of thumb: yield fixtures keep setup and matching teardown together; teardown runs on failure too, so resources are always released.

pytest ships fixtures you request by name: tmp_path (unique temp Path), monkeypatch (patch/restore), capsys (capture stdout/stderr), caplog (capture log records), and request (test metadata).

def test_output(capsys):
    print("hello")
    assert capsys.readouterr().out == "hello\n"

def test_file(tmp_path):
    (tmp_path / "f.txt").write_text("x")   # auto-cleaned temp dir

def test_logs(caplog):
    logging.warning("oops")
    assert "oops" in caplog.text

Rule of thumb: reach for built-in fixtures (tmp_path, capsys, caplog, monkeypatch) before writing your own — they handle cleanup for you.

@pytest.mark.skip always skips; skipif(cond) skips conditionally (e.g. by platform/version); xfail marks a test expected to fail (a known bug) — it's reported separately and doesn't fail the suite.

import sys, pytest

@pytest.mark.skip(reason="not implemented yet")
def test_future(): ...

@pytest.mark.skipif(sys.version_info < (3, 11), reason="needs 3.11+")
def test_new_feature(): ...

@pytest.mark.xfail(reason="known bug #123")
def test_buggy():
    assert broken() == 1

Rule of thumb: skip/skipif for tests that shouldn't run in this environment; xfail for known failures you want tracked without breaking CI.

Pass ids= to label cases (instead of auto-generated names), and parametrize a fixture with params= so every test using it runs once per value.

import pytest

@pytest.mark.parametrize("n, expected", [(2, 4), (3, 9)],
                         ids=["two", "three"])
def test_sq(n, expected):
    assert n ** 2 == expected      # test_sq[two], test_sq[three]

@pytest.fixture(params=["sqlite", "postgres"])
def db(request):
    return connect(request.param)  # tests run against both backends

Rule of thumb: use ids= for clear test names and parametrized fixtures to run the same tests across multiple configurations/backends.

A fixture requests other fixtures as parameters, just like a test does — pytest resolves the dependency graph and builds them in order. This lets you compose small fixtures into larger setups.

import pytest

@pytest.fixture
def db():
    return connect()

@pytest.fixture
def user(db):                 # depends on db
    return db.create_user("ada")

def test_profile(user):       # gets a user (and transitively a db)
    assert user.name == "ada"

Rule of thumb: build complex setups by composing fixtures (one requesting another) rather than one giant fixture — clearer and more reusable.

pytest rewrites assert statements at import time to capture operand values, so a failing assert a == b shows the actual values and a diff — no need for assertEqual. Add an optional message after a comma for context.

def test_lists():
    result = [1, 2, 4]
    assert result == [1, 2, 3]
    # pytest shows:
    #   assert [1, 2, 4] == [1, 2, 3]
    #     At index 2 diff: 4 != 3

assert total > 0, f"expected positive, got {total}"

Rule of thumb: just use plain assert in pytest — its rewriting gives rich diffs; reserve a trailing message for extra context the values alone don't convey.

Use pytest.approx, which compares with a tolerance — avoiding the 0.1 + 0.2 != 0.3 trap. It works for numbers, and for lists/dicts of numbers.

import pytest

assert 0.1 + 0.2 == pytest.approx(0.3)
assert [0.1 + 0.2, 1.0] == pytest.approx([0.3, 1.0])
assert 100.5 == pytest.approx(100, rel=0.01)   # 1% relative tolerance

Rule of thumb: never assert raw == on floats — wrap the expected value in pytest.approx (tune with rel=/abs=).

By default pytest collects files matching test_*.py / *_test.py, functions/ methods prefixed test_, and classes prefixed Test (with no __init__). You can run subsets by node id, keyword (-k), or marker (-m).

pytest                       # discover & run everything
pytest tests/test_api.py::test_login   # a single test by node id
pytest -k "login and not slow"          # match by name expression
pytest -m "smoke"                        # run tests marked @pytest.mark.smoke

Rule of thumb: follow the test_*/Test* naming so discovery finds your tests; use -k/-m/node ids to run focused subsets during development.

An autouse fixture (@pytest.fixture(autouse=True)) runs automatically for every test in its scope without being requested as a parameter. It's meant for cross-cutting setup/teardown — resetting global state, seeding a clock, clearing a cache — that every test needs but none should have to name explicitly.

import pytest

@pytest.fixture(autouse=True)
def reset_registry():
    registry.clear()      # setup runs before each test
    yield
    registry.clear()      # teardown runs after each test

def test_a():             # no parameter, yet reset still runs
    register("x")
    assert registry.size() == 1

Use it sparingly: because it fires invisibly, overusing autouse hides dependencies and makes tests harder to reason about. Rule of thumb: reach for autouse only for genuinely universal setup; otherwise request fixtures explicitly so each test's needs are visible.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel