1 A function, and its unit tests

Build it yourself. Read the tutorial is the other way to read this part.

This walk goes through rahuldave/cheesecracker on GitHub. This part starts from the code at step-00. The code before this part

If you start at this part, make your clone as Get the project says, and then run this command. It puts in your clone the files that this part starts from. A file that the project deleted before this part stays, so delete it if git check lists it:

git fetch --quiet --tags --force origin; git rm -rq --cached --ignore-unmatch -- :/ && git restore --source step-00 --staged --worktree -- :/

A unit test calls one function alone. The first function of the server, power(), gets eleven of them.

just setup

The server will compute distances, and it needs a function that raises a number to a power. We write the function first, and tests that call it alone. A unit test calls one function, with no server and no network, so it runs in milliseconds.

1.1 power(), the first function of the API

The code of the API is a Python package, api, in the folder src/api-service/. Its first function, power(), returns its first argument raised to the power of its second.

Apply the patch to src/api-service/api/__init__.py, from the top of the project:

curl -fsSL https://github.com/rahuldave/cheesecracker/commit/bb1701fd54ba7c6aa9bd971c6651f34439f004d6.diff | git apply --include=src/api-service/api/__init__.py

Apply the patch to src/api-service/api/utils.py, from the top of the project:

curl -fsSL https://github.com/rahuldave/cheesecracker/commit/bb1701fd54ba7c6aa9bd971c6651f34439f004d6.diff | git apply --include=src/api-service/api/utils.py

Make the folder src/api-service/api/, and apply both patches. __init__.py has one comment, and makes the folder a package. In utils.py, read the function power() after the docstring of the module.

The package is not on the import path of the project yet. So the check runs Python in the folder src/api-service/, where api is a folder of its own.

uv run --directory src/api-service python -c "from api.utils import power; print(power(2, 3))"

Python prints 8, which is 2 to the power of 3.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check bb1701fd54ba

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git fetch --quiet --tags --force origin; git restore --source step-00 --staged -- :/ && git restore --source bb1701fd54ba --staged --worktree -- :/

What changed

  • src/api-service/api/__init__.py: one comment, which makes the folder a package. New, 1 line. The file
  • src/api-service/api/utils.py: the function power(). New, 9 lines. The file
    • look at return base**exponent

The patch to src/api-service/api/__init__.py
@@ -0,0 +1 @@
+# API Package

The patch to src/api-service/api/utils.py
@@ -0,0 +1,9 @@
+"""
+Utility functions for mathematical operations
+Used by the Cheese App API Service
+"""
+
+
+def power(base, exponent):
+    """Calculate base raised to the power of exponent"""
+    return base**exponent

This change on GitHub The code after it

1.2 pytest, in the dependency group test

A test calls power() and compares the result with the answer that we expect. pytest is the test runner of most Python projects. It finds each function whose name starts with test_, in the files test_*.py, and runs it. A test checks a result with a plain assert. pytest reports each failure with the values that it compared.

The tests need pytest, but the server does not. So pytest goes into a group of dependencies of its own, test.

uv add --group test pytest

The command adds pytest to the group test of pyproject.toml. It writes the exact versions of pytest and of the packages that it needs into uv.lock.

uv run pytest --version

uv prints the version of pytest, pytest 9.1.1 on the day of the class.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check fc8c3bce8360

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git restore --source bb1701fd54ba --staged -- :/ && git restore --source fc8c3bce8360 --staged --worktree -- :/

What changed

  • pyproject.toml: pytest, in the group test. 5 lines added. It comes from uv add --group test pytest. The file
    • look at "pytest>=9.1.1",
  • uv.lock: the exact version of pytest, and of the packages that it needs. 71 lines added. It comes from uv add --group test pytest. The file

The diff that uv add --group test pytest makes in pyproject.toml
@@ -11,3 +11,8 @@ dependencies = []
 package = false
 # uv sync and uv run install every dependency group, not only dev
 default-groups = "all"
+
+[dependency-groups]
+test = [
+    "pytest>=9.1.1",
+]

This change on GitHub The code after it

1.3 pytest finds the tests, and the package api

pytest looks for tests in every folder of the project, .venv too, which is slow. Also, the tests cannot import api, because the folder src/api-service/ is not on the import path. Two settings of pytest, in pyproject.toml, solve both problems.

