← Back to Course Index
Lesson 13 of 13

Testing with pytest

~25 min · pytest, fixtures, parametrize, mocking, coverage

Ref
Primary Source
pytest Documentation — Getting Started

pytest is the de-facto Python testing framework. The official docs are excellent. Also see Real Python — Effective Python Testing with pytest.

1 — pytest vs Jest: The Map

Jest (JS)pytest (Python)Notes
npm testpytestRun all tests
test("...", () => {})def test_something():Define a test
expect(x).toBe(y)assert x == yAssertion
expect(x).toEqual(y)assert x == yDeep equality
expect(fn).toThrow()with pytest.raises(Error):Expect exception
beforeEach(() => {})@pytest.fixtureSetup/teardown
test.each([...])@pytest.mark.parametrizeParameterized tests
jest.fn() / jest.mock()unittest.mock.MagicMock / monkeypatchMocking
jest --coveragepytest --covCoverage

2 — Writing Your First Tests

# math_utils.py
def add(a, b):
    return a + b

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

def is_even(n):
    return n % 2 == 0
# test_math_utils.py  ← file must start with test_
import pytest
from math_utils import add, divide, is_even

# Function must start with test_
def test_add_two_positive_numbers():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, 1) == 0

def test_add_returns_int():
    result = add(2, 3)
    assert isinstance(result, int)

def test_divide_raises_on_zero():
    with pytest.raises(ValueError) as exc_info:
        divide(10, 0)
    assert "zero" in str(exc_info.value).lower()

def test_is_even():
    assert is_even(4) is True
    assert is_even(3) is False
# Run tests
pytest                        # discover and run all tests
pytest test_math_utils.py     # specific file
pytest test_math_utils.py::test_add_two_positive_numbers  # specific test
pytest -v                     # verbose output
pytest -x                     # stop on first failure
pytest -k "add"               # run only tests matching "add"
💡 pytest discovers tests automatically

pytest finds tests by scanning for files named test_*.py or *_test.py, and within them any function named test_* or class named Test*. No config needed to get started.

3 — Assertions

pytest rewrites assert statements to show detailed failure output — much better than bare assert.

# All pytest assertions use plain Python assert
assert result == expected
assert result != unexpected
assert result is None
assert result is not None
assert result is True
assert "hello" in result         # membership
assert len(result) == 3
assert result > 0
assert abs(result - 3.14) < 0.01  # float comparison

# Approximate float comparison — use pytest.approx
assert result == pytest.approx(3.14159, abs=1e-3)
assert 0.1 + 0.2 == pytest.approx(0.3)   # floating point safe

# Exception details
with pytest.raises(ValueError, match="zero"):  # match against error message
    divide(10, 0)

# Check exception type
with pytest.raises((TypeError, ValueError)):
    risky_operation()

# Check it does NOT raise (just run it)
result = add(1, 2)   # would fail the test if it raises

4 — Fixtures

Fixtures are pytest's beforeEach — reusable setup that is injected into tests by name. They're more powerful than Jest's hooks because they're composable and can do teardown too.

# conftest.py — fixtures here are available to all test files
import pytest

@pytest.fixture
def sample_user():
    """A fresh user dict for each test that requests it."""
    return {"name": "Alice", "age": 30, "email": "alice@example.com"}

@pytest.fixture
def db_connection():
    """Set up and tear down a database connection."""
    conn = create_test_db()
    yield conn             # ← everything after yield is teardown
    conn.close()           # always runs, even if test fails
    cleanup_test_db()

# Use fixtures by matching the parameter name
def test_user_name(sample_user):
    assert sample_user["name"] == "Alice"

def test_user_age(sample_user):
    assert sample_user["age"] == 30

def test_save_user(db_connection, sample_user):
    db_connection.save(sample_user)
    found = db_connection.find("Alice")
    assert found["email"] == "alice@example.com"

Fixture scope — control how often setup runs

# scope="function" (default) — fresh fixture per test
# scope="class"    — shared within a test class
# scope="module"   — shared across the whole file
# scope="session"  — shared across all tests in the run

@pytest.fixture(scope="session")
def expensive_resource():
    """Created once, shared across all tests — e.g. a database."""
    resource = create_expensive_thing()
    yield resource
    resource.destroy()

