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
StatusUse it when
200 / 201Read succeeded / resource created (return a Location header)
204Write succeeded with nothing to return
400Malformed request: bad JSON, missing header
401 / 403Not authenticated / authenticated but not allowed
404No such resource — or one the caller may not see
405Method not allowed on an existing URL
409Conflict: duplicate unique key, version mismatch
422Well-formed body that fails validation
429Rate limited; include Retry-After
500Your 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; use data=... 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 == 404
coverage 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.

Authentication with Flask-Login and password hashing File uploads, static files and sending email

Last refreshed 2026-09-18.