Pytest API Automated Testing Framework: A Practical Tutorial

A hands-on tutorial for automating API tests with pytest and the requests library: fixtures, parametrize, response assertions, and project layout.

INEZA Felin-Michel

INEZA Felin-Michel

22 May 2026

Pytest API Automated Testing Framework: A Practical Tutorial

Python developers reach for pytest because it gets out of the way. A test is just a function whose name starts with test_, an assertion is a plain assert statement, and the runner does the rest. Pair it with the requests library and you have a complete, code-first framework for automating API tests, with no heavyweight ceremony.

This tutorial shows how to build a real pytest API testing suite. You will set up the project, write your first request test, share setup logic with fixtures, run the same test against many inputs with parametrize, and assert against response status, body, and JSON Schema. Every example uses a realistic public-style API so you can adapt the code directly.

Setting up the project

Install the two libraries you need into a virtual environment:

python -m venv .venv
source .venv/bin/activate
pip install pytest requests jsonschema

A clean layout keeps the suite maintainable as it grows:

api-tests/
  conftest.py        # shared fixtures
  test_users.py      # tests for the users endpoints
  test_orders.py     # tests for the orders endpoints
  pytest.ini         # configuration

Pytest discovers tests automatically. Files must start with test_ or end with _test.py, functions must start with test_, and test classes must start with Test and have no __init__ method. Follow those rules and you never configure discovery by hand. If automated testing as a discipline is new to you, our primer on what automated testing is sets the context.

Why pytest for API testing specifically? The requests library handles the HTTP, and pytest handles everything around it: discovery, assertions with readable failure output, setup and teardown through fixtures, data-driven runs through parametrize, and reporting. You assemble a complete API automation framework from two small, well-documented libraries, in a language your team likely already uses for the application itself. That proximity matters. Tests living in the same repository as the code stay honest, because a breaking change and its failing test show up in the same pull request.

Writing your first API test

A pytest API test sends a request and asserts on the response. Here is a test against a users endpoint:

import requests

BASE_URL = "https://api.example.com/v1"

def test_get_user_returns_200():
    response = requests.get(f"{BASE_URL}/users/42")
    assert response.status_code == 200

def test_get_user_returns_expected_fields():
    response = requests.get(f"{BASE_URL}/users/42")
    body = response.json()
    assert body["id"] == 42
    assert "email" in body
    assert body["status"] == "active"

Run the suite with pytest -v. Each assert that fails produces a detailed report showing the actual value, which is one of pytest’s best features. You do not need special assertion methods; the framework rewrites plain assert statements to give rich output. For the wider set of checks worth making on a response, see our guide to API assertions.

Sharing setup with fixtures

Repeating the base URL and an HTTP session in every test is wasteful. Fixtures solve this. A fixture is a function decorated with @pytest.fixture that produces a value tests can request by naming it as a parameter.

Put shared fixtures in conftest.py so every test file can use them without importing:

# conftest.py
import pytest
import requests

BASE_URL = "https://api.example.com/v1"

@pytest.fixture(scope="session")
def api_session():
    session = requests.Session()
    session.headers.update({"Accept": "application/json"})
    yield session
    session.close()

@pytest.fixture
def auth_token(api_session):
    response = api_session.post(
        f"{BASE_URL}/auth/login",
        json={"email": "qa@example.com", "password": "test-pass"},
    )
    return response.json()["token"]

The scope="session" argument means the session is created once for the whole run instead of per test. The yield keyword splits setup from teardown: code before yield runs first, code after runs when the fixture goes out of scope. A test simply requests what it needs:

def test_create_order(api_session, auth_token):
    response = api_session.post(
        f"{BASE_URL}/orders",
        headers={"Authorization": f"Bearer {auth_token}"},
        json={"product_id": 7, "quantity": 2},
    )
    assert response.status_code == 201
    assert response.json()["status"] == "pending"

Fixtures are pytest’s modern replacement for the older setup_function and teardown_function style. They compose cleanly, support scopes, and make dependencies explicit, which is why the official pytest fixtures documentation recommends them as the default approach.

Running one test against many inputs

API endpoints usually need to be checked against many inputs: valid values, invalid values, and edge cases. Writing a separate function for each is tedious. The @pytest.mark.parametrize decorator runs one test body against a list of inputs:

import pytest
import requests

BASE_URL = "https://api.example.com/v1"

@pytest.mark.parametrize("user_id,expected_status", [
    (42, 200),
    (99999, 404),
    (0, 404),
    (-1, 400),
])
def test_get_user_status_codes(api_session, user_id, expected_status):
    response = api_session.get(f"{BASE_URL}/users/{user_id}")
    assert response.status_code == expected_status

This produces four separate test cases from one function. Each runs and reports independently, so a single bad input does not hide the others. Parametrize is pytest’s built-in answer to data-driven testing. When the input set grows large, load it from a file instead; our guide to data-driven API testing with CSV and JSON covers that pattern. If you are unsure which status code each input should return, the reference on HTTP status codes REST APIs should use is a useful companion.

Asserting on the response body and schema

Status codes are necessary but not sufficient. A 200 response with a malformed body is still a bug. Assert on the parsed JSON directly:

