Pytest for API Testing: Python Test Framework for REST Endpoints
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
- What is a Pytest fixture and when is it used?
- How do you parametrize tests with multiple input combinations?
- How do you mock external HTTP calls in Pytest?
- What are custom markers and how do you use them?
Answers:
- 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.
- 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. - Use the
responseslibrary. Decorate the test with@responses.activate, thenresponses.add(method, url, json=..., status=...)to mock specific endpoints. Assertlen(responses.calls)for verification. - Custom markers (
@pytest.mark.smoke) categorize tests. Configure markers inpytest.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
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