Python Essentials for QA
Python is the most popular language for backend/API testing, data validation, and scripting in the QA world. Its readable syntax, rich standard library, and vast ecosystem of testing libraries make it the default choice for QA engineers who work beyond the browser.
Why Python for QA
| Strength | How It Helps QA |
|---|---|
| Readable syntax | Test code is self-documenting; easy for non-Python developers to review |
| pytest ecosystem | The most powerful test framework, with fixtures, parametrize, and plugins |
| requests library | Clean HTTP client for API testing |
| Data libraries | pandas, json, csv — essential for data validation |
| Scripting speed | Quick scripts for log parsing, data cleanup, environment setup |
| Type hints + mypy | Optional static typing catches errors before tests run |
pytest: The Test Framework
pytest is the standard Python test framework. It is simpler than unittest, more powerful, and used by the majority of Python QA teams.
Basic Test Structure
# test_login.py
def test_successful_login(api_client):
response = api_client.post("/auth/login", json={
"email": "test@example.com",
"password": "ValidPass123!"
})
assert response.status_code == 200
assert "access_token" in response.json()
def test_login_with_invalid_password(api_client):
response = api_client.post("/auth/login", json={
"email": "test@example.com",
"password": "wrong"
})
assert response.status_code == 401
assert "access_token" not in response.json()
Fixtures
Fixtures provide reusable setup and teardown. They replace the setup/teardown methods of unittest with a more flexible, composable pattern.
# conftest.py
import pytest
import requests
@pytest.fixture(scope="session")
def base_url():
return os.environ.get("API_BASE_URL", "http://localhost:3000/api/v1")
@pytest.fixture(scope="session")
def auth_token(base_url):
r = requests.post(f"{base_url}/auth/login", json={
"email": "admin@test.com", "password": "adminpass"
})
return r.json()["access_token"]
@pytest.fixture
def auth_headers(auth_token):
return {"Authorization": f"Bearer {auth_token}"}
@pytest.fixture
def api_client(base_url, auth_headers):
session = requests.Session()
session.headers.update(auth_headers)
session.base_url = base_url
return session
Fixture scopes control lifecycle:
scope="function"(default): runs before/after each testscope="class": once per test classscope="module": once per filescope="session": once per test run
Parametrize
Run the same test with different data:
@pytest.mark.parametrize("email,expected_status", [
("valid@test.com", 200),
("", 400),
("not-an-email", 422),
("valid@test.com; DROP TABLE users", 422),
("a" * 255 + "@test.com", 422),
])
def test_login_email_validation(api_client, email, expected_status):
response = api_client.post("/auth/login", json={
"email": email, "password": "ValidPass123!"
})
assert response.status_code == expected_status
Markers
Tag tests for selective execution:
@pytest.mark.smoke
def test_health_check(base_url):
assert requests.get(f"{base_url}/health").status_code == 200
@pytest.mark.slow
def test_full_checkout_flow(api_client):
# ... lengthy test
pass
pytest -m smoke # Run only smoke tests
pytest -m "not slow" # Skip slow tests
The requests Library
The standard HTTP client for API testing in Python.
import requests
# GET with query parameters
r = requests.get("https://api.example.com/users", params={"page": 1, "limit": 10})
print(r.status_code) # 200
print(r.json()) # parsed JSON response
print(r.headers) # response headers
print(r.elapsed) # time taken
# POST with JSON body
r = requests.post("https://api.example.com/users", json={
"name": "Alice",
"email": "alice@example.com"
})
# PUT with headers
r = requests.put("https://api.example.com/users/123",
json={"name": "Alice Updated"},
headers={"Authorization": "Bearer token123"}
)
# DELETE
r = requests.delete("https://api.example.com/users/123",
headers={"Authorization": "Bearer token123"}
)
assert r.status_code == 204
Session Objects
For tests that need to maintain state (cookies, headers) across requests:
session = requests.Session()
session.headers.update({"Authorization": "Bearer token123"})
# All subsequent requests include the auth header
r1 = session.get("https://api.example.com/users/me")
r2 = session.get("https://api.example.com/users/me/orders")
Data Validation Patterns
import json
from datetime import datetime
def test_user_response_structure(api_client, base_url):
r = api_client.get(f"{base_url}/users/me")
user = r.json()
# Required fields exist
required_fields = {"id", "name", "email", "created_at", "role"}
assert required_fields.issubset(user.keys()), f"Missing: {required_fields - user.keys()}"
# Sensitive fields absent
assert "password" not in user
assert "password_hash" not in user
# Type validation
assert isinstance(user["id"], int)
assert isinstance(user["name"], str)
assert len(user["name"]) > 0
# Date format validation
datetime.fromisoformat(user["created_at"]) # Raises if invalid
# Enum validation
assert user["role"] in {"admin", "editor", "viewer"}
Package Management
| Tool | Best For |
|---|---|
| pip | Simple projects, quick installs |
| poetry | Dependency management with lock files, publishing |
| uv | Fast installs, modern replacement for pip + venv |
# Create virtual environment and install dependencies
python -m venv .venv
source .venv/bin/activate
pip install pytest requests httpx
# Or with poetry
poetry init
poetry add pytest requests httpx
# Or with uv
uv init
uv add pytest requests httpx
Practical Exercise
Write a pytest test file that:
- Uses a fixture to authenticate and get a token
- Tests creating a user (POST)
- Tests retrieving the created user (GET)
- Tests that duplicate email creation returns 409
- Uses parametrize to test email validation with at least 5 inputs
- Cleans up the created user in a fixture teardown
Key Takeaways
- pytest is the standard: learn fixtures, parametrize, and markers
- requests is the standard HTTP client: learn sessions, JSON handling, and response inspection
- Use conftest.py for shared fixtures across test files
- Validate response structure (fields, types, absence of sensitive data), not just status codes
- Use parametrize for data-driven testing instead of copy-pasting test functions