API Test Automation Framework: Building a Reusable Test Infrastructure
In this tutorial, you will learn about API Test Automation Framework: Building a Reusable Test Infrastructure. We cover key concepts, practical examples, and best practices to help you master this topic.
An API test automation framework provides reusable infrastructure including base test classes, configuration management, logging, reporting, helper utilities, environment abstraction, and standardized patterns for writing maintainable API tests.
What You'll Learn
How to build a reusable API test automation framework: project structure, configuration management (env-specific settings), base test classes with common setup/teardown, logging and reporting utilities, API client abstraction, and framework extensibility patterns.
Why It Matters
Without a framework, each test reinvents setup, teardown, and error handling. A well-designed framework makes tests consistent, reduces boilerplate, and enables rapid test creation. DodaTech's framework provides 50+ reusable components used across 2,000 tests.
Real-World Use
A new team member writes an API test in 5 minutes: extend BaseApiTest, call self.client.post() with auto-auth, use self.assert_schema() for validation, and self.logger for debug output. The framework handles config, auth, reporting, and cleanup.
flowchart LR
A["BaseApiTest\nClass"] --> B["Config\nLoader"]
A --> C["API Client\n(Auth Wrapped)"]
A --> D["Logger\nSetup"]
A --> E["Assertion\nHelpers"]
A --> F["Reporting\nIntegration"]
B --> G["Environment\nVariables"]
C --> H["Request\n+ Assertions"]
D --> I["Console &\nFile Logging"]
E --> J["Schema\nValidation"]
F --> K["JUnit/Allure\nReports"]
style A fill:#6366f1,color:#fff
style G fill:#dbeafe,stroke:#2563eb
style J fill:#bbf7d0,stroke:#16a34a
Framework Structure
api-test-framework/
config/
__init__.py
settings.py # Configuration loading
environments/ # Environment-specific configs
dev.yaml
staging.yaml
production.yaml
core/
__init__.py
api_client.py # Base HTTP client with auth
base_test.py # Base test class
assertion_helpers.py # Reusable assertion methods
logger.py # Logging configuration
models/
__init__.py
user.py # User data model/factory
order.py # Order data model/factory
utilities/
__init__.py
data_generator.py # Random data generation
db_cleanup.py # Database cleanup utilities
retry_handler.py # Retry decorator for flaky tests
tests/
__init__.py
conftest.py
test_users.py
test_orders.py
test_products.py
reports/ # Generated test reports
conftest.py
pytest.ini
Configuration Management
# config/settings.py
import os
import yaml
from pydantic import BaseSettings
class APITestConfig(BaseSettings):
# API settings
base_url: str = "http://localhost:8000"
api_version: str = "v1"
timeout: int = 30
# Authentication
auth_type: str = "bearer" # bearer, basic, api_key
auth_token: str | None = None
api_key: str | None = None
api_key_header: str = "X-API-Key"
# Database
database_url: str | None = None
# Reporting
report_dir: str = "reports"
log_level: str = "INFO"
# Test configuration
retry_count: int = 3
retry_delay: float = 1.0
default_page_size: int = 20
class Config:
env_file = ".env"
env_prefix = "TEST_"
@classmethod
def load_env(cls, environment: str = "staging"):
"""Load environment-specific configuration."""
config_file = f"config/environments/{environment}.yaml"
if os.path.exists(config_file):
with open(config_file) as f:
overrides = yaml.safe_load(f)
return cls(**overrides)
return cls()
# Usage:
config = APITestConfig.load_env("staging")
print(f"Testing against: {config.base_url}")
print(f"Auth type: {config.auth_type}")
Base API Client
# core/api_client.py
import requests
import logging
from typing import Any, Optional
logger = logging.getLogger(__name__)
class APIClient:
"""Reusable API client with authentication and logging."""
def __init__(self, config):
self.config = config
self.session = requests.Session()
self.session.headers.update({
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "DodaTech-Test-Framework/1.0"
})
self._setup_auth()
def _setup_auth(self):
if self.config.auth_type == "bearer" and self.config.auth_token:
self.session.headers["Authorization"] = f"Bearer {self.config.auth_token}"
elif self.config.auth_type == "api_key" and self.config.api_key:
self.session.headers[self.config.api_key_header] = self.config.api_key
def _log_request(self, method, url, **kwargs):
logger.debug(f"{method} {url}")
if kwargs.get("json"):
logger.debug(f" Body: {kwargs['json']}")
def _log_response(self, response):
logger.debug(f" Status: {response.status_code} ({response.elapsed.total_seconds():.3f}s)")
def get(self, path, **kwargs) -> requests.Response:
url = f"{self.config.base_url}/{self.config.api_version}/{path.lstrip('/')}"
self._log_request("GET", url, **kwargs)
response = self.session.get(url, timeout=self.config.timeout, **kwargs)
self._log_response(response)
return response
def post(self, path, **kwargs) -> requests.Response:
url = f"{self.config.base_url}/{self.config.api_version}/{path.lstrip('/')}"
self._log_request("POST", url, **kwargs)
response = self.session.post(url, timeout=self.config.timeout, **kwargs)
self._log_response(response)
return response
def put(self, path, **kwargs) -> requests.Response:
url = f"{self.config.base_url}/{self.config.api_version}/{path.lstrip('/')}"
self._log_request("PUT", url, **kwargs)
response = self.session.put(url, timeout=self.config.timeout, **kwargs)
self._log_response(response)
return response
def delete(self, path, **kwargs) -> requests.Response:
url = f"{self.config.base_url}/{self.config.api_version}/{path.lstrip('/')}"
self._log_request("DELETE", url, **kwargs)
response = self.session.delete(url, timeout=self.config.timeout, **kwargs)
self._log_response(response)
return response
Base Test Class
# core/base_test.py
import unittest
import logging
import json
from jsonschema import validate, ValidationError
from .api_client import APIClient
from .logger import setup_logger
class BaseAPITest(unittest.TestCase):
"""Base class for all API tests."""
@classmethod
def setUpClass(cls):
cls.config = cls.get_config()
cls.client = APIClient(cls.config)
cls.logger = setup_logger(cls.__name__, cls.config.log_level)
@classmethod
def get_config(cls):
"""Override in subclasses for custom config."""
from config.settings import APITestConfig
return APITestConfig.load_env("staging")
def assert_status(self, response, expected_status: int):
"""Assert HTTP status code with descriptive message."""
self.assertEqual(
response.status_code, expected_status,
f"Expected {expected_status}, got {response.status_code}. "
f"Body: {response.text[:500]}"
)
def assert_json_schema(self, instance, schema):
"""Validate response against JSON schema."""
try:
validate(instance=instance, schema=schema)
except ValidationError as e:
self.fail(f"Schema validation failed: {e.message}\n"
f"Path: {list(e.absolute_path)}\n"
f"Instance: {json.dumps(instance, indent=2)[:200]}")
def assert_response_time(self, response, max_ms: int = 500):
"""Assert response time under threshold."""
elapsed_ms = response.elapsed.total_seconds() * 1000
self.assertLessEqual(
elapsed_ms, max_ms,
f"Response took {elapsed_ms:.0f}ms, expected under {max_ms}ms"
)
def assert_has_fields(self, data, fields: list):
"""Assert response contains required fields."""
for field in fields:
self.assertIn(field, data,
f"Missing field '{field}' in response")
def log_response(self, response):
"""Log response details for debugging."""
self.logger.debug(f"Response: {response.status_code}")
self.logger.debug(f"Body: {response.text[:1000]}")
Framework Extensibility
# plugins/retry_plugin.py - Extending the framework
import functools
import time
import logging
logger = logging.getLogger(__name__)
def retry_on_failure(max_retries=3, delay=1.0, exceptions=(Exception,)):
"""Decorator to retry flaky API calls."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
logger.warning(
f"Attempt {attempt + 1}/{max_retries} failed: {e}"
)
if attempt < max_retries - 1:
time.sleep(delay * (2 ** attempt))
raise last_exception
return wrapper
return decorator
# plugins/data_builder.py
from factory import Factory, Faker, Sequence
class UserData(Factory):
class Meta:
model = dict
email = Faker("email")
name = Faker("name")
password = "TestPassword123!"
class OrderData(Factory):
class Meta:
model = dict
product_id = Faker("uuid4")
quantity = Faker("random_int", min=1, max=5)
# Usage in a test:
# from plugins.retry_plugin import retry_on_failure
# from plugins.data_builder import UserData, OrderData
#
# class TestUserAPI(BaseAPITest):
# @retry_on_failure(max_retries=2)
# def test_create_user(self):
# user = UserData()
# response = self.client.post("/users", json=user)
# self.assert_status(response, 201)
Common Mistakes
1. Over-Engineering the Framework
Building an abstract factory pattern with 10 layers of inheritance makes tests hard to understand. Keep it simple: base class, client, config, and helpers. Add complexity only when needed.
2. Tight Coupling to Test Framework
Using pytest-specific features directly in the framework makes Migration hard. Wrap pytest fixtures in your framework methods. Use standard unittest assertions that work across test runners.
3. No Configuration Validation
Invalid config (wrong URL, missing token, wrong environment) causes cryptic failures. Validate config at startup with clear error messages: "Missing TEST_AUTH_TOKEN environment variable".
4. Ignoring Logging
Without proper logging, debugging CI failures requires re-running tests. Log every request URL, status code, response time, and truncated body. Use structured logging for machine Parsing.
5. Not Versioning the Framework
The framework evolves alongside tests. Version the framework with semantic versioning. Document breaking changes. Pin framework version in test project requirements.
Practice Questions
- What are the core components of an API test framework?
- How do you handle configuration across environments?
- Why is a base test class useful?
- How do you make a framework extensible?
Answers:
- Configuration management, API client with auth, base test class, assertion helpers, logging, reporting integration, and data factories. Each component has a single responsibility.
- Use environment-specific config files (dev.yaml, staging.yaml, production.yaml) with a base config. Override with environment variables. Load at startup and validate all required values.
- A base test class provides shared setup (client, config, logger), reusable assertion methods (assert_status, assert_schema, assert_response_time), and cleanup logic. Tests extend it and only write test-specific code.
- Use plugin architecture for optional features (retry decorator, custom reporters), factory pattern for data generation, and Composition Over Inheritance for flexibility. Allow overriding any component.
Challenge: Build a complete API test automation framework from scratch: configuration management with 3 environment profiles, base API client with retry and logging, base test class with 5 assertion helpers, data factories for 3 entity types, retry decorator plugin, Allure reporting integration, and 3 sample test classes using the framework.
FAQ
Mini Project
Build a production-quality API test automation framework: config management with 3 environments, base API client with auto-auth and retry, base test class with 5 assertions, data factories for 4 entity types, retry/flaky decorator, Allure reporting plugin, environment override CLI option, and 5 test classes demonstrating all features.
What's Next
Reporting Allure — generate rich test reports with Allure Framework.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro