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 Testing interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.