Skip to content

API Test Automation Framework: Building a Reusable Test Infrastructure

DodaTech Updated 2026-06-28 7 min read

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

  1. What are the core components of an API test framework?
  2. How do you handle configuration across environments?
  3. Why is a base test class useful?
  4. How do you make a framework extensible?

Answers:

  1. Configuration management, API client with auth, base test class, assertion helpers, logging, reporting integration, and data factories. Each component has a single responsibility.
  2. 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.
  3. 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.
  4. 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

Should I use pytest or unittest for the framework?

Pytest is preferred for its fixtures, parametrization, and plugin ecosystem. But design the framework to work with both by using standard Python assertions and dependency injection.

How do I handle authentication token refresh?

Implement a token refresh mechanism in the API client. When a 401 is received, automatically refresh the token and retry the request once. Use a lock to prevent concurrent refresh.

How do I run tests against different environments?

Pass environment name via CLI: pytest --env staging. The framework loads the corresponding config file. Environment-specific configs in separate YAML files.

Should the framework include performance testing?

No, keep performance testing separate (k6, JMeter). The functional test framework focuses on correctness. Load tests have different requirements (VUs, ramp-up, thresholds).

How do I manage test data across the framework?

Use data factories (factory_boy) for dynamic data, fixtures for static reference data, and a DataManager class for lifecycle (create, track, cleanup). Each test creates only the data it needs.

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