Building and testing a JSON API
Return consistent JSON with honest status codes, then test the contract with the test client, fixtures and a database that is not the one you develop on.
Designing the responses
from flask import jsonify, request, url_for
@app.get("/api/v1/posts")
def list_posts():
items = Post.query.filter_by(published=True).limit(20).all()
return jsonify({
"data": [serialize_post(p) for p in items],
"meta": {"count": len(items)},
})
@app.post("/api/v1/posts")
def create_post():
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return jsonify({"error": {"code": "invalid_json",
"message": "A JSON object body is required."}}), 400
errors = validate_post(payload)
if errors:
return jsonify({"error": {"code": "validation_failed", "fields": errors}}), 422
post = Post(title=payload["title"], body=payload.get("body", ""))
db.session.add(post)
db.session.commit()
return jsonify({"data": serialize_post(post)}), 201
# a bare dict is serialised to JSON automatically; a tuple sets the status
def serialize_post(post):
return {"id": post.id, "title": post.title,
"url": url_for("post_detail", post_id=post.id, _external=True)}
@app.errorhandler(404)
def not_found(err):
if request.path.startswith("/api/"):
return jsonify({"error": {"code": "not_found", "message": "No such resource."}}), 404
return render_template("404.html"), 404| Status | Use it when |
|---|---|
| 200 / 201 | Read succeeded / resource created (return a Location header) |
| 204 | Write succeeded with nothing to return |
| 400 | Malformed request: bad JSON, missing header |
| 401 / 403 | Not authenticated / authenticated but not allowed |
| 404 | No such resource — or one the caller may not see |
| 405 | Method not allowed on an existing URL |
| 409 | Conflict: duplicate unique key, version mismatch |
| 422 | Well-formed body that fails validation |
| 429 | Rate limited; include Retry-After |
| 500 | Your bug — log it and return a generic body |
Testing with the client and fixtures
# tests/conftest.py
import pytest
from app import create_app
from app.extensions import db as _db
from app.models import User
@pytest.fixture
def app(tmp_path):
application = create_app("testing")
application.config.update(
SQLALCHEMY_DATABASE_URI=f"sqlite:///{tmp_path/'test.db'}",
SERVER_NAME="localhost",
)
with application.app_context():
_db.create_all()
yield application
_db.session.remove()
_db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def auth_client(client, app):
user = User(email="[email protected]")
user.set_password("pw12345678")
_db.session.add(user)
_db.session.commit()
with client.session_transaction() as sess:
sess["_user_id"] = str(user.id) # what Flask-Login writes at login
sess["_fresh"] = True
return client# tests/test_api.py
def test_list_returns_published_only(client, app):
... # create one draft, one published
res = client.get("/api/v1/posts")
assert res.status_code == 200
body = res.get_json()
assert body["meta"]["count"] == 1
assert body["data"][0]["title"] == "Published post"
def test_create_rejects_bad_body(client, auth_client):
res = auth_client.post("/api/v1/posts", json={"title": ""})
assert res.status_code == 422
assert res.get_json()["error"]["code"] == "validation_failed"
def test_auth_login_flow(client):
res = client.post("/auth/login", data={"email": "[email protected]",
"password": "pw12345678"})
assert res.status_code == 302
# a follow-up request is authenticated
assert client.get("/api/v1/me").status_code == 200
def test_error_shape_is_stable(client):
res = client.get("/api/v1/posts/9999")
assert res.status_code == 404
assert res.get_json()["error"]["code"] == "not_found"client.post(json=...)sets the content type and serialises for you; usedata=...only for form posts.session_transaction()lets you seed or inspect the session as a block. It is the fastest way to test an authenticated request without going through the login form.- Assert the contract: status code, the keys the client depends on, and the error code string. Do not assert a full JSON blob — one added field breaks a test that was never about that field.
Edge cases worth a test
def test_missing_json_content_type(client, auth_client):
res = auth_client.post("/api/v1/posts", data="not json",
content_type="text/plain")
assert res.status_code == 400
def test_duplicate_title_conflicts(client, auth_client):
auth_client.post("/api/v1/posts", json={"title": "Same"})
res = auth_client.post("/api/v1/posts", json={"title": "Same"})
assert res.status_code == 409
def test_anonymous_write_is_refused(client):
res = client.post("/api/v1/posts", json={"title": "Hack"})
assert res.status_code in (401, 403) # never 200, never 500
def test_delete_of_another_users_post_is_404(client, auth_client):
other = make_post(owner="someone-else")
assert auth_client.delete(f"/api/v1/posts/{other.id}").status_code == 404coverage run -m pytest && coverage report --skip-covered --fail-under=85
pytest -x -q --maxfail=1 # stop at the first failure while iterating⚠️
A test suite pointed at the development or production database is worse than no tests: it will pass locally, and the first run that deletes a row cannot be undone. Build the fixtures from a dedicated configuration whose database URI cannot resolve to anything real — an in-memory SQLite or a temporary file per test session — and create and drop the schema inside the fixture, not by hand.
FAQ
Should the API return 401 or 403 for an anonymous request?
401 when credentials are missing or invalid and the endpoint requires authentication; 403 when the caller is authenticated but lacks permission. Clients use the difference to decide whether to prompt for login. Returning 403 for an anonymous browser request makes a login prompt impossible.
How do I test a JSON API from outside the app?
The test client covers request handling, routing and serialisation without a network. Add a small collection of end-to-end checks with a real HTTP client against a staging deployment for the things the client cannot see — TLS, proxy headers, the WSGI server's behaviour under load.
Related
Authentication with Flask-Login and password hashing File uploads, static files and sending email
Last refreshed 2026-09-18.