API Testing in Practice: Test a REST API with Pytest, Requests, and Fixtures

API Testing in Practice: Test a REST API with Pytest, Requests, and Fixtures

API testing helps you catch broken contracts before users or frontend developers find them. For junior and mid-level developers, the goal is not to build a huge testing framework on day one. The goal is to write small, repeatable tests that prove your API returns the right status codes, response shapes, headers, and error messages.

In this article, you will build a practical API test suite using pytest and requests. The examples target a simple REST API with users and authentication, but the same structure works for most JSON APIs.

What We Are Testing

Assume your API exposes these endpoints:

  • POST /auth/login returns an access token.
  • GET /users/me returns the current authenticated user.
  • POST /users creates a user.
  • GET /users/{id} returns one user by ID.

Your API might be built with FastAPI, Laravel, Express, Django, or another framework. The testing approach stays almost the same because API tests call the application from the outside, just like a real client.

Project Setup

Create a small test project:

api-tests/ ├── pytest.ini ├── requirements.txt └── tests/ ├── conftest.py ├── test_auth.py └── test_users.py 

Add the dependencies:

# requirements.txt pytest==8.3.4 requests==2.32.3 python-dotenv==1.0.1 

Install them:

python -m venv .venv source .venv/bin/activate pip install -r requirements.txt 

On Windows PowerShell, activate the virtual environment with:

.venv\Scripts\Activate.ps1 

Now add basic pytest configuration:

# pytest.ini [pytest] testpaths = tests python_files = test_*.py addopts = -v 

Use Environment Variables for the API URL

Do not hard-code your local or staging API URL in every test. Store it in one place. You can use a .env file locally and real environment variables in CI.

# .env API_BASE_URL=http://localhost:8000 [email protected] API_TEST_PASSWORD=password123 

Create shared test configuration in tests/conftest.py:

# tests/conftest.py import os import pytest import requests from dotenv import load_dotenv load_dotenv() @pytest.fixture(scope="session") def base_url(): url = os.getenv("API_BASE_URL") if not url: raise RuntimeError("API_BASE_URL is not set") return url.rstrip("/") @pytest.fixture(scope="session") def api_session(): session = requests.Session() session.headers.update({ "Accept": "application/json", "Content-Type": "application/json", }) return session 

The base_url fixture gives every test the same API root. The api_session fixture reuses HTTP connection settings and default headers.

Test Login and Token Creation

Start with authentication because many other tests depend on it.

# tests/test_auth.py import os def test_login_returns_access_token(base_url, api_session): payload = { "email": os.getenv("API_TEST_EMAIL"), "password": os.getenv("API_TEST_PASSWORD"), } response = api_session.post(f"{base_url}/auth/login", json=payload) assert response.status_code == 200 data = response.json() assert "access_token" in data assert isinstance(data["access_token"], str) assert len(data["access_token"]) > 20 

This test checks more than “the endpoint works.” It verifies the response status, confirms that the JSON contains an access_token, checks the token type, and makes sure it is not an empty string.

You should also test failure cases. Broken error handling is common in APIs.

def test_login_rejects_wrong_password(base_url, api_session): payload = { "email": os.getenv("API_TEST_EMAIL"), "password": "wrong-password", } response = api_session.post(f"{base_url}/auth/login", json=payload) assert response.status_code == 401 data = response.json() assert "message" in data assert data["message"] 

A useful API test suite includes both happy paths and negative paths. If you only test successful requests, you will miss many real production bugs.

Create an Authenticated Fixture

Instead of repeating login code in every test, create a fixture that returns an authenticated session.

# tests/conftest.py import os import pytest import requests from dotenv import load_dotenv load_dotenv() @pytest.fixture(scope="session") def base_url(): url = os.getenv("API_BASE_URL") if not url: raise RuntimeError("API_BASE_URL is not set") return url.rstrip("/") @pytest.fixture(scope="session") def api_session(): session = requests.Session() session.headers.update({ "Accept": "application/json", "Content-Type": "application/json", }) return session @pytest.fixture(scope="session") def auth_session(base_url): session = requests.Session() session.headers.update({ "Accept": "application/json", "Content-Type": "application/json", }) login_payload = { "email": os.getenv("API_TEST_EMAIL"), "password": os.getenv("API_TEST_PASSWORD"), } response = session.post(f"{base_url}/auth/login", json=login_payload) assert response.status_code == 200, response.text token = response.json()["access_token"] session.headers.update({ "Authorization": f"Bearer {token}" }) return session 

The auth_session fixture logs in once per test session and attaches the bearer token to future requests. This keeps your actual test files clean.

Test the Current User Endpoint

