3 The distance endpoint, and integration 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-02. 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-02

The server gets a new request, /euclidean_distance/, which works out how far a point is from the origin. New tests send that request to the app, and check the answer.

just setup

The app needs an endpoint that does some work, and tests that call it. The endpoint computes the distance of a point from the origin with power(). An integration test sends requests to the app and checks the answers, with no server and no network.

3.1 /euclidean_distance/, the distance of a point from the origin

The Cheese App asks the server for the distance of a point (x, y) from the origin (0, 0). That distance is the square root of x squared plus y squared.

± See the change to src/api-service/api/service.py

service.py now imports math, and power() from api.utils. The new function at the end, calculate_euclidean_distance(), answers /euclidean_distance/. It takes x and y, with 1 and 2 as defaults, and returns them with the distance.

Start the server, and ask it for the distance of the point (3, 4). The server runs to the end of this part, and restarts when a file of the code changes, because of --reload.

just serve
curl -s "localhost:9000/euclidean_distance/?x=3&y=4"

The server answers {"x":3.0,"y":4.0,"distance":5.0}.

What changed

  • src/api-service/api/service.py: the imports of math and power(), and the endpoint /euclidean_distance/. 12 lines added. The file
    • look at z = power(x, 2) + power(y, 2)

The diff of src/api-service/api/service.py
@@ -4,6 +4,8 @@ Cheese App API Service
 
 from fastapi import FastAPI
 from starlette.middleware.cors import CORSMiddleware
+import math
+from api.utils import power
 
 # Setup FastAPI app
 app = FastAPI(title="Cheese API Server", description="API Server for Cheese App", version="v1")
@@ -27,3 +29,13 @@ async def get_index():
 @app.get("/health")
 async def health_check():
     return {"status": "healthy"}
+
+
+@app.get("/euclidean_distance/")
+async def calculate_euclidean_distance(x: float = 1, y: float = 2):
+    """Calculate the Euclidean distance from origin: sqrt(x^2 + y^2)
+    Returns the distance of a point (x, y) from the origin (0, 0)
+    """
+    z = power(x, 2) + power(y, 2)
+    result = math.sqrt(z)
+    return {"x": x, "y": y, "distance": result}

This change on GitHub The code after it

3.2 httpx2 and pytest-asyncio, for tests of the app

FastAPI's TestClient sends requests to the app in the same process, with no server. It needs httpx2, a library that sends HTTP requests. One test of the app is a coroutine, an async function. pytest cannot run an async function by itself, so pytest-asyncio runs it.

This change came from running uv add --group test httpx2 pytest-asyncio.

The command adds both packages to the group test, and writes their versions into uv.lock.

just test -q

pytest still prints 11 passed. The new packages change no test.

What changed

  • pyproject.toml: httpx2 and pytest-asyncio, in the group test. 2 lines added. It comes from uv add --group test httpx2 pytest-asyncio. The file
    • look at "httpx2>=2.13.1",
  • uv.lock: the exact versions of httpx2, pytest-asyncio, and the packages that they need. 68 lines added, 1 removed. It comes from uv add --group test httpx2 pytest-asyncio. The file

The diff of pyproject.toml
@@ -17,7 +17,9 @@ default-groups = "all"
 
 [dependency-groups]
 test = [
+    "httpx2>=2.13.1",
     "pytest>=9.1.1",
+    "pytest-asyncio>=1.4.0",
 ]
 
 [tool.pytest.ini_options]

This change on GitHub The code after it

3.3 integration tests of the API

A unit test of power() cannot tell whether the app routes a request to the right function, or reads its parameters, or answers in JSON. Ten integration tests send requests to the app through TestClient, and check each answer.

± See the new file tests/integration/test_api.py

The new file tests/integration/test_api.py makes client = TestClient(app). The class TestAPIEndpoints checks the welcome, the distance of (3, 4) and the defaults. It also checks a route that does not exist (404), and a method that is not allowed (405). The class TestCORS checks the CORS headers.

just test -q

pytest prints 21 passed, the eleven unit tests and the ten integration tests. The tests needed no server, so we stop the one of just serve.

just stop

What changed

  • tests/integration/test_api.py: ten integration tests, through FastAPI's TestClient. New, 89 lines. The file
    • look at response = client.get("/euclidean_distance/?x=3&y=4")

The diff of tests/integration/test_api.py
@@ -0,0 +1,89 @@
+"""
+Integration tests for the Cheese App API
+Tests the full API endpoints with FastAPI TestClient
+Based on L18, Slide 11-12 - Integration tests
+"""
+
+import pytest
+from fastapi.testclient import TestClient
+from api.service import app
+
+client = TestClient(app)
+
+
+class TestAPIEndpoints:
+    """Integration tests for API endpoints"""
+
+    def test_root_endpoint(self):
+        """Test the root endpoint returns welcome message"""
+        response = client.get("/")
+        assert response.status_code == 200
+        data = response.json()
+        assert "message" in data
+        assert "Cheese App" in data["message"]
+
+    def test_root_returns_json(self):
+        """Test that root returns JSON content type"""
+        response = client.get("/")
+        assert response.status_code == 200
+        assert "application/json" in response.headers["content-type"]
+
+    def test_euclidean_distance_endpoint(self):
+        """Test the euclidean_distance endpoint"""
+        response = client.get("/euclidean_distance/?x=3&y=4")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["x"] == 3
+        assert data["y"] == 4
+        assert data["distance"] == 5.0
+
+    def test_euclidean_distance_default_parameters(self):
+        """Test euclidean_distance with default parameters"""
+        response = client.get("/euclidean_distance/")
+        assert response.status_code == 200
+        data = response.json()
+        assert "distance" in data
+
+    def test_euclidean_distance_with_floats(self):
+        """Test euclidean_distance with floating point numbers"""
+        response = client.get("/euclidean_distance/?x=1.5&y=2.0")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["x"] == 1.5
+        assert data["y"] == 2.0
+        assert isinstance(data["distance"], float)
+
+    def test_invalid_route_returns_404(self):
+        """Test that invalid routes return 404"""
+        response = client.get("/this-route-does-not-exist")
+        assert response.status_code == 404
+
+    def test_method_not_allowed(self):
+        """Test that POST to GET-only endpoint returns 405"""
+        response = client.post("/")
+        assert response.status_code == 405
+
+
+class TestCORS:
+    """Tests for CORS configuration"""
+
+    def test_cors_enabled(self):
+        """Test that CORS headers are present"""
+        response = client.get("/", headers={"Origin": "http://localhost:3000"})
+        assert response.status_code == 200
+        assert "access-control-allow-origin" in response.headers
+
+    def test_cors_allows_all_origins(self):
+        """Test that CORS allows all origins"""
+        response = client.get("/", headers={"Origin": "http://example.com"})
+        assert response.status_code == 200
+        assert response.headers["access-control-allow-origin"] == "*"
+
+
+@pytest.mark.asyncio
+async def test_api_health_check():
+    """Test that the API is healthy and responding"""
+    response = client.get("/")
+    assert response.status_code == 200
+    # API should respond quickly
+    assert response.elapsed.total_seconds() < 1 if hasattr(response, "elapsed") else True

This change on GitHub

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