pytest is the test runner most Python QA projects use. It discovers test functions automatically, runs them, and tells you what failed and why — with no boilerplate, no class hierarchy to inherit from, no setUp/tearDown ceremony. A test is a plain function whose name starts with test_ and whose body contains assert. That's the whole protocol. This lesson covers installing pytest, writing your first test, the discovery rules, the most useful command-line flags, the assertion-rewriting magic, and markers for organising large suites.
Installing pytest
In your venv (chapter 6):
pip install pytestThat's the only dependency. pytest ships with everything you need — discovery, assertions, fixtures (next lesson), reporting hooks, plugin loader.
Your first test
Create a file called test_login.py:
def test_valid_login():
result = login("alice@test.com", "password123")
assert result["status"] == "success"
def test_invalid_password():
result = login("alice@test.com", "wrong")
assert result["status"] == "error"
assert "invalid" in result["message"].lower()(For now imagine login() is a function in a helpers.py next door — the point is the test shape.)
Run from the project root:
pytestOutput:
============================== test session starts ==============================
collected 2 items
test_login.py .. [100%]
=============================== 2 passed in 0.04s ===============================
Two dots, two passes. Add -v for one line per test:
pytest -vtest_login.py::test_valid_login PASSED [ 50%]
test_login.py::test_invalid_password PASSED [100%]
Discovery — the rules pytest follows
pytest finds tests automatically. Out of the box:
- Files matching
test_*.pyor*_test.py - Classes starting with
Test(and with no__init__) - Functions or methods starting with
test_
Rename a file from test_login.py to login.py and pytest stops finding it — no error, just zero collected tests. Same for renaming test_x to check_x. Internalise the prefix: it's how the runner knows what's a test.
You can override the conventions in pytest.ini or pyproject.toml, but stick with defaults until you have a reason not to.
The assert statement does the heavy lifting
In other test frameworks (JUnit, NUnit) you'd call assertEquals(actual, expected). pytest does assertion rewriting: it transforms a plain assert x == y into a version that, on failure, shows you the values on both sides. You get the readable output of a special matcher with the syntax of plain Python:
def test_user_role():
user = {"name": "Bob", "role": "tester"}
assert user["role"] == "admin"> assert user["role"] == "admin"
E AssertionError: assert 'tester' == 'admin'
E - admin
E + tester
That diff comes from rewriting — no helper, no library. assert in, assert ==, assert is None, assert isinstance(...) all get the same treatment.
The few assertion forms that come up most:
assert status == 200
assert status != 500
assert "admin" in user["roles"]
assert isinstance(response, dict)
assert len(users) > 0
assert user.get("email") is not None
assert all(u["active"] for u in users)
assert any(u["role"] == "admin" for u in users)For more involved patterns — asserting an exception is raised, comparing floats with tolerance, custom helpers — see lesson 3.
Running a subset
Three flags you'll use daily:
pytest test_login.py # one file
pytest test_login.py::test_valid_login # one test
pytest -k "login and not invalid" # any test whose name matches the expressionThe -k filter is a small expression language: login and not invalid, admin or guest, plain substring checkout. It's invaluable when you're iterating on a single test in a large suite.
For the inverse — exclude from the full run — combine -k and not: pytest -k "not slow".
Failing fast and rerunning failures
Two more flags worth memorising:
pytest -x # stop after the first failure
pytest --lf # rerun only the tests that failed last time (stored in .pytest_cache)
pytest --ff # run failures first, then everything elseThe flow when something breaks: run, see a failure, run pytest --lf -x to iterate quickly on just that test until it passes, then pytest to confirm the whole suite stays green.
Test classes — optional, for grouping
You can group related tests in a class. The class name must start with Test, and tests inside take self as the first parameter:
class TestLogin:
def test_valid_login(self):
assert login("alice@test.com", "pass")["status"] == "success"
def test_empty_email(self):
assert login("", "pass")["status"] == "error"
def test_empty_password(self):
assert login("alice@test.com", "")["status"] == "error"Run them by class, file, or pattern:
pytest -v test_login.py::TestLogin
pytest -v test_login.py::TestLogin::test_empty_emailClasses are optional. Many pytest projects skip them entirely and use modules + filenames for grouping. Pick whichever reads better; don't mix the two for the same kind of test.
Markers — tagging tests
Markers are decorators that attach metadata to tests. Two come up most:
import pytest
@pytest.mark.smoke
def test_homepage_loads():
assert get("/").status_code == 200
@pytest.mark.slow
def test_full_data_export():
# ... a 30-second test ...
passRun a subset:
pytest -m smoke # only smoke-marked tests
pytest -m "not slow" # everything except slow tests
pytest -m "smoke and not flaky"Two built-in markers earn their keep:
@pytest.mark.skip(reason="endpoint not yet deployed")
def test_new_feature():
pass
@pytest.mark.skipif(sys.platform == "win32", reason="Linux-only path test")
def test_unix_paths():
passFor custom markers like @pytest.mark.smoke, register them in pyproject.toml so pytest doesn't print a "unknown marker" warning:
[tool.pytest.ini_options]
markers = [
"smoke: subset of tests run on every commit",
"slow: tests over 5 seconds — exclude from local runs",
]Configuring pytest — pyproject.toml
Two patterns most projects use. Either pytest.ini:
[pytest]
testpaths = tests
addopts = -v --tb=short
markers =
smoke: smoke subset
slow: slow tests…or, more modern, pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"
markers = [
"smoke: smoke subset",
"slow: slow tests",
]testpaths tells pytest where to look (avoids scanning the whole tree). addopts adds default flags so everyone runs the same options. Both files are committed; both are picked up automatically by pytest.
A complete first project
my_tests/
├── pyproject.toml
├── helpers.py
├── tests/
│ ├── __init__.py
│ ├── test_login.py
│ └── test_users.py
# helpers.py
def login(email: str, password: str) -> dict:
if not email or not password:
return {"status": "error", "message": "Invalid credentials"}
if password == "password123":
return {"status": "success", "user": {"email": email, "role": "tester"}}
return {"status": "error", "message": "Invalid credentials"}# tests/test_login.py
import pytest
from helpers import login
@pytest.mark.smoke
def test_valid_login():
result = login("alice@test.com", "password123")
assert result["status"] == "success"
assert result["user"]["email"] == "alice@test.com"
def test_invalid_password():
result = login("alice@test.com", "wrong")
assert result["status"] == "error"
assert "invalid" in result["message"].lower()
@pytest.mark.parametrize("email,password", [
("", "password123"),
("alice@test.com", ""),
("", ""),
])
def test_missing_field(email, password):
assert login(email, password)["status"] == "error"pytest from the project root finds three files of tests, runs them, prints pass/fail. pytest -m smoke runs only the test_valid_login. pytest --tb=line (terse traceback) keeps failures one line each.
That's the entire learning curve to get started. Everything else you'll learn — fixtures, parametrize, plugins, reports — adds polish on top of this foundation.
A pytest run, end to end
Step 1 of 6
Read config
pytest.ini / pyproject.toml is parsed for testpaths, addopts, and markers. Plugins listed in installed packages are activated.
Six steps every run. The setup-fixtures-then-test loop is what makes pytest's function scope feel cheap — you'll see how to control that scope in the next lesson.
⚠️ Common mistakes
- Forgetting the
test_prefix. A file calledlogin_tests.pymatches*_test.py(singular) but nottest_*.py. A function calledcheck_loginis never collected. When pytest reports "collected 0 items", check the names first. - Using
unittest.TestCaseout of habit. pytest can rununittest-style classes, but you lose plainassertrewriting and fixtures get awkward to wire up. For new tests, use plain functions andpytestfixtures — the syntax is shorter and the failure output is better. - Running pytest without a venv.
pip install pytestoutside a venv installs into the system Python, then a different Python version on CI fails to import a package, and now you have a "works on my machine" story. Always activate the venv first; CI scripts callvenv/bin/pytestdirectly.
🎯 Practice task
Write your first pytest suite. 25-30 minutes.
- In a new venv,
pip install pytest. Confirmpytest --versionworks. - Create a project:
helpers.pynext to atests/folder containingtest_helpers.py. Add an empty__init__.pyintests/. - In
helpers.py, write three functions:is_valid_email(s: str) -> bool— true ifscontains"@"and ends with.com/.org/.net/.io.format_status(code: int) -> str— return"ok"for 2xx,"client_error"for 4xx,"server_error"for 5xx, else"unknown".unique_priorities(results: list) -> set— given a list of dicts each with a"priority"key, return the set of priorities.
- In
tests/test_helpers.py, write at least eight test functions. Cover happy paths and edge cases (empty input, wrong type, boundaries 199/200/399/400/499/500). - Run
pytest -v. Make sure all eight pass. - Add a
@pytest.mark.smoketo two of the tests. Register the marker inpyproject.toml. Runpytest -m smoke. - Use
-kto run only the email tests:pytest -k "email". - Add a deliberately failing test (
assert 1 + 1 == 3) and read pytest's diff output. Then remove it. - Use
pytest -x --tb=shortto see the short-traceback flow. Trypytest --lfafter a failure to rerun only it. - Stretch: wrap two related tests in
class TestEmail:. Run just that class withpytest -v tests/test_helpers.py::TestEmail.
You can now write, organise, and run pytest test suites. The next lesson dives into the features that make pytest stand out: fixtures and parametrize.