Set the values that this change sets in pyproject.toml. Run this command from the top of the project. It reads your file with tomlkit, a library for TOML files. It copies each of these tables and values from the project's file after this change, with their comments, and keeps the rest of your file:

uv run --quiet --no-config --no-project --with tomlkit python - <<'PY'
import copy, pathlib, subprocess, tomlkit
from tomlkit.items import Comment, Table, Whitespace
path = pathlib.Path('pyproject.toml')
doc = tomlkit.parse(path.read_text(encoding='utf-8')) if path.exists() else tomlkit.document()
# The project's file after this change, from your clone
project = tomlkit.parse(subprocess.run(['git', 'show', 'ff82ac1870a7:pyproject.toml'], capture_output=True, text=True,
                                        check=True).stdout)
# Each table and value that the change sets, by its keys: each comes from the project's file with its comments
COPY = [
    ('tool', 'pytest', 'ini_options'),
]

def body(table):   # the items of a table in their order, with its comment lines
    inner = table if isinstance(table, tomlkit.TOMLDocument) else getattr(table, 'value', None)
    return getattr(inner, 'body', [])

def above(table, key):   # the comment lines just above a key
    found = []
    for name, item in body(table):
        if name is not None and name.key == key:
            return found
        found = found + [item] if isinstance(item, Comment) else found if isinstance(item, Whitespace) else []
    return []

for keys in COPY:
    source, target = project, doc
    for key in keys[:-1]:   # make each table on the way
        source = source[key]
        if key not in target:
            target[key] = tomlkit.table(True)
        target = target[key]
    item = copy.deepcopy(source[keys[-1]])
    if isinstance(item, Table) and not item.as_string().endswith('\n\n'):
        item.add(tomlkit.nl())   # a blank line before the table after it
    if keys[-1] not in target:
        for comment in above(source, keys[-1]):
            target.add(copy.deepcopy(comment))
    target[keys[-1]] = item
path.write_text(tomlkit.dumps(doc), encoding='utf-8')
PY

Read the patch. It adds the table [tool.pytest.ini_options] to pyproject.toml.

uv run pytest

pytest collects 0 items, and fails with the code 5, because there are no tests yet. It also warns that it found no files in tests/, which does not exist yet.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check ff82ac1870a7

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git restore --source fc8c3bce8360 --staged -- :/ && git restore --source ff82ac1870a7 --staged --worktree -- :/

What changed

  • pyproject.toml: the table [tool.pytest.ini_options], with the folder of the tests and the folder of the package. 5 lines added. The file
    • look at pythonpath = ["src/api-service"]

The patch to pyproject.toml
@@ -16,3 +16,8 @@ default-groups = "all"
 test = [
     "pytest>=9.1.1",
 ]
+
+[tool.pytest.ini_options]
+# The tests are in tests/, and import the package api from src/api-service/
+testpaths = ["tests"]
+pythonpath = ["src/api-service"]

This change on GitHub The code after it

1.4 unit tests of power()

We do not know whether power() gives the right answer for every kind of input. Eleven tests try it with integers, zero, one, negative numbers, floats, the special values of floats, and inputs of the wrong type.

Apply the patch to tests/unit/test_utils.py, from the top of the project:

curl -fsSL https://github.com/rahuldave/cheesecracker/commit/cbc98b162671bce7a7ba82d7d18c7dc6d530b746.diff | git apply --include=tests/unit/test_utils.py

Make the folder tests/unit/, and apply the patch. Read the class TestPower. Each method is one test, and each assert compares a result of power() with its answer.

uv run pytest -q

pytest prints 11 passed. So power() gives the answer that each test expects.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check cbc98b162671

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git restore --source ff82ac1870a7 --staged -- :/ && git restore --source cbc98b162671 --staged --worktree -- :/

What changed

  • tests/unit/test_utils.py: eleven unit tests of power(). New, 91 lines. The file
    • look at assert power(2, 3) == 8

The patch to tests/unit/test_utils.py
@@ -0,0 +1,91 @@
+"""
+Unit tests for utils module
+Tests the power function in isolation
+"""
+
+import pytest
+from api.utils import power
+
+
+class TestPower:
+    """Tests for the power function"""
+
+    def test_power_positive_integers(self):
+        """Test power with positive integers"""
+        assert power(2, 3) == 8
+        assert power(3, 2) == 9
+        assert power(5, 2) == 25
+        assert power(10, 2) == 100
+
+    def test_power_with_zero_exponent(self):
+        """Test power with zero exponent (any number^0 = 1)"""
+        assert power(5, 0) == 1
+        assert power(100, 0) == 1
+        assert power(0, 0) == 1
+
+    def test_power_with_one(self):
+        """Test power with base or exponent of one"""
+        assert power(1, 5) == 1
+        assert power(5, 1) == 5
+        assert power(1, 1) == 1
+
+    def test_power_negative_base(self):
+        """Test power with negative base"""
+        assert power(-2, 2) == 4
+        assert power(-3, 3) == -27
+        assert power(-2, 4) == 16
+        assert power(-5, 2) == 25
+
+    def test_power_negative_exponent(self):
+        """Test power with negative exponent (1/base^exp)"""
+        assert power(2, -1) == pytest.approx(0.5)
+        assert power(2, -2) == pytest.approx(0.25)
+        assert power(10, -1) == pytest.approx(0.1)
+
+    def test_power_with_floats(self):
+        """Test power with floating point numbers"""
+        assert power(2.5, 2) == pytest.approx(6.25)
+        assert power(1.5, 3) == pytest.approx(3.375)
+        assert power(0.5, 2) == pytest.approx(0.25)
+
+    def test_power_large_numbers(self):
+        """Test power with large numbers"""
+        assert power(10, 6) == 1000000
+        assert power(2, 10) == 1024
+
+    def test_power_fractional_exponent(self):
+        """Test power with fractional exponent (roots)"""
+        assert power(4, 0.5) == pytest.approx(2.0)  # square root
+        assert power(8, 1 / 3) == pytest.approx(2.0)  # cube root
+        assert power(16, 0.25) == pytest.approx(2.0)  # fourth root
+
+    def test_power_special_float_values(self):
+        """Test special float values"""
+        import math
+
+        assert math.isnan(power(float("nan"), 2))
+        assert power(float("inf"), 2) == float("inf")
+        assert power(2, float("inf")) == float("inf")
+
+    def test_power_invalid_inputs(self):
+        """Test that invalid inputs raise TypeError"""
+        with pytest.raises(TypeError):
+            power("3", 4)
+        with pytest.raises(TypeError):
+            power(3, "4")
+        with pytest.raises(TypeError):
+            power(None, 4)
+        with pytest.raises(TypeError):
+            power(3, None)
+        with pytest.raises(TypeError):
+            power([3], 4)
+        with pytest.raises(TypeError):
+            power(3, [4])
+        with pytest.raises(TypeError):
+            power({}, 4)
+
+    def test_power_boolean_values(self):
+        """Test with boolean values (True=1, False=0)"""
+        assert power(True, 5) == 1
+        assert power(False, 5) == 0
+        assert power(2, True) == 2

This change on GitHub The code after it

1.5 just test runs the tests

The command uv run pytest is long, and every task of the project gets a recipe in the justfile, so that one short command runs it. The recipe test runs pytest through uv, and passes on to pytest any words that you type after just test.

Apply the patch to justfile, from the top of the project:

curl -fsSL https://github.com/rahuldave/cheesecracker/commit/e86e0c3d10bbe9409674f1c65ce4afdc38e7f76f.diff | git apply --include=justfile

Read the recipe test at the end of the justfile.

just test -q

just prints the command of the recipe, and pytest prints 11 passed.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check e86e0c3d10bb

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git fetch --quiet --tags --force origin; git restore --source cbc98b162671 --staged -- :/ && git restore --source step-01 --staged --worktree -- :/

What changed

  • justfile: the recipe test. 4 lines added. The file
    • look at uv run pytest "$@"

The patch to justfile
@@ -10,3 +10,7 @@ default:
 # Install Python and every dependency into .venv, exactly as the lockfile says
 setup:
     uv sync --locked
+
+# Run the tests. Extra arguments go to pytest: just test tests/unit
+test *args:
+    uv run pytest "$@"

This change on GitHub

This part ends at the code at step-01. The code after this part