Pytest can test HTTP endpoints by making actual HTTP requests to your running application, asserting the responses, and managing test state.

Let’s see it in action. Imagine you have a simple Flask app:

from flask import Flask, jsonify, request

app = Flask(__name__)

@app.route('/users', methods=['POST'])
def create_user():
    data = request.get_json()
    if not data or 'username' not in data:
        return jsonify({"error": "username is required"}), 400
    # In a real app, you'd save this to a database
    return jsonify({"message": "User created", "username": data['username']}), 201

@app.route('/users/<username>', methods=['GET'])
def get_user(username):
    # In a real app, you'd fetch from a database
    if username == "alice":
        return jsonify({"username": "alice", "email": "alice@example.com"}), 200
    return jsonify({"error": "User not found"}), 404

if __name__ == '__main__':
    app.run(debug=True)

Now, let’s write a pytest test for it. We’ll use the requests library to make HTTP calls.

import pytest
import requests
import json

# Define the base URL of your running API
BASE_URL = "http://127.0.0.1:5000"

# Fixture to ensure the API is running before tests
@pytest.fixture(scope="session")
def api_server():
    # In a real-world scenario, you'd start your app here
    # or ensure it's running via a separate process manager.
    # For this example, we assume it's started manually or
    # by some other mechanism before running pytest.
    print("\n--- Ensure your Flask app is running at http://127.0.0.1:5000 ---")
    # A simple check to see if the server is responsive
    try:
        response = requests.get(f"{BASE_URL}/users/alice") # Using an existing endpoint to check
        assert response.status_code == 200
        print("API is responsive.")
    except requests.exceptions.ConnectionError:
        pytest.fail("API server is not running or not accessible at BASE_URL.")
    yield

def test_create_user_success(api_server):
    """Tests successful user creation."""
    user_data = {"username": "bob", "email": "bob@example.com"}
    response = requests.post(f"{BASE_URL}/users", json=user_data)

    assert response.status_code == 201
    response_data = response.json()
    assert response_data["message"] == "User created"
    assert response_data["username"] == "bob"

def test_create_user_missing_username(api_server):
    """Tests user creation with missing username."""
    user_data = {"email": "charlie@example.com"}
    response = requests.post(f"{BASE_URL}/users", json=user_data)

    assert response.status_code == 400
    response_data = response.json()
    assert "username is required" in response_data["error"]

def test_get_user_success(api_server):
    """Tests successful retrieval of an existing user."""
    response = requests.get(f"{BASE_URL}/users/alice")

    assert response.status_code == 200
    response_data = response.json()
    assert response_data["username"] == "alice"
    assert response_data["email"] == "alice@example.com"

def test_get_user_not_found(api_server):
    """Tests retrieval of a non-existent user."""
    response = requests.get(f"{BASE_URL}/users/dave")

    assert response.status_code == 404
    response_data = response.json()
    assert "User not found" in response_data["error"]

To run these tests:

  1. Save the Flask app as app.py.
  2. Save the tests as test_api.py.
  3. Install necessary libraries: pip install Flask requests pytest.
  4. Run your Flask app: python app.py.
  5. In a separate terminal, run pytest: pytest.

You’ll see output indicating the tests are running against your live API.

This approach is powerful because it tests the entire stack from the HTTP layer down, including your web server and application logic, just as a real client would interact with it.

The core idea is to use a library like requests to send HTTP requests to your API endpoints and then use pytest’s assertion mechanisms to verify the responses. This includes checking status codes, response bodies (often JSON), headers, and even timing.

The api_server fixture is crucial. It acts as a gatekeeper, ensuring that the API is actually running and accessible before any tests attempt to interact with it. Without this, your tests would likely fail with connection errors, making debugging difficult. In a more complex setup, this fixture might involve starting and stopping a test server, or ensuring a pre-deployed environment is healthy.

You control the tests by defining the BASE_URL and then constructing specific URLs for each endpoint you want to test. The requests library handles the details of forming the HTTP request (GET, POST, PUT, DELETE, etc.), including sending JSON payloads (json=user_data) or query parameters.

The response object from requests is what you’ll assert against. response.status_code is the most common check, followed by response.json() to parse the JSON body into a Python dictionary for further assertions.

One subtle but powerful aspect is how you manage test state. For instance, if you have tests that create data, subsequent tests might need to read that data. You can achieve this using pytest fixtures that return shared state, or by making API calls within tests to set up preconditions. For example, a test_create_user_success might create a user, and then a separate test_get_user_success could be written to fetch that specific user, assuming the test environment is clean or that you’re using unique identifiers.

The requests library also allows you to set custom headers, send form data, handle authentication, and manage cookies, giving you fine-grained control over how your API is tested. You can also use pytest-xdist to run these tests in parallel, but be mindful of shared resources or database states that might cause race conditions if not managed properly.

When testing APIs, especially those that interact with databases or external services, you often need to isolate your tests. This means ensuring that each test starts with a clean slate or predictable state. For API integration tests, this usually involves:

  1. Data Setup: Using API calls (e.g., POST requests) within fixtures or setup steps to create the necessary data before a test runs.
  2. Data Teardown: Using API calls (e.g., DELETE requests) or database cleanup scripts after tests to remove created data, preventing test pollution.
  3. Mocking External Services: If your API depends on other services (like email, payment gateways), you might mock these dependencies to ensure your API tests focus only on your application’s logic, rather than the behavior of external systems. This is often done using libraries like unittest.mock or specialized mocking tools for HTTP.

The next challenge in API integration testing is often managing more complex test scenarios, such as testing sequences of operations or handling asynchronous responses.

Want structured learning?

Take the full Pytest course →