Now you can test a protected endpoint:

# tests/test_users.py def test_get_current_user(base_url, auth_session): response = auth_session.get(f"{base_url}/users/me") assert response.status_code == 200 data = response.json() assert "id" in data assert "email" in data assert "name" in data assert isinstance(data["id"], int) assert "@" in data["email"] 

This test checks the contract that a frontend developer might rely on. If someone renames email to user_email without warning, this test fails before the change reaches production.

Test Creating a User

For create endpoints, generate unique data so the test can run many times without conflicts.

# tests/test_users.py from uuid import uuid4 def test_create_user(base_url, auth_session): unique_id = uuid4().hex[:8] payload = { "name": "API Test User", "email": f"api-test-{unique_id}@example.com", "password": "StrongPassword123!" } response = auth_session.post(f"{base_url}/users", json=payload) assert response.status_code == 201, response.text data = response.json() assert data["name"] == payload["name"] assert data["email"] == payload["email"] assert "id" in data assert "password" not in data 

The final assertion is important. API responses should not expose password fields, even if the value is hashed. A simple test like this can catch a serious security mistake.

Test Validation Errors

Validation tests are useful because APIs often fail at the edges: missing fields, wrong types, invalid emails, and short passwords.

def test_create_user_requires_valid_email(base_url, auth_session): payload = { "name": "Invalid Email User", "email": "not-an-email", "password": "StrongPassword123!" } response = auth_session.post(f"{base_url}/users", json=payload) assert response.status_code in [400, 422] data = response.json() assert "errors" in data or "message" in data 

The expected status code depends on your framework and API conventions. FastAPI commonly returns 422 for validation errors. Some teams prefer 400. Pick one convention for your project and make the tests enforce it.

Test Response Headers

Status codes and JSON bodies are not the whole API contract. Headers also matter, especially for content type, caching, rate limiting, and security.

def test_users_me_returns_json(base_url, auth_session): response = auth_session.get(f"{base_url}/users/me") assert response.status_code == 200 assert "application/json" in response.headers["Content-Type"] 

You can also test cache behavior for public endpoints:

def test_public_profile_cache_header(base_url, api_session): user_id = 1 response = api_session.get(f"{base_url}/users/{user_id}") assert response.status_code == 200 assert "Cache-Control" in response.headers 

Only add header tests for rules your API truly owns. Do not test headers that are injected differently in local, staging, and production unless your environment is stable.

Run a Smaller Test Group with Markers

As your suite grows, you may want to separate smoke tests from slower full regression tests.

# pytest.ini [pytest] testpaths = tests python_files = test_*.py addopts = -v markers = smoke: quick checks for core API health regression: broader API behavior checks 

Use the marker in a test:

import pytest @pytest.mark.smoke def test_get_current_user(base_url, auth_session): response = auth_session.get(f"{base_url}/users/me") assert response.status_code == 200 assert "email" in response.json() 

Run only smoke tests:

pytest -m smoke 

Run everything except smoke tests:

pytest -m "not smoke" 

Add API Tests to CI

API tests become much more valuable when they run automatically. Here is a simple GitHub Actions workflow that runs the tests against an already deployed staging API:

# .github/workflows/api-tests.yml name: API Tests on: push: branches: [main] pull_request: jobs: test-api: runs-on: ubuntu-latest env: API_BASE_URL: ${{ secrets.API_BASE_URL }} API_TEST_EMAIL: ${{ secrets.API_TEST_EMAIL }} API_TEST_PASSWORD: ${{ secrets.API_TEST_PASSWORD }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Run API tests run: pytest 

Store credentials in CI secrets, not in the repository. Use a dedicated test account with limited permissions. Do not use a real admin account for automated API tests.

Practical Rules for Better API Tests

  • Test behavior, not implementation. API tests should not care how the backend code is organized.
  • Keep test data isolated. Generate unique emails, names, and IDs where possible.
  • Assert useful details. Do not stop at status_code == 200. Check fields, types, and important headers.
  • Include negative cases. Invalid tokens, missing fields, and bad input are part of the contract.
  • Keep smoke tests fast. A small set of quick tests should tell you if the API is basically healthy.

Conclusion

Good API testing does not require a complex framework. With pytest, requests, fixtures, and a few clear conventions, you can build a reliable test suite that checks authentication, protected routes, validation, response shape, and headers.

Start with the endpoints your frontend or external clients depend on most. Add one happy-path test, one failure test, and one response-shape test for each important route. Over time, this creates a safety net that lets your team refactor backend code, update dependencies, and deploy with more confidence.


Leave a Reply

Your email address will not be published. Required fields are marked *