Lesson 16 of 34 · 0 of 1 exercises solved

Testing, Virtual Environments & Packaging

Tests let you change code with confidence. This lesson covers the tools around a real Python project: , pytest, and a pyproject.toml that makes your code installable.

Bash
# Virtual environments — one isolated set of packages per project
python -m venv .venv
source .venv/bin/activate        # macOS / Linux
.venv\Scripts\activate           # Windows PowerShell

pip install pytest requests
pip freeze > requirements.txt    # snapshot exact versions
pip install -r requirements.txt  # reproduce elsewhere
deactivate

# uv — a much faster modern replacement for pip + venv
# uv venv && uv pip install pytest
# uv run pytest
Python
# calculator.py
def divide(a, b):
    if b == 0:
        raise ValueError("cannot divide by zero")
    return a / b

def apply_discount(price, percent):
    if not 0 <= percent <= 100:
        raise ValueError("percent must be 0-100")
    return round(price * (1 - percent / 100), 2)


# test_calculator.py  — pytest finds files named test_*.py
import pytest
from calculator import divide, apply_discount

def test_divide_basic():
    assert divide(10, 2) == 5

def test_divide_by_zero_raises():
    with pytest.raises(ValueError, match="divide by zero"):
        divide(1, 0)

# One test body, many cases
@pytest.mark.parametrize("price,percent,expected", [
    (100, 0,   100.00),
    (100, 25,   75.00),
    (19.99, 10, 17.99),
])
def test_discount(price, percent, expected):
    assert apply_discount(price, percent) == expected
Python
# Fixtures build the world your test needs, then tear it down
import pytest, json

@pytest.fixture
def sample_config(tmp_path):
    path = tmp_path / "config.json"
    path.write_text(json.dumps({"debug": True, "retries": 3}))
    return path            # tmp_path is deleted automatically after the test

def test_reads_config(sample_config):
    data = json.loads(sample_config.read_text())
    assert data["retries"] == 3

# Replacing a real dependency with a fake
def get_user(client, user_id):
    return client.fetch(f"/users/{user_id}")

class FakeClient:
    def fetch(self, path):
        return {"path": path, "name": "Alice"}

def test_get_user_uses_correct_path():
    assert get_user(FakeClient(), 7)["path"] == "/users/7"
Bash
pytest                    # run everything
pytest -v                 # one line per test
pytest -k discount        # only tests whose name matches "discount"
pytest -x                 # stop at the first failure
pytest --lf               # rerun only last failures
pytest --cov=mypackage    # coverage report (pip install pytest-cov)

# pyproject.toml — the modern project manifest
# [project]
# name = "mypackage"
# version = "0.1.0"
# requires-python = ">=3.11"
# dependencies = ["requests>=2.31"]
#
# [build-system]
# requires = ["hatchling"]
# build-backend = "hatchling.build"

pip install -e .          # install your own project in editable mode

Common question

Do tests slow me down?

A test is a small program that calls your code and checks what it returns. pytest finds files and named test_, runs them, and reports any failed checks along with the values involved. The payoff is confidence you can renew in seconds: change something, run the tests, and know whether you broke anything.

Write tests for behaviour you would otherwise check by hand, especially awkward cases: empty input, edge values, bad data. Keep each project in its own virtual environment with its dependencies listed in a file, and run a formatter and linter such as Ruff so style stops being something to argue about. Good tooling is what lets a program grow without falling apart.

Source: Kent Beck: "I get paid for code that works, not for tests, so my philosophy is to test as little as possible to reach a given level of confidence." Confidence is the goal; tests are the cheap way to get it.

Common mix-ups

Each of these sounds reasonable. Think about why it is wrong, then open the explanation.

  • Time spent writing tests is time not spent writing the program.

  • Tests are for big teams; if I'm working alone I know the code and don't need them.

  • A virtual environment is unnecessary when works fine globally.

Practice

The tests at the bottom describe how apply_discount should behave, and two of them fail. Fix the function — do not touch the tests — then run the file to see three OK lines.

Exercise

Make the tests pass

4 checks
def apply_discount(price, percent):
    """Return price reduced by percent. Percent must be between 0 and 100."""
    if percent < 0 or percent > 100:
        raise ValueError("percent must be between 0 and 100")
    return price - percent


def test_ten_percent_off():
    assert apply_discount(200, 10) == 180


def test_zero_percent():
    assert apply_discount(50, 0) == 50


def test_invalid_percent():
    try:
        apply_discount(10, 150)
    except ValueError:
        return
    assert False, "expected ValueError"


for test in [test_ten_percent_off, test_zero_percent, test_invalid_percent]:
    try:
        test()
        print("OK  ", test.__name__)
    except AssertionError as e:
        print("FAIL", test.__name__, e)
 
Python (loads on first run)Ctrl+Enter runs
Output
Press Run to try your code, then Check answer to grade it.

Check yourself

Quick check

How does pytest decide which functions are tests?

Finished reading?1 exercise still open. You can come back to it later.

Tip: use the ← and → keys to move between lessons.