8 System tests, against the run image
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-07. 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-07 --staged --worktree -- :/A system test sends real HTTP requests to a running server, as a user of the app does. The run image is our deployment, so the system tests send their requests to the server in it.
just setupThe integration tests call the app in the same process as the tests. They cannot tell whether the image starts, or
whether the server answers on its port. A system test sends real HTTP requests to the running server, as the Cheese
App does. Here the server is the run image, our deployment, which just docker-serve starts.
8.1 requests, for tests that send real HTTP requests
The system tests send their requests with the package requests, the most used
HTTP library of Python. It is a tool of the tests, so it goes into the group
test.
uv add --group test requestsuv run python -c "import requests; print(requests.__version__)"Python prints the version of requests.
Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:
git check de0780b72a5cIf 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-07 --staged -- :/ && git restore --source de0780b72a5c --staged --worktree -- :/What changed
pyproject.toml: requests, in the grouptest. 1 line added. It comes fromuv add --group test requests. The file- look at
"requests>=2.34.2",
- look at
uv.lock: the exact versions of requests, and of the packages that it needs. 166 lines added. It comes fromuv add --group test requests. The file
The diff that uv add --group test requests makes in pyproject.toml
@@ -25,6 +25,7 @@ test = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0",
+ "requests>=2.34.2",
]
[tool.pytest.ini_options]8.2 the tests that run by default: unit and integration
The system tests need a running server, and the other tests do not. just test, just check, the git hook and the
dev image run every test of testpaths. They must not depend on a server, so testpaths names the two folders of
tests that need none. The system tests then run only when we ask for them.
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', 'c8302b4cff8b: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', 'testpaths'),
]
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')
PYRead the line testpaths in the table [tool.pytest.ini_options] of pyproject.toml.
just test -qpytest prints 21 passed, as before.
Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:
git check c8302b4cff8bIf 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 de0780b72a5c --staged -- :/ && git restore --source c8302b4cff8b --staged --worktree -- :/What changed
pyproject.toml: the two folders of tests that run by default. 1 line added, 1 removed. The file- look at
testpaths = ["tests/unit", "tests/integration"]
- look at
The patch to pyproject.toml
@@ -30,7 +30,7 @@ test = [
[tool.pytest.ini_options]
# The tests are in tests/, and import the package api from src/api-service/
-testpaths = ["tests"]
+testpaths = ["tests/unit", "tests/integration"]
pythonpath = ["src/api-service"]
[tool.ruff]8.3 system tests, against the server in Docker
Five system tests send requests to localhost:9000. Four of them need a server there, and the fifth imports the app.
A test that needs a server must fail when none answers, as a test fails without its data. A fixture does that.
Apply the patch to tests/system/test_system_api.py, from the top of the project:
curl -fsSL https://github.com/rahuldave/cheesecracker/commit/19813cf32d0843eea486048243e1eaf62b16a0fe.diff | git apply --include=tests/system/test_system_api.pyMake the folder tests/system/, and apply the patch. Read the fixture server, then the line
@pytest.mark.usefixtures("server") above the class TestAPIEndpoints.
Build the run image, start its server, and run the system tests against it. The server runs to the end of this part.
just docker-build run
just docker-serve
uv run pytest tests/system -vpytest prints 5 passed. The four tests of TestAPIEndpoints sent their requests to the server in Docker.
just test -qjust test still prints 21 passed, because testpaths leaves out tests/system.
Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:
git check 19813cf32d08If 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 c8302b4cff8b --staged -- :/ && git restore --source 19813cf32d08 --staged --worktree -- :/What changed
tests/system/test_system_api.py: five system tests, through real HTTP requests tolocalhost:9000. New, 69 lines. The file- look at
pytest.fail(f"no server at {API_BASE_URL}: start one with just docker-serve", pytrace=False)
- look at
The patch to tests/system/test_system_api.py
@@ -0,0 +1,69 @@
+"""
+System tests of the API server
+Send real HTTP requests to the server at localhost:9000, the deployment
+"""
+
+import pytest
+import requests
+
+
+# Base URL for the API (assumes API is running)
+API_BASE_URL = "http://localhost:9000"
+
+
+@pytest.fixture(scope="module")
+def server():
+ """Fail the tests that use it when no server answers at localhost:9000"""
+ try:
+ requests.get(f"{API_BASE_URL}/health", timeout=2)
+ except requests.RequestException:
+ pytest.fail(f"no server at {API_BASE_URL}: start one with just docker-serve", pytrace=False)
+
+
+@pytest.mark.usefixtures("server")
+class TestAPIEndpoints:
+ """System tests of the endpoints, through the running server"""
+
+ def test_root_endpoint(self):
+ """Test the root endpoint returns welcome message"""
+ response = requests.get(f"{API_BASE_URL}/")
+ assert response.status_code == 200
+ data = response.json()
+ assert "message" in data
+ assert "Cheese App" in data["message"]
+
+ def test_health_check(self):
+ """Test health check endpoint"""
+ response = requests.get(f"{API_BASE_URL}/health")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "healthy"
+
+ def test_euclidean_distance_endpoint_default(self):
+ """Test euclidean_distance endpoint with default values"""
+ response = requests.get(f"{API_BASE_URL}/euclidean_distance/")
+ assert response.status_code == 200
+ data = response.json()
+ assert "distance" in data
+ assert data["x"] == 1.0
+ assert data["y"] == 2.0
+ assert data["distance"] == pytest.approx(2.236, rel=0.01)
+
+ def test_euclidean_distance_endpoint_custom_values(self):
+ """Test euclidean_distance endpoint with custom values (3, 4)"""
+ response = requests.get(f"{API_BASE_URL}/euclidean_distance/?x=3&y=4")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["distance"] == pytest.approx(5.0)
+
+
+# Standalone tests that don't require running API
+class TestAPIWithoutServer:
+ """Tests that can run without a live server"""
+
+ def test_api_structure(self):
+ """Test that we can import the API module"""
+ from api.service import app
+
+ assert app.title == "Cheese API Server"
+ assert app.version == "v1"8.4 just test-system runs the system tests
The system tests have a recipe of their own, test-system, apart from just test and just check. A missing server
fails them, and never stops a commit.
Apply the patch to justfile, from the top of the project:
curl -fsSL https://github.com/rahuldave/cheesecracker/commit/277d54eebce4a1727ed3d8a5c46570ca4a95f75d.diff | git apply --include=justfileRead the recipe test-system at the end of the justfile.
just test-system -qpytest prints 5 passed. Then stop the server, and run the system tests again.
just docker-stop
just test-system -qpytest fails, with 1 passed, 4 errors. Each error says no server at http://localhost:9000: start one with just docker-serve.
Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:
git check 277d54eebce4If 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 19813cf32d08 --staged -- :/ && git restore --source step-08 --staged --worktree -- :/What changed
justfile: the recipetest-system. 4 lines added. The file- look at
uv run pytest tests/system "$@"
- look at
The patch to justfile
@@ -82,3 +82,7 @@ docker-serve:
# Stop the server of just docker-serve, and remove its container
docker-stop:
docker stop cheesecracker-run
+
+# Run the system tests against the server at http://localhost:9000. They fail when no server answers there
+test-system *args:
+ uv run pytest tests/system "$@"This part ends at the code at step-08. The code after this part