pytest Basics — Writing and Running Tests

8 min read

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 pytest

That'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:

pytest

Output:

============================== 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 -v
test_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_*.py or *_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 expression

The -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 else

The 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_email

Classes 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 ...
    pass

Run 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():
    pass

For 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 called login_tests.py matches *_test.py (singular) but not test_*.py. A function called check_login is never collected. When pytest reports "collected 0 items", check the names first.
  • Using unittest.TestCase out of habit. pytest can run unittest-style classes, but you lose plain assert rewriting and fixtures get awkward to wire up. For new tests, use plain functions and pytest fixtures — the syntax is shorter and the failure output is better.
  • Running pytest without a venv. pip install pytest outside 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 call venv/bin/pytest directly.

🎯 Practice task

Write your first pytest suite. 25-30 minutes.

  1. In a new venv, pip install pytest. Confirm pytest --version works.
  2. Create a project: helpers.py next to a tests/ folder containing test_helpers.py. Add an empty __init__.py in tests/.
  3. In helpers.py, write three functions:
    • is_valid_email(s: str) -> bool — true if s contains "@" 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.
  4. 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).
  5. Run pytest -v. Make sure all eight pass.
  6. Add a @pytest.mark.smoke to two of the tests. Register the marker in pyproject.toml. Run pytest -m smoke.
  7. Use -k to run only the email tests: pytest -k "email".
  8. Add a deliberately failing test (assert 1 + 1 == 3) and read pytest's diff output. Then remove it.
  9. Use pytest -x --tb=short to see the short-traceback flow. Try pytest --lf after a failure to rerun only it.
  10. Stretch: wrap two related tests in class TestEmail:. Run just that class with pytest -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.

// tip to track lessons you complete and pick up where you left off across devices.