Skip to content

Testing Patterns

Common patterns for testing CRUD operations, auth flows, payment systems, and multi-tenant applications.

Overview

This guide covers battle-tested patterns for testing different types of APIs with VenomQA.

CRUD Testing

The most common pattern: testing Create, Read, Update, Delete operations.

Basic CRUD

Python
from venomqa import Action, Agent, Invariant, Severity, World
from venomqa.adapters.http import HttpClient

api = HttpClient("http://localhost:8000")
world = World(api=api, state_from_context=["item_id"])

# Create
def create_item(api, context):
    resp = api.post("/items", json={"name": "test", "value": 100})
    context.set("item_id", resp.json()["id"])
    context.set("item_name", "test")
    return resp

# Read
def get_item(api, context):
    item_id = context.get("item_id")
    if not item_id:
        return None  # Skip: no item exists
    return api.get(f"/items/{item_id}")

# Update
def update_item(api, context):
    item_id = context.get("item_id")
    if not item_id:
        return None
    return api.patch(f"/items/{item_id}", json={"value": 200})

# Delete
def delete_item(api, context):
    item_id = context.get("item_id")
    if not item_id:
        return None
    resp = api.delete(f"/items/{item_id}")
    if resp.ok:
        context.delete("item_id")
    return resp

# List
def list_items(api, context):
    return api.get("/items")

actions = [
    Action(name="create_item", execute=create_item),
    Action(name="get_item", execute=get_item, preconditions=["create_item"]),
    Action(name="update_item", execute=update_item, preconditions=["create_item"]),
    Action(name="delete_item", execute=delete_item, preconditions=["create_item"]),
    Action(name="list_items", execute=list_items),
]

# Invariants
def item_consistency(world):
    """GET should return same data as created."""
    item_id = world.context.get("item_id")
    if not item_id:
        return True
    resp = world.api.get(f"/items/{item_id}")
    if not resp.ok:
        return True  # Item deleted, OK
    return resp.json()["name"] == world.context.get("item_name")

def no_500_errors(world):
    """No server errors allowed."""
    return world.context.get("last_status", 200) < 500

invariants = [
    Invariant("item_consistency", item_consistency, Severity.HIGH),
    Invariant("no_500_errors", no_500_errors, Severity.CRITICAL),
]

agent = Agent(
    world=world,
    actions=actions,
    invariants=invariants,
    max_steps=100,
)
result = agent.explore()

CRUD with Pagination

Python
def list_items_paginated(api, context):
    page = context.get("page", 1)
    resp = api.get("/items", params={"page": page, "limit": 10})
    context.set("page", page + 1)
    return resp

def next_page(api, context):
    page = context.get("page", 1)
    return api.get("/items", params={"page": page, "limit": 10})

Auth Flow Testing

Login/Logout Pattern

Python
world = World(
    api=api,
    state_from_context=["user_id", "logged_in"],
)

def register(api, context):
    email = f"test_{uuid.uuid4().hex[:8]}@example.com"
    resp = api.post("/auth/register", json={
        "email": email,
        "password": "password123",
    })
    if resp.ok:
        context.set("user_email", email)
    return resp

def login(api, context):
    email = context.get("user_email")
    if not email:
        return None
    resp = api.post("/auth/login", json={
        "email": email,
        "password": "password123",
    })
    if resp.ok:
        token = resp.json()["token"]
        context.set("auth_token", token)
        context.set("logged_in", True)
        api.set_header("Authorization", f"Bearer {token}")
    return resp

def logout(api, context):
    if not context.get("logged_in"):
        return None
    resp = api.post("/auth/logout")
    if resp.ok:
        context.delete("auth_token")
        context.set("logged_in", False)
        api.remove_header("Authorization")
    return resp

def get_profile(api, context):
    if not context.get("logged_in"):
        return None
    return api.get("/auth/profile")

def delete_account(api, context):
    if not context.get("logged_in"):
        return None
    resp = api.delete("/auth/account")
    if resp.ok:
        context.delete("user_email")
        context.delete("auth_token")
        context.set("logged_in", False)
    return resp

actions = [
    Action(name="register", execute=register),
    Action(name="login", execute=login, preconditions=["register"]),
    Action(name="logout", execute=logout, preconditions=["login"]),
    Action(name="get_profile", execute=get_profile, preconditions=["login"]),
    Action(name="delete_account", execute=delete_account, preconditions=["login"]),
]

# Invariant: can't access protected routes after logout
def auth_enforcement(world):
    if not world.context.get("logged_in"):
        return True  # Not logged in, nothing to check

    # Try accessing protected route
    resp = world.api.get("/auth/profile")
    if resp.status_code == 401:
        # Good: properly rejected
        return True
    return resp.ok  # Should succeed if logged in

invariants = [
    Invariant("auth_enforcement", auth_enforcement, Severity.CRITICAL),
]

Token Refresh Pattern

Python
def refresh_token(api, context):
    token = context.get("auth_token")
    if not token:
        return None
    resp = api.post("/auth/refresh")
    if resp.ok:
        context.set("auth_token", resp.json()["token"])
    return resp

