Skip to main content

Integration testing

Integration tests exercise the full request-to-app run path of your connector with locally running infrastructure. Unlike unit tests, which use mock infrastructure and run without any external services, integration tests start a real app server and make actual HTTP requests through the handler API, validating responses with a declarative assertion DSL. These tests typically run in CI; see Run locally if you want to run them on your machine.

How integration tests work

Integration tests are built on BaseIntegrationTest, a pytest-based class that drives HTTP calls to a running app server. You define scenarios: lightweight data objects that specify which API to call, what inputs to send, and what the response must contain. The framework runs each scenario as a separate pytest test.

Project structure

Organize integration tests to mirror your source code layout—when every source module has a predictable test counterpart, finding and adding tests is straightforward:

tests/integration/
├── __init__.py
├── conftest.py # shared fixtures
├── _example/ # reference implementation (copy this)
│ ├── __init__.py
│ ├── scenarios.py
│ └── test_integration.py
└── my_connector/
├── __init__.py
├── scenarios.py
└── test_integration.py

Start from the reference implementation:

cp -r tests/integration/_example tests/integration/my_connector

Define scenarios

Edit scenarios.py to describe the test cases for your connector. Each Scenario object declares one API call, the inputs it receives, and the assertions that must pass on the response.

Use names that describe what's being tested and the expected outcome—these become the pytest test identifiers:

# Clear: what is being tested and why it should fail
"auth_wrong_password"
"preflight_nonexistent_database"
"workflow_empty_schema_filter"

# Unclear: gives no information about the scenario
"test_1"
"scenario_b"
"negative_case"

For non-obvious scenarios, add a description field to document the condition being tested:

Scenario(
name="preflight_read_only_user",
description=(
"User has SELECT on all tables but no CREATE permission. "
"Preflight should pass with a warning, not fail."
),
api="preflight",
args=lazy(lambda: {...}),
assert_that={
"success": equals(True),
"data.status": equals("ready"),
},
)

A complete scenarios.py example:

# tests/integration/my_connector/scenarios.py
import os

from application_sdk.testing.integration import (
Scenario,
lazy,
equals,
exists,
is_true,
one_of,
)


def load_credentials():
return {
"host": os.getenv("MY_DB_HOST"),
"username": os.getenv("MY_DB_USER"),
"password": os.getenv("MY_DB_PASSWORD"),
}


scenarios = [
# Authentication — valid credentials
Scenario(
name="auth_valid",
api="auth",
args=lazy(lambda: {"credentials": load_credentials()}),
assert_that={
"success": equals(True),
"data.status": equals("success"),
},
),

# Authentication — wrong password
Scenario(
name="auth_invalid_password",
api="auth",
args=lazy(lambda: {
"credentials": {**load_credentials(), "password": "wrong"}
}),
assert_that={"success": equals(False)},
),

# Preflight — valid configuration
Scenario(
name="preflight_valid",
api="preflight",
args=lazy(lambda: {
"credentials": load_credentials(),
"metadata": {"databases": ["MY_DB"]},
}),
assert_that={
"success": equals(True),
"data.status": equals("ready"),
"data.checks.0.passed": equals(True),
},
),

# Workflow — start extraction
Scenario(
name="workflow_extraction",
api="workflow",
args=lazy(lambda: {
"credentials": load_credentials(),
"metadata": {"databases": ["MY_DB"]},
"connection": {"name": "test_conn"},
}),
assert_that={
"success": equals(True),
"data.workflow_id": exists(),
"data.run_id": exists(),
},
),
]

Supported APIs

ValueEndpointPurpose
"auth"/workflows/v1/authTest authentication
"preflight"/workflows/v1/checkValidate configuration
"metadata"/workflows/v1/metadataFetch metadata list
"workflow"/workflows/v1/startStart a workflow
"config"/workflows/v1/config/{id}Get or update a credential config

Create test class

Edit test_integration.py to declare a test class that inherits from BaseIntegrationTest and references your scenarios:

# tests/integration/my_connector/test_integration.py
from application_sdk.testing.integration import BaseIntegrationTest
from .scenarios import scenarios


class MyConnectorTest(BaseIntegrationTest):
scenarios = scenarios
server_host = "http://localhost:8000"

BaseIntegrationTest generates one pytest test per scenario. Each test calls the specified API, validates all assertions, and reports a clear failure message if any assertion doesn't pass.

Set environment variables

Never hardcode credentials in scenario files or commit real credentials to version control. Reference environment variables and load them at test-run time:

def load_credentials():
return {
"host": os.getenv("MY_DB_HOST"),
"username": os.getenv("MY_DB_USER"),
"password": os.getenv("MY_DB_PASSWORD"),
}

Set the variables before running tests:

export MY_DB_HOST=localhost
export MY_DB_USER=test_user
export MY_DB_PASSWORD=YOUR_PASSWORD_HERE

Use a .env file for local development—never commit real credentials:

# .env — excluded from version control
MY_DB_HOST=localhost
MY_DB_USER=test_user
MY_DB_PASSWORD=YOUR_PASSWORD_HERE

Commit a .env.example with placeholder values as documentation:

# .env.example — committed as documentation
MY_DB_HOST=your-host-here
MY_DB_USER=your-username-here
MY_DB_PASSWORD=your-password-here

In CI, populate these from repository secrets.

Run tests

# All integration tests
pytest tests/integration/ -v

# One connector
pytest tests/integration/my_connector/ -v

# One scenario by name
pytest tests/integration/my_connector/ -v -k "auth_valid"

# With debug logging to see full API responses
pytest tests/integration/my_connector/ -v --log-cli-level=DEBUG

Lazy evaluation for credentials

Wrap credential loading in lazy() so the lambda is evaluated at test-run time, not at import time. This prevents failures when environment variables aren't set in the environment where scenarios are imported:

# Wrong: evaluated at import time — fails if env vars are missing
args={"credentials": load_credentials()}

# Correct: evaluated when the test runs
args=lazy(lambda: {"credentials": load_credentials()})

lazy() caches the result after the first evaluation, so credentials are loaded once per test run.

Assertion DSL

The assertion DSL provides composable predicate functions for validating nested response fields using dot-notation paths.

Basic assertions

from application_sdk.testing.integration import (
equals, not_equals, exists, is_none, is_true, is_false
)

assert_that = {
"success": equals(True),
"data.status": equals("success"),
"data.error": is_none(),
"data.workflow_id": exists(),
}

Collection assertions

from application_sdk.testing.integration import (
one_of, not_one_of, contains, not_contains,
has_length, is_empty, is_not_empty
)

assert_that = {
"data.status": one_of(["RUNNING", "COMPLETED"]),
"message": contains("successful"),
"data.checks": is_not_empty(),
}

Numeric assertions

from application_sdk.testing.integration import (
greater_than, greater_than_or_equal, less_than, between
)

assert_that = {
"data.count": greater_than(0),
"data.duration_ms": between(1, 30000),
}

Type assertions

from application_sdk.testing.integration import is_dict, is_list, is_string, is_type

assert_that = {
"data": is_dict(),
"data.checks": is_list(),
"data.workflow_id": is_string(),
}

Combinators

from application_sdk.testing.integration import all_of, any_of, none_of

assert_that = {
"data.name": all_of(exists(), is_string(), is_not_empty()),
"data.role": any_of(equals("admin"), equals("superuser")),
"message": none_of(contains("error"), contains("fail")),
}

Custom assertions

from application_sdk.testing.integration import custom

assert_that = {
"data.count": custom(lambda x: x % 2 == 0, "must_be_even"),
"data.value": lambda x: 0 < x < 100,
}

Advanced test class configuration

Custom app run endpoint

class MyConnectorTest(BaseIntegrationTest):
scenarios = scenarios
server_host = "http://localhost:8000"
workflow_endpoint = "/extract" # overrides the default /start
timeout = 60 # seconds; default is 30

Override the endpoint per-scenario:

Scenario(
name="mine_queries",
api="workflow",
endpoint="/mine",
args={...},
assert_that={...},
)

Setup and teardown hooks

Use hooks to create and clean up test data around the entire test run:

class MyConnectorTest(BaseIntegrationTest):
scenarios = scenarios

@classmethod
def setup_test_environment(cls):
"""Called once before any scenario runs."""
cls.db = create_test_database_connection()
cls.db.execute("CREATE SCHEMA IF NOT EXISTS test_schema")

@classmethod
def cleanup_test_environment(cls):
"""Called once after all scenarios finish."""
cls.db.execute("DROP SCHEMA test_schema CASCADE")
cls.db.close()

def before_scenario(self, scenario):
"""Called before each individual scenario."""
print(f"Starting: {scenario.name}")

def after_scenario(self, scenario, result):
"""Called after each individual scenario."""
status = "PASSED" if result.success else "FAILED"
print(f"{scenario.name}: {status}")

Skip slow scenarios

Mark individual scenarios to skip in routine runs:

Scenario(
name="workflow_full_extraction",
api="workflow",
args={...},
assert_that={...},
skip=True,
skip_reason="Full extraction takes >10 minutes; run manually only",
)

Troubleshooting

"Server not available": confirm the app server is running and accessible:

curl http://localhost:8000/server/health

"Credentials not loading": check environment variables are exported in the current shell:

env | grep MY_DB_

"Assertion failed": run with debug logging to see the raw API response:

pytest -v --log-cli-level=DEBUG -k "failing_scenario_name"

"Timeout": increase the class-level timeout:

class MyConnectorTest(BaseIntegrationTest):
timeout = 120

Run locally

Integration tests require Dapr and Temporal to be installed and running, plus the app server. In CI these are provided by the workflow; locally you need to set them up yourself.

Install the CLIs (one-time):

# Dapr CLI
curl -fsSL https://raw.githubusercontent.com/dapr/cli/master/install/install.sh | /bin/bash
dapr init --slim

# Temporal CLI
curl -sSf https://temporal.download/cli.sh | sh

Start services and the app server, each in its own terminal:

# Terminal 1 — Dapr + Temporal
uv run poe start-deps

# Terminal 2 — app server
uv run python main.py

Run tests in a third terminal:

pytest tests/integration/ -v

See also

  • Unit testing: mock-based tests for task logic and handlers, no external services required
  • Test your apps: overview of the three testing tiers