5. Testing Flask Routes
So far we've tested pure Python logic and HTTP clients in isolation. Now we'll step up to full integration tests: your Flask routes running end-to-end, with the URL routing, view function, JSON serialisation, and status codes all real. The only thing we'll stub out is the network call to the upstream weather API.
The trick is Flask's built-in test_client(). It runs your app in-process, with no socket and no port. A test that would take 300 milliseconds over real HTTP runs in under five milliseconds with the test client.
Why not just run a real server?
Beginners often think testing a Flask app means starting it on port 5000 and firing requests at localhost with the requests library. That works, but it's slow, fragile, and harder than it needs to be.
The test client takes your Flask app object, builds a fake WSGI environment, and calls your view functions directly. You get real responses back: status codes, headers, JSON bodies, redirects. But there's no socket, no port, no separate process to manage. That's the sweet spot for integration testing: as real as possible, but without the flakiness of real networking.
Install Flask
You'll need Flask for this section. From the project root with your venv active:
pip install Flask
pip freeze > requirements.txt
The freeze updates your requirements.txt so a teammate or CI server can recreate the environment. Do that any time you install a new package.
The app we'll test
Here's a small Flask app that wraps the weather client from Section 4. One endpoint, /weather, accepts a city query parameter and returns JSON. Save this at the root of your project as app.py (next to weather_client.py, not inside tests/):
from flask import Flask, request, jsonify
import requests
from weather_client import get_weather
app = Flask(__name__)
app.config["WEATHER_API_KEY"] = "replace_me"
@app.route("/weather")
def weather():
city = request.args.get("city")
if not city:
return jsonify({"error": "city parameter required"}), 400
try:
data = get_weather(city, api_key=app.config["WEATHER_API_KEY"])
return jsonify(data)
except requests.HTTPError:
return jsonify({"error": "weather service unavailable"}), 503
Three behaviours to lock down: the happy path where a real city returns weather data, the missing-parameter case that should return 400, and the upstream-failure case that should translate into a clean 503. We'll write one test for each.
The test client fixture
Flask's test client is built from your app object. Rather than build it by hand inside every test, we'll put a pytest fixture in conftest.py. Anything in conftest.py is automatically available to every test in the same directory without an import.
Save this as tests/conftest.py:
import pytest
from app import app as flask_app
@pytest.fixture
def client():
flask_app.config.update(
TESTING=True,
WEATHER_API_KEY="test_key",
)
with flask_app.test_client() as test_client:
yield test_client
Two things worth noticing. TESTING=True tells Flask to propagate exceptions up to pytest instead of swallowing them and returning a generic 500, which makes failures much easier to debug. And overriding WEATHER_API_KEY to a fake value in the fixture means your tests never depend on a real key being set in your shell.
The happy-path test
With the fixture in place, writing the first route test is short. Any test function that declares client as a parameter receives the fixture automatically. Save this as tests/test_app.py:
import responses
@responses.activate
def test_weather_endpoint_returns_data(client):
responses.add(
responses.GET,
"https://api.openweathermap.org/data/2.5/weather",
json={
"name": "Dublin",
"main": {"temp": 11.2},
"weather": [{"description": "light rain"}],
},
status=200,
)
response = client.get("/weather?city=Dublin")
assert response.status_code == 200
assert response.get_json() == {
"city": "Dublin",
"temp_c": 11.2,
"description": "light rain",
}
Run it from the project root:
$ pytest tests/test_app.py
tests/test_app.py . [100%]
============================== 1 passed in 0.05s ==============================
One test, two layers exercised. The Flask routing, view function, JSON serialisation, and status code are all real. The HTTP call to OpenWeather is mocked by responses. That combination is genuine integration testing: your code running end-to-end, with only the external boundaries stubbed.
A few test client methods worth knowing before we go further: client.get(path) and client.post(path, json={...}, data={...}) cover most cases. For form data, pass data=. For a JSON body, pass json=. The response object gives you response.status_code, response.get_json(), response.data (raw bytes), and response.headers.
Testing validation and error paths
Happy paths are the easy part. The endpoint should also behave correctly when the client sends bad input or when the upstream service fails. Both deserve their own tests. Add these to the bottom of your existing tests/test_app.py:
def test_weather_endpoint_rejects_missing_city(client):
response = client.get("/weather")
assert response.status_code == 400
assert response.get_json() == {"error": "city parameter required"}
@responses.activate
def test_weather_endpoint_handles_upstream_outage(client):
responses.add(
responses.GET,
"https://api.openweathermap.org/data/2.5/weather",
status=503,
)
response = client.get("/weather?city=Dublin")
assert response.status_code == 503
assert response.get_json() == {"error": "weather service unavailable"}
Run the whole file from the project root. You should see three tests passing: the happy path from before, plus the two new error paths.
$ pytest tests/test_app.py
tests/test_app.py ... [100%]
============================== 3 passed in 0.06s ==============================
The first test doesn't need @responses.activate at all, because the 400 response is returned before get_weather is ever called. That's worth paying attention to: if you had accidentally placed the validation check after the API call, the missing mock would have caused a confusing ConnectionError and pointed you straight at the bug.
The second test proves something genuinely useful. When OpenWeather returns a 503, your endpoint translates it into a 503 response with a clean error body, instead of crashing with a traceback or leaking the upstream error shape. That's exactly the kind of behaviour you want locked down by a test, so a future refactor doesn't accidentally break it.
What not to test at this layer
You already tested the weather client's transformation logic in Section 4. Don't test it again through the Flask endpoint. Route tests should verify the route-specific behaviour: URL routing, query parameter parsing, status codes, JSON envelope format, error translation. If you write a dozen route tests that each re-check the transformation logic, you've coupled your suite unnecessarily and slowed it down. Each layer gets its own tests.
Next, we'll take these same route tests and layer authentication on top: sessions, login, and protected endpoints.