πŸš€ Automated API Testing with Dredd Hooks - Handling Dynamic Data and Cleanup Efficiently

:open_book: Introduction

API tests play a crucial role in backend development by ensuring that services function as expected. However, traditional API testing often relies on static test data, making tests unreliable when data changes.

For example, an API test may attempt to fetch a specific user_id stored in a database. If that record is modified or deleted, the test fails, requiring manual intervention to update the test data. This dependency on static data leads to brittle tests and inefficiencies in automation.

The best approach is to make API tests dynamic, ensuring they are not dependent on any fixed dataset. This is where Dredd and hooks come in.


:warning: Problem Statement

APIs often require unique or dynamically generated data, making static test cases impractical. When test cases rely on hardcoded values, they break if the data is removed or altered.

Challenges with Static API Tests

  • Tests fail when predefined data no longer exists.
  • Requires frequent manual updates to test cases.
  • Leads to unreliable automation, increasing debugging efforts.

Dredd, an API testing tool, validates APIs against their Swagger (OpenAPI) specifications. However, real-world API testing often requires dynamic test execution, such as injecting authentication tokens, generating unique test data, and handling ID-dependent requests.


:white_question_mark: How API Tests Work in Dredd

  1. Dredd Configuration (dredd.yml)

    • This file acts as the test runner’s configuration.
    • It contains the Swagger file path (swagger.json) and defines API endpoints to test.
    • Example dredd.yml:
         hookfiles: hooks.py
         language: python
         config: ./dredd.yml
         blueprint: example-swagger.json
         endpoint: 'https://apis.dev.example.com'
      
    • When Dredd runs, it reads this file and validates API requests based on the Swagger documentation.
  2. Swagger (OpenAPI) Specification

    • This file (swagger.json) contains API definitions such as:
      • Endpoints (/users, /orders/{order_id})
      • Request methods (GET, POST, PATCH, DELETE)
      • Expected request body and responses
  3. Dredd Executes Tests

    • Dredd makes actual API calls based on the Swagger specification.
    • It compares the expected responses (defined in Swagger) with the actual API responses from the server.
    • If responses match, the test passes; otherwise, it fails.
  4. Hooks (hooks.py) – The Test Mediator

    • Hooks allow custom logic before and after API requests.
    • They control test execution by:
      :white_check_mark: Modifying request payloads dynamically (e.g., injecting authentication tokens)
      :white_check_mark: Handling dynamic data (e.g., replacing hardcoded user IDs)
      :white_check_mark: Cleaning up test data after execution

:memo: How to write hooks.py

This explains the usage of Dredd hooks for automated API testing, ensuring the creation, validation, and management of test data.

1. Logging Configuration

Logging is configured to log messages to a file named hooks.log in append mode. This helps track execution flow and errors.

Example:

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    filename="hooks.log",
    filemode="a",
)

2. Global Variables

Some test data is stored globally to be accessed across different hook functions.

Global variables to store data across tests

Example:


user_id = None
user_email = None
user_email_duplicate = None

3. Setup Test Data

This step is executed before all API tests. It creates test users dynamically and stores key details for validation in later tests. We use before_all hooks to capture IDs or emails that are duplicated, which can be utilized for 409 conflict test cases.

Example:

import os
import requests
from dredd_hooks import before_all

@before_all
def setup_test_data(transaction):
    """
    Hook to prepare test data before all tests run.
    """
    token = os.environ.get("TOKEN")
    
    global user_country_code
    global user_phone_number
    global user_email_duplicate
    
    if not token:
        logging.warning("No token available to call the create API.")
        return
    
    create_endpoint = "https://apis.dev.example.com/users/"
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
    }

    random_country_code = random.choice(["+1", "+91", "+44"])
    random_number = random.randint(1000000000, 9999999999)

    payload = generate_user_data(random_country_code, random_number)
    payload["basic_details"]["email_address"] = f"apitestuser+{random_number}@gmail.com"
    
    try:
        response = requests.post(create_endpoint, headers=headers, json=payload)
        response.raise_for_status()
        user = response.json()

        user_country_code = user["data"]["country_code"]
        user_phone_number = user["data"]["phone_number"]
        user_email_duplicate = user["data"]["email_address"]
    except requests.exceptions.RequestException as e:
        logging.error(f"Error while creating test users: {e}")