def use_expired_token(api, context):
    """Test that expired tokens are rejected."""
    old_token = context.get("auth_token")
    if not old_token:
        return None

    # Simulate expired token
    api.set_header("Authorization", "Bearer expired_token_xyz")
    resp = api.get("/auth/profile")
    api.set_header("Authorization", f"Bearer {old_token}")

    return resp

# Invariant: expired tokens should return 401
def expired_token_rejected(world):
    last_status = world.context.get("last_status", 200)
    if world.context.get("testing_expired"):
        return last_status == 401
    return True

Payment Flow Testing

Order → Payment → Refund

Python
world = World(
    api=api,
    state_from_context=["order_id", "payment_id", "order_status"],
)

# Order creation
def create_order(api, context):
    resp = api.post("/orders", json={
        "amount": 10000,  # $100.00 in cents
        "currency": "USD",
    })
    if resp.ok:
        context.set("order_id", resp.json()["id"])
        context.set("order_amount", 10000)
        context.set("order_status", "pending")
    return resp

# Payment
def pay_order(api, context):
    order_id = context.get("order_id")
    if not order_id or context.get("order_status") != "pending":
        return None

    resp = api.post(f"/orders/{order_id}/pay", json={
        "payment_method": "card",
        "card_token": "test_token",
    })
    if resp.ok:
        context.set("payment_id", resp.json()["payment_id"])
        context.set("order_status", "paid")
        context.set("amount_paid", resp.json().get("amount", 10000))
    return resp

# Refund
def refund_order(api, context):
    order_id = context.get("order_id")
    payment_id = context.get("payment_id")
    if not order_id or not payment_id:
        return None

    amount = context.get("refund_amount", 10000)
    resp = api.post(f"/orders/{order_id}/refund", json={
        "payment_id": payment_id,
        "amount": amount,
    })
    if resp.ok:
        refunded = context.get("total_refunded", 0) + amount
        context.set("total_refunded", refunded)
    return resp

# Partial refund
def partial_refund(api, context):
    context.set("refund_amount", 5000)  # $50.00
    return refund_order(api, context)

# Full refund
def full_refund(api, context):
    context.set("refund_amount", 10000)  # $100.00
    return refund_order(api, context)

# Cancel
def cancel_order(api, context):
    order_id = context.get("order_id")
    if not order_id:
        return None

    resp = api.post(f"/orders/{order_id}/cancel")
    if resp.ok:
        context.set("order_status", "canceled")
    return resp

actions = [
    Action(name="create_order", execute=create_order),
    Action(name="pay_order", execute=pay_order, preconditions=["create_order"]),
    Action(name="partial_refund", execute=partial_refund, preconditions=["pay_order"]),
    Action(name="full_refund", execute=full_refund, preconditions=["pay_order"]),
    Action(name="cancel_order", execute=cancel_order, preconditions=["create_order"]),
]

# Invariants
def no_over_refund(world):
    """Total refunded should not exceed order amount."""
    total_refunded = world.context.get("total_refunded", 0)
    order_amount = world.context.get("order_amount", 0)
    return total_refunded <= order_amount

def no_refund_unpaid(world):
    """Can't refund an unpaid order."""
    order_status = world.context.get("order_status")
    if order_status == "pending":
        last_status = world.context.get("last_status", 200)
        # Refund attempt should fail with 400 or 403
        return last_status >= 400
    return True

def cancel_prevents_refund(world):
    """Can't refund a canceled order."""
    order_status = world.context.get("order_status")
    if order_status == "canceled":
        last_status = world.context.get("last_status", 200)
        # Any refund attempt should fail
        if world.context.get("refund_attempted"):
            return last_status >= 400
    return True

invariants = [
    Invariant("no_over_refund", no_over_refund, Severity.CRITICAL),
    Invariant("no_refund_unpaid", no_refund_unpaid, Severity.HIGH),
    Invariant("cancel_prevents_refund", cancel_prevents_refund, Severity.HIGH),
]

Multi-Tenant Testing

Tenant Isolation

Python
world = World(
    api=api,
    state_from_context=["tenant_a_id", "tenant_b_id", "resource_id"],
)

# Setup tenants
def setup_tenant_a(api, context):
    resp = api.post("/tenants", json={"name": "Tenant A"})
    if resp.ok:
        context.set("tenant_a_id", resp.json()["id"])
        context.set("tenant_a_token", resp.json()["api_key"])
    return resp

def setup_tenant_b(api, context):
    resp = api.post("/tenants", json={"name": "Tenant B"})
    if resp.ok:
        context.set("tenant_b_id", resp.json()["id"])
        context.set("tenant_b_token", resp.json()["api_key"])
    return resp

def create_resource_as_a(api, context):
    token = context.get("tenant_a_token")
    if not token:
        return None

    resp = api.post(
        "/resources",
        json={"name": "A's resource"},
        headers={"X-API-Key": token},
    )
    if resp.ok:
        context.set("resource_id", resp.json()["id"])
        context.set("resource_owner", "A")
    return resp

