1 power(), and its unit tests

Read the tutorial. Build it yourself 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

To look at this code on your machine, run these commands. The first one makes a clone, if this folder has none:

[ -d cheesecracker ] || git clone https://github.com/rahuldave/cheesecracker
cd cheesecracker
git fetch --quiet --tags --force origin
git switch --detach step-00
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.

± See the new file src/api-service/api/__init__.py

± See the new file src/api-service/api/utils.py

The new file src/api-service/api/utils.py has a docstring and one function, power(base, exponent). It returns base**exponent. src/api-service/api/__init__.py has one comment, and makes api a package.

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.

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 diff of src/api-service/api/__init__.py
@@ -0,0 +1 @@
+# API Package

The diff of 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.

This change came from running 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.

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 of 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.

± See the change to pyproject.toml

The new table [tool.pytest.ini_options] of pyproject.toml has two settings. testpaths makes pytest look for tests in tests/ only. pythonpath puts src/api-service/ on the import path, so a test can import api.

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.

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 diff of 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.

± See the new file tests/unit/test_utils.py

The new file tests/unit/test_utils.py has the class TestPower, with eleven tests. Each assert compares a result of power() with its answer. test_power_invalid_inputs expects a TypeError for a string, None, a list or a dictionary.

uv run pytest -q

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

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 diff of 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.

± See the change to justfile

The recipe test, at the end of the justfile, runs uv run pytest "$@". "$@" stands for the words after just test, so just test tests/unit runs the unit tests alone.

just test -q

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

What changed

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

The diff of 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