4. Handling Dynamic IDs in Requests

For GET, PATCH, and DELETE APIs, we need valid IDs that are generated when adding the record. Hardcoding these IDs in Swagger files or hooks leads to test failures when the IDs become invalid due to deletion or modification.

Example:

Let’s assume we are testing an API that manages users. When a user is created, the response returns a unique user_id, such as USR12345.

Sample API Flow:

  1. Create User (POST /users):

    • Request Body:
      {
        "name": "John Doe",
        "email": "johndoe@example.com"
      }
      
    • Response:
      {
        "user_id": "USR12345",
        "name": "John Doe",
        "email": "johndoe@example.com"
      }
      
  2. Get User (GET /users/USR_STATIC_ID):

    • Swagger file contains a static user_id, which leads to failures when the ID changes.
    {
      "paths": {
        "/users/{user_id}": {
          "get": {
            "summary": "Get User Details",
            "parameters": [
              {
                "name": "user_id",
                "in": "path",
                "required": true,
                "schema": {
                  "type": "string"
                },
                "example": "USR_STATIC_ID"
              }
            ],
            "responses": {
              "200": {
                "description": "User details retrieved successfully",
                "content": {
                  "application/json": {
                    "example": {
                      "user_id": "USR_STATIC_ID",
                      "name": "John Doe",
                      "email": "johndoe@example.com"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
    

We capture `user_id` dynamically from `POST` responses and replace the static ID in subsequent requests.

Capturing the Dynamic User ID (after_each Hook)

The after_each hook runs after every API request and can be used to extract dynamic data from responses.

from dredd_hooks import after_each
import json
import logging

# Configure logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")

# Global variable to store the generated user ID
user_id = None

@after_each
def capture_response(transaction):
    global user_id
    
    if transaction["expected"]["statusCode"] == "201":  # Capture ID for user creation response
        try:
            response_body = json.loads(transaction["real"]["body"])
            user_id = response_body.get("user_id")
            logging.info(f"Captured User ID: {user_id}")
        except (ValueError, KeyError) as e:
            logging.error(f"Failed to parse response: {e}")

Replacing Static User ID (before_each Hook)

The before_each hook runs before every API request, allowing us to modify request data dynamically.

from dredd_hooks import before_each

@before_each
def set_dynamic_ids(transaction):
    global user_id
    
    if transaction["expected"]["statusCode"] == "200":
        if transaction["request"]["method"] == "GET" and transaction["request"]["uri"].startswith("/users"):
            transaction["fullPath"] = transaction["fullPath"].replace("USR_STATIC_ID", user_id)  # Replacing static ID
            logging.info(f"Replaced static User ID in request path: {transaction['fullPath']}")

5. Deleting Test Data to Maintain a Clean Environment

Repeated test executions create new data, leading to clutter and inconsistencies. Managing and cleaning up this test data efficiently is crucial.

To keep the environment clean, we delete the test data after all tests have run.

If a delete feature is available, running the API test for the delete API with the created ID will remove the corresponding record. Otherwise, we need to call a separate delete API that removes all records starting with the api_test keyword, which must be included during creation.

Deleting Test Data (after_all Hook)

The after_all hook runs after all tests have finished. We can use it to trigger cleanup logic.

from dredd_hooks import after_all
import os
import requests
import logging

@after_all
def delete_test_data(transaction):
    token = os.environ.get("TOKEN")
    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
    
    delete_endpoint = "https://apis.dev.example.net/bdd/delete-users"
    requests.delete(delete_endpoint, headers=headers)
    logging.info("Test data deleted")

:bullseye: Conclusion

Dredd is a fantastic tool for API testing, but its true power is unlocked when combined with hooks. By leveraging hooks, we:

  • Dynamically capture and use generated IDs, eliminating the need for static test data.
  • Ensure our API tests remain reliable even as the database changes.
  • Implement automatic test data cleanup, maintaining a clean testing environment.

By following these approaches, we make API testing more efficient, robust, and maintainable.

6 Likes