Skip to content

Pytest for API Testing: Python Test Framework for REST Endpoints

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Pytest for API Testing: Python Test Framework for REST Endpoints. We cover key concepts, practical examples, and best practices to help you master this topic.

Pytest is a mature Python testing framework with built-in fixtures, parametrization, and plugin ecosystem, making it ideal for API testing with HTTP clients, database setup, mocking, and reporting.

What You'll Learn

How to use Pytest for API testing, create fixtures for HTTP clients and database setup, use parametrization for data-driven tests, mock external HTTP services with responses library, use custom markers for test categorization, and generate Allure reports.

Why It Matters

Pytest is the dominant Python test framework with 12M+ weekly downloads. Its fixture system, parametrization, and plugin ecosystem make it powerful for API testing. DodaTech uses Pytest for all Python microservice integration tests.

Real-World Use

A DodaTech team adds a new endpoint. They write Pytest tests with parametrized inputs (valid, invalid, edge cases), mock the billing service HTTP dependency, seed test database, and run 50 test cases in 2 seconds.

flowchart LR
    A["Pytest\nRunner"] --> B["conftest.py\nFixtures"]
    B --> C["Client\nFixture"]
    B --> D["Database\nFixture"]
    B --> E["Mock\nFixture"]
    C --> F["Test\nFunctions"]
    D --> F
    E --> F
    F --> G["Assertions\n(assert)"]
    G --> H{"All Tests\nPass?"}
    H -->|Yes| I["Report:\nPassed"]
    H -->|No| J["Report:\nFailed"]
    style A fill:#dbeafe,stroke:#2563eb
    style I fill:#bbf7d0,stroke:#16a34a
    style J fill:#fecaca,stroke:#dc2626

Basic Pytest API Test

import pytest
import requests

# Simple API test
class TestUserAPI:
    BASE_URL = "https://api.dodatech.com/v1"

    def test_get_users_returns_list(self):
        response = requests.get(f"{self.BASE_URL}/users")
        assert response.status_code == 200
        data = response.json()
        assert isinstance(data, list)
        assert len(data) > 0

    def test_create_user(self):
        payload = {
            "email": "test@pytest.com",
            "name": "Pytest User",
            "password": "SecurePass123!"
        }
        response = requests.post(f"{self.BASE_URL}/users", json=payload)
        assert response.status_code == 201
        data = response.json()
        assert data["email"] == payload["email"]
        assert "id" in data

Fixtures with conftest.py

# conftest.py - shared fixtures
import pytest
import requests
from database import TestDatabase

@pytest.fixture(scope="session")
def base_url():
    return "https://api.dodatech.com/v1"

@pytest.fixture(scope="module")
def auth_token(base_url):
    """Get auth token once per module."""
    response = requests.post(f"{base_url}/auth/login", json={
        "email": "test@dodatech.com",
        "password": "test-password"
    })
    assert response.status_code == 200
    return response.json()["token"]

@pytest.fixture
def api_client(base_url, auth_token):
    """Provide an authenticated HTTP client."""
    session = requests.Session()
    session.headers.update({
        "Authorization": f"Bearer {auth_token}",
        "Content-Type": "application/json"
    })
    yield session
    session.close()

@pytest.fixture
def test_db():
    """Set up test database with seed data."""
    db = TestDatabase()
    db.seed()
    yield db
    db.cleanup()
# test_users.py - using fixtures
class TestUsersWithFixtures:
    def test_create_and_get_user(self, api_client, base_url, test_db):
        # Create user
        create_resp = api_client.post(f"{base_url}/users", json={
            "email": "fixture-test@example.com",
            "name": "Fixture Test User"
        })
        assert create_resp.status_code == 201
        user_id = create_resp.json()["id"]

        # Verify user exists
        get_resp = api_client.get(f"{base_url}/users/{user_id}")
        assert get_resp.status_code == 200
        assert get_resp.json()["email"] == "fixture-test@example.com"

Parametrized Tests

import pytest

# Data-driven API tests
@pytest.mark.parametrize("email, name, expected_status", [
    ("valid@example.com", "Valid User", 201),
    ("", "Empty Email", 400),
    ("invalid-email", "Bad Email", 400),
    ("valid@example.com", "", 400),
    ("a" * 256 + "@test.com", "Long Email", 400),
])
def test_create_user_validation(api_client, base_url, email, name, expected_status):
    payload = {"email": email, "name": name}
    response = api_client.post(f"{base_url}/users", json=payload)
    assert response.status_code == expected_status

# Expected output:
# test_create_user_validation[valid@example.com-Valid User-201] PASSED
# test_create_user_validation[-Empty Email-400] PASSED
# test_create_user_validation[invalid-email-Bad Email-400] PASSED
# test_create_user_validation[valid@example.com--400] PASSED
# test_create_user_validation[a...@test.com-Long Email-400] PASSED

Mocking External Services

import responses
import pytest

