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.
# 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# 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# 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"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 modeCommon 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.
Make the tests pass
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)
Press Run to try your code, then Check answer to grade it.Check yourself
How does pytest decide which functions are tests?
Tip: use the ← and → keys to move between lessons.