5 — Parametrize — test.each for Python

Run the same test with multiple inputs — like Jest's test.each.

Jest — test.each
test.each([
  [2, 3, 5],
  [-1, 1, 0],
  [0, 0, 0],
])("add(%i, %i) = %i", (a, b, expected) => {
  expect(add(a, b)).toBe(expected);
});
pytest — @parametrize
@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
    (100, -100, 0),
])
def test_add(a, b, expected):
    assert add(a, b) == expected
# Runs 4 separate tests — all shown in output
# Parametrize with pytest.param for custom IDs and marks
@pytest.mark.parametrize("n, expected", [
    pytest.param(4, True,  id="even-positive"),
    pytest.param(3, False, id="odd-positive"),
    pytest.param(0, True,  id="zero"),
    pytest.param(-2, True, id="even-negative"),
])
def test_is_even(n, expected):
    assert is_even(n) == expected

6 — Mocking

Python's unittest.mock is your jest.fn() and jest.mock().

# The code under test
import requests

def get_github_user(username):
    resp = requests.get(f"https://api.github.com/users/{username}")
    resp.raise_for_status()
    return resp.json()["name"]
# test_github.py
from unittest.mock import patch, MagicMock
import pytest
from github import get_github_user

# patch — replace requests.get with a mock for this test only
def test_get_github_user(monkeypatch):
    mock_response = MagicMock()
    mock_response.json.return_value = {"name": "Linus Torvalds"}
    mock_response.raise_for_status.return_value = None

    monkeypatch.setattr("github.requests.get", lambda url: mock_response)
    result = get_github_user("torvalds")
    assert result == "Linus Torvalds"

# Alternatively use @patch decorator
@patch("github.requests.get")
def test_get_github_user_patch(mock_get):
    mock_get.return_value.json.return_value = {"name": "Linus Torvalds"}
    mock_get.return_value.raise_for_status.return_value = None
    assert get_github_user("torvalds") == "Linus Torvalds"
    mock_get.assert_called_once_with("https://api.github.com/users/torvalds")

# MagicMock directly
def test_with_mock():
    mock_fn = MagicMock(return_value=42)
    result = mock_fn(1, 2)
    assert result == 42
    mock_fn.assert_called_once_with(1, 2)

7 — Marks & Skipping

# Skip a test
@pytest.mark.skip(reason="not implemented yet")
def test_future_feature():
    ...

# Skip conditionally
import sys
@pytest.mark.skipif(sys.platform == "win32", reason="Linux only")
def test_linux_specific():
    ...

# Mark expected failure
@pytest.mark.xfail(reason="known bug #123")
def test_known_bug():
    assert broken_function() == 42

# Custom marks (register in pyproject.toml)
@pytest.mark.slow
def test_heavy_computation():
    ...

# Run only slow tests: pytest -m slow
# Run all except slow: pytest -m "not slow"

8 — Coverage

# Install
pip install pytest-cov

# Run with coverage
pytest --cov=my_package --cov-report=term-missing

# HTML report
pytest --cov=my_package --cov-report=html
# opens htmlcov/index.html

# Example output:
# Name              Stmts   Miss  Cover
# ------------------------------------
# math_utils.py        10      2    80%

# pyproject.toml config
[tool.coverage.run]
source = ["my_package"]
omit = ["*/tests/*", "*/migrations/*"]

[tool.coverage.report]
fail_under = 80   # fail if coverage drops below 80%

9 — Project Layout

my_project/
├── src/
│   └── my_package/
│       ├── __init__.py
│       ├── math_utils.py
│       └── api.py
├── tests/
│   ├── conftest.py        ← shared fixtures
│   ├── test_math_utils.py
│   └── test_api.py
├── pyproject.toml
└── requirements.txt

# pyproject.toml — configure pytest
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts   = "-v --tb=short"

🧠 Quiz

1. How does pytest discover test functions automatically?

2. What does yield inside a @pytest.fixture do?

3. To test that a function raises a ValueError, you use:

4. @pytest.mark.parametrize("x, y", [(1,2),(3,4)]) on a test function will run it…

5. What is conftest.py used for in a pytest project?

0/5

Questions answered correctly.

🏆

Course Complete — 13 lessons done!

You now have a solid Python foundation as a JavaScript developer.
Next step: build something real.