# Mock external HTTP calls using responses library
class TestPaymentAPI:
    @responses.activate
    def test_payment_with_mocked_stripe(self, api_client, base_url):
        # Mock Stripe API call
        responses.add(
            responses.POST,
            "https://api.stripe.com/v1/payment_intents",
            json={"id": "pi_mock_123", "status": "succeeded"},
            status=200
        )

        response = api_client.post(f"{base_url}/payments", json={
            "order_id": "ORD-123",
            "amount": 2999,
            "currency": "usd"
        })
        assert response.status_code == 200
        assert response.json()["payment_id"] == "pi_mock_123"
        assert len(responses.calls) == 1

    @responses.activate
    def test_payment_decline_handling(self, api_client, base_url):
        # Mock Stripe decline
        responses.add(
            responses.POST,
            "https://api.stripe.com/v1/payment_intents",
            json={"error": {"code": "card_declined", "message": "Card declined"}},
            status=402
        )

        response = api_client.post(f"{base_url}/payments", json={
            "order_id": "ORD-456",
            "amount": 5000
        })
        assert response.status_code == 402
        assert "declined" in response.json()["error"].lower()

Custom Markers and Test Organization

# pytest.ini
# [pytest]
# markers =
#     smoke: Quick smoke tests for critical endpoints
#     regression: Full regression test suite
#     slow: Tests that take >5 seconds
#     external: Tests that call external APIs

import pytest

@pytest.mark.smoke
def test_health_check(api_client, base_url):
    response = api_client.get(f"{base_url}/health")
    assert response.status_code == 200

@pytest.mark.smoke
def test_login_endpoint(api_client, base_url):
    response = api_client.post(f"{base_url}/auth/login", json={
        "email": "test@test.com", "password": "test123"
    })
    assert response.status_code in [200, 401]

@pytest.mark.slow
@pytest.mark.external
def test_full_checkout_flow(api_client, base_url):
    # Full E2E test that may take >5 seconds
    pass

# Run only smoke tests:
# pytest -m smoke -v

# Run all except slow:
# pytest -m "not slow" -v

Common Mistakes

1. Using Module-Level Fixtures for Mutable State

Scope=session fixtures share state across all tests. If a test modifies shared state, other tests break. Use scope=function for test-specific data and scope=session only for immutable setup.

2. Not Using responses.activate Decorator

Without @responses.activate, the mock is not active. Tests may make real HTTP calls to external services, causing flaky and slow tests. Always apply the decorator to each test function.

3. Hardcoding URLs Instead of Fixtures

Hardcoded URLs make environment switching impossible. Use fixtures for base_url, auth tokens, and configuration. Override fixtures in conftest.py per environment.

4. Forgetting to Clean Up Mock Calls

Assert len(responses.calls) to verify the expected number of HTTP calls. Unmatched mock calls indicate unexpected external requests in your code.

5. Ignoring Test Isolation with Database

Tests that share database rows interfere with each other. Use transactions that rollback after each test, or truncate test tables between tests, or use unique data per test.

Practice Questions

  1. What is a Pytest fixture and when is it used?
  2. How do you parametrize tests with multiple input combinations?
  3. How do you mock external HTTP calls in Pytest?
  4. What are custom markers and how do you use them?

Answers:

  1. A fixture is a reusable setup/teardown function. Use it for HTTP clients, database connections, authentication tokens, and test data. Fixtures can have session, module, class, or function scope.
  2. Use @pytest.mark.parametrize("arg1, arg2", [(val1a, val1b), (val2a, val2b)]). Each tuple becomes a separate test case with those arguments. Pytest generates descriptive test names from the values.
  3. Use the responses library. Decorate the test with @responses.activate, then responses.add(method, url, json=..., status=...) to mock specific endpoints. Assert len(responses.calls) for verification.
  4. Custom markers (@pytest.mark.smoke) categorize tests. Configure markers in pytest.ini. Run filtered sets: pytest -m smoke, pytest -m "not slow", pytest -m "regression and not external".

Challenge: Write a complete Pytest test suite for an e-commerce API: fixtures for auth and database, parametrized tests for product validation, mocked payment gateway, custom markers for test categories, conftest.py configuration, and run with Allure reporting and coverage.

FAQ

How do I run Pytest tests in parallel?

Install pytest-xdist and run pytest -n auto. Tests run across CPU cores. Ensure test isolation (separate database, no shared state) for safe parallel execution.

What is the difference between scope session and function?

Session scope: fixture created once per test run, shared by all tests. Function scope: fixture created fresh for each test. Use session for immutable setup (config), function for mutable state (database rows).

How do I generate test reports?

Install pytest-html for HTML reports (pytest --html=report.html) or pytest-allure-adaptor for Allure reports. JUnit XML is built-in: pytest --junitxml=results.xml.

Can I use pytest.raises for API error testing?

Yes, pytest.raises works but is better for unit tests. For API testing, assert response.status_code and response.json() directly rather than catching exceptions.

How do I handle async API tests in Pytest?

Install pytest-asyncio. Mark tests with @pytest.mark.asyncio and use async def test_.... Use httpx.AsyncClient for async HTTP calls.

Mini Project

Write a complete Pytest API test suite for a blog API: conftest.py with auth and database fixtures, parametrized tests for post CRUD validation, mocked external image processing service, custom markers (smoke, regression, slow), test data factories, and generate HTML and JUnit reports.

What's Next

Requests Python — master Python requests library for API testing.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro