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.
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.
How API Tests Work in Dredd
-
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.
-
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
- Endpoints (
- This file (
-
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.
-
Hooks (
hooks.py) β The Test Mediator- Hooks allow custom logic before and after API requests.
- They control test execution by:
Modifying request payloads dynamically (e.g., injecting authentication tokens)
Handling dynamic data (e.g., replacing hardcoded user IDs)
Cleaning up test data after execution
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:
-
Create User (
POST /users):- Request Body:
{ "name": "John Doe", "email": "johndoe@example.com" } - Response:
{ "user_id": "USR12345", "name": "John Doe", "email": "johndoe@example.com" }
- Request Body:
-
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" } } } } } } } } } - Swagger file contains a static
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")
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.