def try_access_as_b(api, context):
    """Tenant B tries to access Tenant A's resource."""
    resource_id = context.get("resource_id")
    token_b = context.get("tenant_b_token")

    if not resource_id or not token_b:
        return None

    context.set("cross_tenant_attempt", True)
    resp = api.get(
        f"/resources/{resource_id}",
        headers={"X-API-Key": token_b},
    )
    context.set("cross_tenant_status", resp.status_code)
    return resp

actions = [
    Action(name="setup_tenant_a", execute=setup_tenant_a),
    Action(name="setup_tenant_b", execute=setup_tenant_b, preconditions=["setup_tenant_a"]),
    Action(name="create_resource_as_a", execute=create_resource_as_a, preconditions=["setup_tenant_a"]),
    Action(name="try_access_as_b", execute=try_access_as_b, preconditions=["create_resource_as_a", "setup_tenant_b"]),
]

# Invariant: tenants can't access each other's resources
def tenant_isolation(world):
    if world.context.get("cross_tenant_attempt"):
        status = world.context.get("cross_tenant_status", 200)
        # Should be 403 or 404
        return status in [403, 404]
    return True

invariants = [
    Invariant("tenant_isolation", tenant_isolation, Severity.CRITICAL),
]

State Machine Testing

Order State Machine

Text Only
pending → paid → fulfilled
    ↓        ↓
canceled  refunded
Python
world = World(api=api, state_from_context=["order_id", "status"])

# Define valid transitions
VALID_TRANSITIONS = {
    None: ["create"],
    "pending": ["pay", "cancel"],
    "paid": ["fulfill", "refund"],
    "fulfilled": [],  # Terminal
    "canceled": [],   # Terminal
    "refunded": [],   # Terminal
}

def create_order(api, context):
    if context.get("order_id"):
        return None  # Already have an order
    resp = api.post("/orders", json={"amount": 100})
    if resp.ok:
        context.set("order_id", resp.json()["id"])
        context.set("status", "pending")
    return resp

def pay_order(api, context):
    if context.get("status") != "pending":
        return None
    resp = api.post(f"/orders/{context.get('order_id')}/pay")
    if resp.ok:
        context.set("status", "paid")
    return resp

def fulfill_order(api, context):
    if context.get("status") != "paid":
        return None
    resp = api.post(f"/orders/{context.get('order_id')}/fulfill")
    if resp.ok:
        context.set("status", "fulfilled")
    return resp

def refund_order(api, context):
    if context.get("status") != "paid":
        return None
    resp = api.post(f"/orders/{context.get('order_id')}/refund")
    if resp.ok:
        context.set("status", "refunded")
    return resp

def cancel_order(api, context):
    if context.get("status") != "pending":
        return None
    resp = api.post(f"/orders/{context.get('order_id')}/cancel")
    if resp.ok:
        context.set("status", "canceled")
    return resp

# Invariant: only valid transitions allowed
def state_transition_validity(world):
    """All transitions must follow the state machine."""
    last_status = world.context.get("last_status", 200)
    if last_status >= 400:
        # Request failed - acceptable if it was an invalid transition
        return True

    # If request succeeded, transition was valid
    return True

invariants = [
    Invariant("state_transition_validity", state_transition_validity, Severity.HIGH),
]

Rate Limiting

Python
import time

class RateLimitedAction:
    """Action wrapper that respects rate limits."""

    def __init__(self, action, min_interval_ms: int = 100):
        self.action = action
        self.min_interval_ms = min_interval_ms
        self.last_call = 0

    def __call__(self, api, context):
        now = time.time() * 1000
        elapsed = now - self.last_call
        if elapsed < self.min_interval_ms:
            time.sleep((self.min_interval_ms - elapsed) / 1000)

        self.last_call = time.time() * 1000
        return self.action(api, context)

# Wrap actions
actions = [
    Action("create", RateLimitedAction(create_fn, min_interval_ms=200)),
    Action("update", RateLimitedAction(update_fn, min_interval_ms=200)),
]

Best Practices Summary

Pattern Key Insight
CRUD Use preconditions for Read/Update/Delete
Auth Track logged_in state in context
Payment Track cumulative values (total_refunded)
Multi-tenant Test cross-tenant access explicitly
State machine Define valid transitions upfront
Rate limiting Wrap actions or use sleep

Common Invariants

Python
# No server errors
Invariant("no_500s", lambda w: w.context.get("last_status", 200) < 500, Severity.CRITICAL)

# Idempotency
Invariant("create_idempotent", lambda w: count_items() <= expected, Severity.HIGH)

# Data consistency
Invariant("api_db_sync", lambda w: api_count() == db_count(), Severity.HIGH)

# Business rules
Invariant("positive_balance", lambda w: get_balance() >= 0, Severity.CRITICAL)

# Security
Invariant("no_unauthorized_access", lambda w: not (was_unauthorized and succeeded), Severity.CRITICAL)