def test_order_response_shape(api_session, auth_token):
    response = api_session.post(
        f"{BASE_URL}/orders",
        headers={"Authorization": f"Bearer {auth_token}"},
        json={"product_id": 7, "quantity": 2},
    )
    body = response.json()
    assert isinstance(body["id"], int)
    assert body["quantity"] == 2
    assert body["total"] > 0
    assert response.elapsed.total_seconds() < 1.0

For stronger guarantees, validate the body against a JSON Schema. This catches structural drift, such as a renamed or missing field, that hand-written checks miss:

from jsonschema import validate

order_schema = {
    "type": "object",
    "required": ["id", "product_id", "quantity", "status", "total"],
    "properties": {
        "id": {"type": "integer"},
        "product_id": {"type": "integer"},
        "quantity": {"type": "integer", "minimum": 1},
        "status": {"type": "string"},
        "total": {"type": "number"},
    },
}

def test_order_matches_schema(api_session, auth_token):
    response = api_session.post(
        f"{BASE_URL}/orders",
        headers={"Authorization": f"Bearer {auth_token}"},
        json={"product_id": 7, "quantity": 2},
    )
    validate(instance=response.json(), schema=order_schema)

Schema validation scales better than field-by-field assertions because one schema covers the whole response shape. The jsonschema library is the standard choice, and its validation documentation explains the supported keywords.

Running the suite in CI

A pytest suite earns its keep when it runs automatically. Pytest returns a non-zero exit code on failure, which is exactly what a CI server needs to fail a build. Emit a JUnit report for inline display:

pytest -v --junitxml=results.xml

Wire that command into a GitHub Actions step or any other pipeline and your API tests gate every commit. Our walkthrough of API tests in CI/CD pipelines shows the full setup, including secret injection for tokens and environment selection.

Two CI habits keep a pytest suite reliable. First, never hard-code secrets or environment URLs in test files. Read them from environment variables so the same suite runs against staging in CI and locally without edits:

import os

BASE_URL = os.environ.get("API_BASE_URL", "https://staging.example.com/v1")

Second, run independent tests in parallel to keep feedback fast. The pytest-xdist plugin spreads tests across CPU cores with pytest -n auto. Parallel runs only work if your tests do not share state, which is one more reason the test data layer matters. A suite that depends on execution order will fail unpredictably the moment it runs in parallel.

Keeping a pytest suite maintainable

A suite of fifty tests is easy. A suite of five hundred needs discipline. Three practices keep a pytest API framework healthy as it grows.

Group related tests into modules and use classes only when they share setup, not for decoration. A test_orders.py file with a clear set of functions reads better than one giant file. Use marks, registered in pytest.ini, to tag tests so you can run subsets: @pytest.mark.smoke for a fast gate, @pytest.mark.slow for the full sweep. Run the smoke set on every commit and the full set nightly.

Centralize configuration. Base URLs, schemas, and shared fixtures belong in conftest.py or a small config module, never copy-pasted across files. When the staging URL changes, you should edit one line. The same modular discipline that applies to any framework, covered in our guide to writing automated test scripts, applies here: extract anything you write twice into a fixture or helper.

When to reach for a platform instead

A pytest framework is excellent when your team writes Python and wants tests living beside application code. It is less convenient when QA or product staff need to contribute, or when you want test design, mocking, and execution in one place without maintaining glue code.

Apidog covers that gap. It provides visual test building, schema validation against your OpenAPI spec, data-driven runs from CSV and JSON, and a CLI runner for CI, all without writing fixture and assertion code by hand. Many teams run both: pytest for logic-heavy scenarios and Apidog for broad coverage and for designing and mocking the APIs the pytest suite tests against. You can download Apidog and compare the two approaches on a real endpoint in an afternoon.

Frequently asked questions

Why use pytest instead of Python’s built-in unittest for API testing?

Pytest needs less boilerplate. Tests are plain functions, assertions are plain assert statements with rich failure output, and fixtures handle setup more flexibly than unittest’s class-based methods. Pytest also has a large plugin ecosystem and built-in parametrize for data-driven tests. It can still run existing unittest-style tests, so migration is low risk.

What is the difference between a fixture and parametrize?

A fixture supplies a reusable resource, such as an HTTP session or an auth token, to any test that requests it. Parametrize runs the same test body multiple times against different input values. Fixtures share setup; parametrize multiplies cases. They combine well: a parametrized test can still depend on fixtures.

Should I assert on response time in pytest API tests?

You can, using response.elapsed.total_seconds(), and a loose upper bound catches gross regressions. But pytest is a functional testing tool, not a load tester. For real performance work, use a dedicated tool. Keep timing assertions generous so normal network variance does not cause flaky failures.

How do I keep API tests independent in pytest?

Give each test its own data through fixtures that create and clean up resources, and avoid relying on test execution order. Pytest runs tests in file order by default, but a well-designed suite does not depend on that. Independent tests can run in parallel and fail in isolation, which makes debugging far easier.

Can pytest validate responses against an OpenAPI specification?

Pytest itself does not, but you can validate against a JSON Schema with the jsonschema library, and plugins exist that check responses against an OpenAPI document. If schema validation is central to your workflow, a platform like Apidog that validates against your OpenAPI spec automatically may save you the plugin setup.

Practice API Design-first in Apidog

Discover an easier way to build and use APIs