Skip to content

Customizing Stateful Testing

This guide shows how to customize Schemathesis's stateful testing to handle non-standard authentication scenarios, inject realistic test data, and adapt requests for your environment.

Why Customize Stateful Testing?

  • Data initialization: Start scenarios with realistic data instead of random generation
  • Authentication: Add login flows and token management to test sequences
  • Request customization: Inject environment-specific headers and parameters
  • Cleanup: Prevent test pollution by properly resetting state between scenarios

Components of Stateful Testing

import schemathesis

schema = schemathesis.openapi.from_url("http://localhost:8000/openapi.json")

APIWorkflow = schema.as_state_machine()
TestAPI = APIWorkflow.TestCase

Stateful testing consists of three components:

  • Schema - Defines available operations and their links
  • State machine (APIWorkflow) - Controls test scenario behavior and request customization
  • Test class (TestAPI) - Integrates with pytest/unittest for fixtures and test execution

The state machine automatically sequences API operations based on OpenAPI links.

How is it implemented?

Schemathesis implements stateful testing on top of Hypothesis's rule-based state machines

Basic Customization Pattern

Extend the state machine class to adjust its behavior:

import schemathesis

schema = schemathesis.openapi.from_url("http://localhost:8000/openapi.json")

class APIWorkflow(schema.as_state_machine()):
    def setup(self):
        """Run once at the start of each test scenario."""

    def teardown(self):
        """Run once at the end of each test scenario."""

    def before_call(self, case):
        """Modify every request in the sequence."""

    def after_call(self, response, case):
        """Process every response."""

TestAPI = APIWorkflow.TestCase

The state machine automatically handles operation sequencing based on OpenAPI links. You customize how requests are made and responses are processed.

Reference Documentation

See the APIStateMachine reference for all available customization methods and their parameters.

Per-Run Setup with pytest Fixtures

For expensive setup that should happen once per test execution (database creation, external services), extend the test class:

class TestAPI(APIWorkflow.TestCase):
    def setUp(self):
        """Create database, start services - runs once per test execution."""

    def tearDown(self):
        """Cleanup resources - runs once per test execution."""

Or use pytest fixtures:

import pytest

@pytest.fixture(scope="session")
def database():
    # create database
    yield 
    # drop database

@pytest.mark.usefixtures("database")
class TestAPI(APIWorkflow.TestCase):
    pass

Key difference

State machine methods (setup/teardown) run for each generated scenario. TestCase methods (setUp/tearDown) run once for the entire test, regardless of how many scenarios Hypothesis generates.

Schema Loading with Fixtures

When your application requires fixtures to initialize (database connections, app configuration), load the schema inside a pytest fixture:

import pytest
import schemathesis

@pytest.fixture
def api_schema(database, app_config):
    # Schema loading requires initialized app
    return schemathesis.openapi.from_url("http://localhost:8000/openapi.json")

@pytest.fixture  
def state_machine(api_schema):
    return api_schema.as_state_machine()

def test_statefully(state_machine):
    state_machine.run()

You can also extend the state machine inside the fixture:

@pytest.fixture
def state_machine(api_schema, auth_service):
    class APIWorkflow(api_schema.as_state_machine()):
        def setup(self):
            # Use fixture dependencies
            self.token = auth_service.get_test_token()

        def before_call(self, case):
            case.headers["Authorization"] = f"Bearer {self.token}"

    return APIWorkflow

Hypothesis Configuration

Configure how many test scenarios run and how many steps each scenario contains:

from hypothesis import settings

# Set on TestCase class
TestCase = schema.as_state_machine().TestCase
TestCase.settings = settings(max_examples=200, stateful_step_count=10)

For fixture-based schema loading, pass settings to the run() method:

def test_statefully(state_machine):
    state_machine.run(
        settings=settings(
            max_examples=200,
            stateful_step_count=10,
        )
    )
  • max_examples=200 - Run 200 test scenarios (default: 100)
  • stateful_step_count=10 - Maximum 10 API calls per scenario (default: 6)

Common Customization Examples

Data Initialization

Create realistic test data at the start of each scenario:

class APIWorkflow(schema.as_state_machine()):
    def setup(self):
        # Create a test user for this scenario
        case = schema["/users"]["POST"].Case(body={
            "username": "test_user",
            "email": "test@example.com"
        })
        response = case.call()
        self.user_id = response.json()["id"]

    def before_call(self, case):
        # Use the created user in operations that need user_id
        if "user_id" in case.path_parameters:
            case.path_parameters["user_id"] = self.user_id

Authentication Flow

Handle login and token management for protected endpoints:

import requests

class APIWorkflow(schema.as_state_machine()):
    def setup(self):
        # Login and get auth token
        response = requests.post("http://localhost:8000/auth/login", json={
            "username": "test_user",
            "password": "test_password"
        })
        token = response.json()["access_token"]
        self.auth_headers = {"Authorization": f"Bearer {token}"}

    def before_call(self, case):
        # Add auth to every request
        case.headers = {**case.headers, **self.auth_headers}