From 70ec2fdb919fbe344266317eb8227d5b68353a05 Mon Sep 17 00:00:00 2001 From: Sergio Herrera Date: Wed, 15 Apr 2026 15:22:48 +0200 Subject: [PATCH 01/15] Move old integration tests to examples/ Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- AGENTS.md | 8 ++++---- README.md | 2 +- examples/AGENTS.md | 4 ++-- examples/conversation/real_llm_providers_example.py | 2 +- tests/clients/test_conversation_helpers.py | 2 +- tests/{integration => examples}/conftest.py | 0 tests/{integration => examples}/test_configuration.py | 0 tests/{integration => examples}/test_conversation.py | 0 tests/{integration => examples}/test_crypto.py | 0 tests/{integration => examples}/test_demo_actor.py | 0 tests/{integration => examples}/test_distributed_lock.py | 0 tests/{integration => examples}/test_error_handling.py | 0 tests/{integration => examples}/test_grpc_proxying.py | 0 tests/{integration => examples}/test_invoke_binding.py | 0 .../{integration => examples}/test_invoke_custom_data.py | 0 tests/{integration => examples}/test_invoke_http.py | 0 tests/{integration => examples}/test_invoke_simple.py | 0 tests/{integration => examples}/test_jobs.py | 0 .../test_langgraph_checkpointer.py | 0 tests/{integration => examples}/test_metadata.py | 0 tests/{integration => examples}/test_pubsub_simple.py | 0 tests/{integration => examples}/test_pubsub_streaming.py | 0 .../test_pubsub_streaming_async.py | 0 tests/{integration => examples}/test_secret_store.py | 0 tests/{integration => examples}/test_state_store.py | 0 tests/{integration => examples}/test_state_store_query.py | 0 tests/{integration => examples}/test_w3c_tracing.py | 0 tests/{integration => examples}/test_workflow.py | 0 tox.ini | 6 +++--- 29 files changed, 12 insertions(+), 12 deletions(-) rename tests/{integration => examples}/conftest.py (100%) rename tests/{integration => examples}/test_configuration.py (100%) rename tests/{integration => examples}/test_conversation.py (100%) rename tests/{integration => examples}/test_crypto.py (100%) rename tests/{integration => examples}/test_demo_actor.py (100%) rename tests/{integration => examples}/test_distributed_lock.py (100%) rename tests/{integration => examples}/test_error_handling.py (100%) rename tests/{integration => examples}/test_grpc_proxying.py (100%) rename tests/{integration => examples}/test_invoke_binding.py (100%) rename tests/{integration => examples}/test_invoke_custom_data.py (100%) rename tests/{integration => examples}/test_invoke_http.py (100%) rename tests/{integration => examples}/test_invoke_simple.py (100%) rename tests/{integration => examples}/test_jobs.py (100%) rename tests/{integration => examples}/test_langgraph_checkpointer.py (100%) rename tests/{integration => examples}/test_metadata.py (100%) rename tests/{integration => examples}/test_pubsub_simple.py (100%) rename tests/{integration => examples}/test_pubsub_streaming.py (100%) rename tests/{integration => examples}/test_pubsub_streaming_async.py (100%) rename tests/{integration => examples}/test_secret_store.py (100%) rename tests/{integration => examples}/test_state_store.py (100%) rename tests/{integration => examples}/test_state_store_query.py (100%) rename tests/{integration => examples}/test_w3c_tracing.py (100%) rename tests/{integration => examples}/test_workflow.py (100%) diff --git a/AGENTS.md b/AGENTS.md index 27eed72a5..98550e585 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,8 +67,8 @@ The `examples/` directory serves as both user-facing documentation and the proje Quick reference: ```bash -tox -e integration # Run all examples (needs Dapr runtime) -tox -e integration -- test_state_store.py # Run a single example +tox -e examples # Run all examples (needs Dapr runtime) +tox -e examples -- test_state_store.py # Run a single example ``` ## Python version support @@ -106,8 +106,8 @@ tox -e ruff # Run type checking tox -e type -# Run integration tests / validate examples (requires Dapr runtime) -tox -e integration +# Run examples tests / validate examples (requires Dapr runtime) +tox -e examples ``` To run tests directly without tox: diff --git a/README.md b/README.md index 333be6933..f6b203412 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ tox -e type 8. Run integration tests (validates the examples) ```bash -tox -e integration +tox -e examples ``` If you need to run the examples against a pre-released version of the runtime, you can use the following command: diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 5dcbdd4ec..4b61d3002 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -13,10 +13,10 @@ Run examples locally (requires a running Dapr runtime via `dapr init`): ```bash # All examples -tox -e integration +tox -e examples # Single example -tox -e integration -- test_state_store.py +tox -e examples -- test_state_store.py ``` In CI (`validate_examples.yaml`), examples run on all supported Python versions (3.10-3.14) on Ubuntu with a full Dapr runtime including Docker, Redis, and (for LLM examples) Ollama. diff --git a/examples/conversation/real_llm_providers_example.py b/examples/conversation/real_llm_providers_example.py index 2347f4b50..e37cec745 100644 --- a/examples/conversation/real_llm_providers_example.py +++ b/examples/conversation/real_llm_providers_example.py @@ -1237,7 +1237,7 @@ def main(): print(f'\n{"=" * 60}') print('🎉 All Alpha2 tests completed!') - print('✅ Real LLM provider integration with Alpha2 API is working correctly') + print('✅ Real LLM provider examples with Alpha2 API is working correctly') print('🔧 Features demonstrated:') print(' • Alpha2 conversation API with sophisticated message types') print(' • Automatic parameter conversion (raw Python values)') diff --git a/tests/clients/test_conversation_helpers.py b/tests/clients/test_conversation_helpers.py index e7c69b30e..c9d86db25 100644 --- a/tests/clients/test_conversation_helpers.py +++ b/tests/clients/test_conversation_helpers.py @@ -1511,7 +1511,7 @@ def google_function(data: str): class TestIntegrationScenarios(unittest.TestCase): - """Test real-world integration scenarios.""" + """Test real-world examples scenarios.""" def test_restaurant_finder_scenario(self): """Test the restaurant finder example from the documentation.""" diff --git a/tests/integration/conftest.py b/tests/examples/conftest.py similarity index 100% rename from tests/integration/conftest.py rename to tests/examples/conftest.py diff --git a/tests/integration/test_configuration.py b/tests/examples/test_configuration.py similarity index 100% rename from tests/integration/test_configuration.py rename to tests/examples/test_configuration.py diff --git a/tests/integration/test_conversation.py b/tests/examples/test_conversation.py similarity index 100% rename from tests/integration/test_conversation.py rename to tests/examples/test_conversation.py diff --git a/tests/integration/test_crypto.py b/tests/examples/test_crypto.py similarity index 100% rename from tests/integration/test_crypto.py rename to tests/examples/test_crypto.py diff --git a/tests/integration/test_demo_actor.py b/tests/examples/test_demo_actor.py similarity index 100% rename from tests/integration/test_demo_actor.py rename to tests/examples/test_demo_actor.py diff --git a/tests/integration/test_distributed_lock.py b/tests/examples/test_distributed_lock.py similarity index 100% rename from tests/integration/test_distributed_lock.py rename to tests/examples/test_distributed_lock.py diff --git a/tests/integration/test_error_handling.py b/tests/examples/test_error_handling.py similarity index 100% rename from tests/integration/test_error_handling.py rename to tests/examples/test_error_handling.py diff --git a/tests/integration/test_grpc_proxying.py b/tests/examples/test_grpc_proxying.py similarity index 100% rename from tests/integration/test_grpc_proxying.py rename to tests/examples/test_grpc_proxying.py diff --git a/tests/integration/test_invoke_binding.py b/tests/examples/test_invoke_binding.py similarity index 100% rename from tests/integration/test_invoke_binding.py rename to tests/examples/test_invoke_binding.py diff --git a/tests/integration/test_invoke_custom_data.py b/tests/examples/test_invoke_custom_data.py similarity index 100% rename from tests/integration/test_invoke_custom_data.py rename to tests/examples/test_invoke_custom_data.py diff --git a/tests/integration/test_invoke_http.py b/tests/examples/test_invoke_http.py similarity index 100% rename from tests/integration/test_invoke_http.py rename to tests/examples/test_invoke_http.py diff --git a/tests/integration/test_invoke_simple.py b/tests/examples/test_invoke_simple.py similarity index 100% rename from tests/integration/test_invoke_simple.py rename to tests/examples/test_invoke_simple.py diff --git a/tests/integration/test_jobs.py b/tests/examples/test_jobs.py similarity index 100% rename from tests/integration/test_jobs.py rename to tests/examples/test_jobs.py diff --git a/tests/integration/test_langgraph_checkpointer.py b/tests/examples/test_langgraph_checkpointer.py similarity index 100% rename from tests/integration/test_langgraph_checkpointer.py rename to tests/examples/test_langgraph_checkpointer.py diff --git a/tests/integration/test_metadata.py b/tests/examples/test_metadata.py similarity index 100% rename from tests/integration/test_metadata.py rename to tests/examples/test_metadata.py diff --git a/tests/integration/test_pubsub_simple.py b/tests/examples/test_pubsub_simple.py similarity index 100% rename from tests/integration/test_pubsub_simple.py rename to tests/examples/test_pubsub_simple.py diff --git a/tests/integration/test_pubsub_streaming.py b/tests/examples/test_pubsub_streaming.py similarity index 100% rename from tests/integration/test_pubsub_streaming.py rename to tests/examples/test_pubsub_streaming.py diff --git a/tests/integration/test_pubsub_streaming_async.py b/tests/examples/test_pubsub_streaming_async.py similarity index 100% rename from tests/integration/test_pubsub_streaming_async.py rename to tests/examples/test_pubsub_streaming_async.py diff --git a/tests/integration/test_secret_store.py b/tests/examples/test_secret_store.py similarity index 100% rename from tests/integration/test_secret_store.py rename to tests/examples/test_secret_store.py diff --git a/tests/integration/test_state_store.py b/tests/examples/test_state_store.py similarity index 100% rename from tests/integration/test_state_store.py rename to tests/examples/test_state_store.py diff --git a/tests/integration/test_state_store_query.py b/tests/examples/test_state_store_query.py similarity index 100% rename from tests/integration/test_state_store_query.py rename to tests/examples/test_state_store_query.py diff --git a/tests/integration/test_w3c_tracing.py b/tests/examples/test_w3c_tracing.py similarity index 100% rename from tests/integration/test_w3c_tracing.py rename to tests/examples/test_w3c_tracing.py diff --git a/tests/integration/test_workflow.py b/tests/examples/test_workflow.py similarity index 100% rename from tests/integration/test_workflow.py rename to tests/examples/test_workflow.py diff --git a/tox.ini b/tox.ini index de0b30a2d..711206c1b 100644 --- a/tox.ini +++ b/tox.ini @@ -39,9 +39,9 @@ commands = ruff format [testenv:integration] -; Pytest-based integration tests that validate the examples/ directory. -; Usage: tox -e integration # run all -; tox -e integration -- test_state_store.py # run one +; Pytest-based examples tests that validate the examples/ directory. +; Usage: tox -e examples # run all +; tox -e examples -- test_state_store.py # run one passenv = HOME basepython = python3 changedir = ./tests/integration/ From 167442deebbb5719c2ab5ebe35a03cf2bc8a33e0 Mon Sep 17 00:00:00 2001 From: Sergio Herrera Date: Wed, 15 Apr 2026 15:36:44 +0200 Subject: [PATCH 02/15] Test DaprClient directly Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- .github/workflows/validate_examples.yaml | 3 + tests/integration/apps/invoke_receiver.py | 13 ++ tests/integration/apps/pubsub_subscriber.py | 25 ++++ .../components/configurationstore.yaml | 11 ++ .../components/localsecretstore.yaml | 13 ++ tests/integration/components/lockstore.yaml | 11 ++ tests/integration/components/pubsub.yaml | 12 ++ tests/integration/components/statestore.yaml | 12 ++ tests/integration/conftest.py | 133 ++++++++++++++++++ tests/integration/secrets.json | 4 + tests/integration/test_configuration.py | 91 ++++++++++++ tests/integration/test_distributed_lock.py | 66 +++++++++ tests/integration/test_invoke.py | 34 +++++ tests/integration/test_metadata.py | 42 ++++++ tests/integration/test_pubsub.py | 49 +++++++ tests/integration/test_secret_store.py | 19 +++ tests/integration/test_state_store.py | 102 ++++++++++++++ tox.ini | 29 +++- 18 files changed, 666 insertions(+), 3 deletions(-) create mode 100644 tests/integration/apps/invoke_receiver.py create mode 100644 tests/integration/apps/pubsub_subscriber.py create mode 100644 tests/integration/components/configurationstore.yaml create mode 100644 tests/integration/components/localsecretstore.yaml create mode 100644 tests/integration/components/lockstore.yaml create mode 100644 tests/integration/components/pubsub.yaml create mode 100644 tests/integration/components/statestore.yaml create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/secrets.json create mode 100644 tests/integration/test_configuration.py create mode 100644 tests/integration/test_distributed_lock.py create mode 100644 tests/integration/test_invoke.py create mode 100644 tests/integration/test_metadata.py create mode 100644 tests/integration/test_pubsub.py create mode 100644 tests/integration/test_secret_store.py create mode 100644 tests/integration/test_state_store.py diff --git a/.github/workflows/validate_examples.yaml b/.github/workflows/validate_examples.yaml index ae784965e..cd69c953c 100644 --- a/.github/workflows/validate_examples.yaml +++ b/.github/workflows/validate_examples.yaml @@ -104,5 +104,8 @@ jobs: sleep 10 ollama pull llama3.2:latest - name: Check examples + run: | + tox -e examples + - name: Run integration tests run: | tox -e integration diff --git a/tests/integration/apps/invoke_receiver.py b/tests/integration/apps/invoke_receiver.py new file mode 100644 index 000000000..41592eb0e --- /dev/null +++ b/tests/integration/apps/invoke_receiver.py @@ -0,0 +1,13 @@ +"""gRPC method handler for invoke integration tests.""" + +from dapr.ext.grpc import App, InvokeMethodRequest, InvokeMethodResponse + +app = App() + + +@app.method(name='my-method') +def my_method(request: InvokeMethodRequest) -> InvokeMethodResponse: + return InvokeMethodResponse(b'INVOKE_RECEIVED', 'text/plain; charset=UTF-8') + + +app.run(50051) diff --git a/tests/integration/apps/pubsub_subscriber.py b/tests/integration/apps/pubsub_subscriber.py new file mode 100644 index 000000000..2c1c4761b --- /dev/null +++ b/tests/integration/apps/pubsub_subscriber.py @@ -0,0 +1,25 @@ +"""Pub/sub subscriber that persists received messages to state store. + +Used by integration tests to verify message delivery without relying on stdout. +""" + +import json + +from cloudevents.sdk.event import v1 +from dapr.ext.grpc import App + +from dapr.clients import DaprClient +from dapr.clients.grpc._response import TopicEventResponse + +app = App() + + +@app.subscribe(pubsub_name='pubsub', topic='TOPIC_A') +def handle_topic_a(event: v1.Event) -> TopicEventResponse: + data = json.loads(event.Data()) + with DaprClient() as d: + d.save_state('statestore', f'received-topic-a-{data["id"]}', event.Data()) + return TopicEventResponse('success') + + +app.run(50051) diff --git a/tests/integration/components/configurationstore.yaml b/tests/integration/components/configurationstore.yaml new file mode 100644 index 000000000..fcf6569d0 --- /dev/null +++ b/tests/integration/components/configurationstore.yaml @@ -0,0 +1,11 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: configurationstore +spec: + type: configuration.redis + metadata: + - name: redisHost + value: localhost:6379 + - name: redisPassword + value: "" diff --git a/tests/integration/components/localsecretstore.yaml b/tests/integration/components/localsecretstore.yaml new file mode 100644 index 000000000..fd574a077 --- /dev/null +++ b/tests/integration/components/localsecretstore.yaml @@ -0,0 +1,13 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: localsecretstore +spec: + type: secretstores.local.file + metadata: + - name: secretsFile + # Relative to the Dapr process CWD (tests/integration/), set by + # DaprTestEnvironment via cwd=INTEGRATION_DIR. + value: secrets.json + - name: nestedSeparator + value: ":" diff --git a/tests/integration/components/lockstore.yaml b/tests/integration/components/lockstore.yaml new file mode 100644 index 000000000..424caceeb --- /dev/null +++ b/tests/integration/components/lockstore.yaml @@ -0,0 +1,11 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: lockstore +spec: + type: lock.redis + metadata: + - name: redisHost + value: localhost:6379 + - name: redisPassword + value: "" diff --git a/tests/integration/components/pubsub.yaml b/tests/integration/components/pubsub.yaml new file mode 100644 index 000000000..18764d8ce --- /dev/null +++ b/tests/integration/components/pubsub.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: pubsub +spec: + type: pubsub.redis + version: v1 + metadata: + - name: redisHost + value: localhost:6379 + - name: redisPassword + value: "" diff --git a/tests/integration/components/statestore.yaml b/tests/integration/components/statestore.yaml new file mode 100644 index 000000000..a0c53bc40 --- /dev/null +++ b/tests/integration/components/statestore.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: statestore +spec: + type: state.redis + version: v1 + metadata: + - name: redisHost + value: localhost:6379 + - name: redisPassword + value: "" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 000000000..f8755f50f --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,133 @@ +import shlex +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Any, Generator + +import pytest + +from dapr.clients import DaprClient + +INTEGRATION_DIR = Path(__file__).resolve().parent +COMPONENTS_DIR = INTEGRATION_DIR / 'components' +APPS_DIR = INTEGRATION_DIR / 'apps' + + +class DaprTestEnvironment: + """Manages Dapr sidecars and returns SDK clients for programmatic testing. + + Unlike tests.examples.DaprRunner (which captures stdout for output-based assertions), this + class returns real DaprClient instances so tests can make assertions against SDK return values. + """ + + def __init__(self, default_components: Path = COMPONENTS_DIR) -> None: + self._default_components = default_components + self._processes: list[subprocess.Popen[str]] = [] + self._log_files: list[Path] = [] + self._clients: list[DaprClient] = [] + + def start_sidecar( + self, + app_id: str, + *, + grpc_port: int = 50001, + http_port: int = 3500, + app_port: int | None = None, + app_cmd: str | None = None, + components: Path | None = None, + wait: int = 5, + ) -> DaprClient: + """Start a Dapr sidecar and return a connected DaprClient. + + Args: + app_id: Dapr application ID. + grpc_port: Sidecar gRPC port (must match DAPR_GRPC_PORT setting). + http_port: Sidecar HTTP port (must match DAPR_HTTP_PORT setting for + the SDK health check). + app_port: Port the app listens on (implies ``--app-protocol grpc``). + app_cmd: Shell command to start alongside the sidecar. + components: Path to component YAML directory. Defaults to + ``tests/integration/components/``. + wait: Seconds to sleep after launching (before the SDK health check). + """ + resources = components or self._default_components + + cmd = [ + 'dapr', + 'run', + '--app-id', + app_id, + '--resources-path', + str(resources), + '--dapr-grpc-port', + str(grpc_port), + '--dapr-http-port', + str(http_port), + ] + if app_port is not None: + cmd.extend(['--app-port', str(app_port), '--app-protocol', 'grpc']) + if app_cmd is not None: + cmd.extend(['--', *shlex.split(app_cmd)]) + + with tempfile.NamedTemporaryFile(mode='w', suffix=f'-{app_id}.log', delete=False) as log: + self._log_files.append(Path(log.name)) + proc = subprocess.Popen( + cmd, + cwd=INTEGRATION_DIR, + stdout=log, + stderr=subprocess.STDOUT, + text=True, + ) + self._processes.append(proc) + + # Give the sidecar a moment to bind its ports before the SDK health + # check starts hitting the HTTP endpoint. + time.sleep(wait) + + # DaprClient constructor calls DaprHealth.wait_for_sidecar(), which + # polls http://localhost:{DAPR_HTTP_PORT}/v1.0/healthz/outbound until + # the sidecar is ready (up to DAPR_HEALTH_TIMEOUT seconds). + client = DaprClient(address=f'127.0.0.1:{grpc_port}') + self._clients.append(client) + return client + + def cleanup(self) -> None: + for client in self._clients: + client.close() + self._clients.clear() + for proc in self._processes: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + self._processes.clear() + for log_path in self._log_files: + log_path.unlink(missing_ok=True) + self._log_files.clear() + + +@pytest.fixture(scope='module') +def dapr_env() -> Generator[DaprTestEnvironment, Any, None]: + """Provides a DaprTestEnvironment for programmatic SDK testing. + + Module-scoped so that all tests in a file share a single Dapr sidecar, + avoiding port conflicts from rapid start/stop cycles and cutting total + test time significantly. + """ + env = DaprTestEnvironment() + yield env + env.cleanup() + + +@pytest.fixture(scope='module') +def apps_dir() -> Path: + return APPS_DIR + + +@pytest.fixture(scope='module') +def components_dir() -> Path: + return COMPONENTS_DIR diff --git a/tests/integration/secrets.json b/tests/integration/secrets.json new file mode 100644 index 000000000..e8db35141 --- /dev/null +++ b/tests/integration/secrets.json @@ -0,0 +1,4 @@ +{ + "secretKey": "secretValue", + "random": "randomValue" +} diff --git a/tests/integration/test_configuration.py b/tests/integration/test_configuration.py new file mode 100644 index 000000000..d43fc8c8f --- /dev/null +++ b/tests/integration/test_configuration.py @@ -0,0 +1,91 @@ +import subprocess +import threading +import time + +import pytest + +from dapr.clients.grpc._response import ConfigurationResponse + +STORE = 'configurationstore' +REDIS_CONTAINER = 'dapr_redis' + + +def _redis_set(key: str, value: str, version: int = 1) -> None: + """Seed a configuration value directly in Redis. + + Dapr's Redis configuration store encodes values as ``value||version``. + """ + subprocess.run( + f'docker exec {REDIS_CONTAINER} redis-cli SET {key} "{value}||{version}"', + shell=True, + check=True, + capture_output=True, + ) + + +@pytest.fixture(scope='module') +def client(dapr_env): + _redis_set('cfg-key-1', 'val-1') + _redis_set('cfg-key-2', 'val-2') + return dapr_env.start_sidecar(app_id='test-config') + + +class TestGetConfiguration: + def test_get_single_key(self, client): + resp = client.get_configuration(store_name=STORE, keys=['cfg-key-1']) + assert 'cfg-key-1' in resp.items + assert resp.items['cfg-key-1'].value == 'val-1' + + def test_get_multiple_keys(self, client): + resp = client.get_configuration(store_name=STORE, keys=['cfg-key-1', 'cfg-key-2']) + assert resp.items['cfg-key-1'].value == 'val-1' + assert resp.items['cfg-key-2'].value == 'val-2' + + def test_get_missing_key_returns_empty_items(self, client): + resp = client.get_configuration(store_name=STORE, keys=['nonexistent-cfg-key']) + # Dapr omits keys that don't exist from the response. + assert 'nonexistent-cfg-key' not in resp.items + + def test_items_have_version(self, client): + resp = client.get_configuration(store_name=STORE, keys=['cfg-key-1']) + item = resp.items['cfg-key-1'] + assert item.version + + +class TestSubscribeConfiguration: + def test_subscribe_receives_update(self, client): + received: list[ConfigurationResponse] = [] + event = threading.Event() + + def handler(_id: str, resp: ConfigurationResponse) -> None: + received.append(resp) + event.set() + + sub_id = client.subscribe_configuration( + store_name=STORE, keys=['cfg-sub-key'], handler=handler + ) + assert sub_id + + # Give the subscription watcher thread time to establish its gRPC + # stream before pushing the update, otherwise the notification is missed. + time.sleep(1) + _redis_set('cfg-sub-key', 'updated-val', version=2) + event.wait(timeout=10) + + assert len(received) >= 1 + last = received[-1] + assert 'cfg-sub-key' in last.items + assert last.items['cfg-sub-key'].value == 'updated-val' + + ok = client.unsubscribe_configuration(store_name=STORE, id=sub_id) + assert ok + + def test_unsubscribe_returns_true(self, client): + sub_id = client.subscribe_configuration( + store_name=STORE, + keys=['cfg-unsub-key'], + handler=lambda _id, _resp: None, + ) + time.sleep(1) + ok = client.unsubscribe_configuration(store_name=STORE, id=sub_id) + assert ok diff --git a/tests/integration/test_distributed_lock.py b/tests/integration/test_distributed_lock.py new file mode 100644 index 000000000..68362c296 --- /dev/null +++ b/tests/integration/test_distributed_lock.py @@ -0,0 +1,66 @@ +import pytest + +from dapr.clients.grpc._response import UnlockResponseStatus + +STORE = 'lockstore' + +# The distributed lock API emits alpha warnings on every call. +pytestmark = pytest.mark.filterwarnings('ignore::UserWarning') + + +@pytest.fixture(scope='module') +def client(dapr_env): + return dapr_env.start_sidecar(app_id='test-lock') + + +class TestTryLock: + def test_acquire_lock(self, client): + lock = client.try_lock(STORE, 'res-acquire', 'owner-a', expiry_in_seconds=10) + assert lock.success + + def test_second_owner_is_rejected(self, client): + first = client.try_lock(STORE, 'res-contention', 'owner-a', expiry_in_seconds=10) + second = client.try_lock(STORE, 'res-contention', 'owner-b', expiry_in_seconds=10) + assert first.success + assert not second.success + + def test_lock_is_truthy_on_success(self, client): + lock = client.try_lock(STORE, 'res-truthy', 'owner-a', expiry_in_seconds=10) + assert bool(lock) is True + + def test_failed_lock_is_falsy(self, client): + client.try_lock(STORE, 'res-falsy', 'owner-a', expiry_in_seconds=10) + contested = client.try_lock(STORE, 'res-falsy', 'owner-b', expiry_in_seconds=10) + assert bool(contested) is False + + +class TestUnlock: + def test_unlock_own_lock(self, client): + client.try_lock(STORE, 'res-unlock', 'owner-a', expiry_in_seconds=10) + resp = client.unlock(STORE, 'res-unlock', 'owner-a') + assert resp.status == UnlockResponseStatus.success + + def test_unlock_wrong_owner(self, client): + client.try_lock(STORE, 'res-wrong-owner', 'owner-a', expiry_in_seconds=10) + resp = client.unlock(STORE, 'res-wrong-owner', 'owner-b') + assert resp.status == UnlockResponseStatus.lock_belongs_to_others + + def test_unlock_nonexistent(self, client): + resp = client.unlock(STORE, 'res-does-not-exist', 'owner-a') + assert resp.status == UnlockResponseStatus.lock_does_not_exist + + def test_unlock_frees_resource_for_others(self, client): + client.try_lock(STORE, 'res-release', 'owner-a', expiry_in_seconds=10) + client.unlock(STORE, 'res-release', 'owner-a') + second = client.try_lock(STORE, 'res-release', 'owner-b', expiry_in_seconds=10) + assert second.success + + +class TestLockContextManager: + def test_context_manager_auto_unlocks(self, client): + with client.try_lock(STORE, 'res-ctx', 'owner-a', expiry_in_seconds=10) as lock: + assert lock + + # After the context manager exits, another owner should be able to acquire. + second = client.try_lock(STORE, 'res-ctx', 'owner-b', expiry_in_seconds=10) + assert second.success diff --git a/tests/integration/test_invoke.py b/tests/integration/test_invoke.py new file mode 100644 index 000000000..45abdcdcb --- /dev/null +++ b/tests/integration/test_invoke.py @@ -0,0 +1,34 @@ +import pytest + + +@pytest.fixture(scope='module') +def client(dapr_env, apps_dir): + return dapr_env.start_sidecar( + app_id='invoke-receiver', + grpc_port=50001, + app_port=50051, + app_cmd=f'python3 {apps_dir / "invoke_receiver.py"}', + ) + + +def test_invoke_method_returns_expected_response(client): + resp = client.invoke_method( + app_id='invoke-receiver', + method_name='my-method', + data=b'{"id": 1, "message": "hello world"}', + content_type='application/json', + ) + # The app returns 'text/plain; charset=UTF-8', but Dapr may strip + # parameters when proxying through gRPC, so only check the media type. + assert resp.content_type.startswith('text/plain') + assert resp.data == b'INVOKE_RECEIVED' + + +def test_invoke_method_with_text_data(client): + resp = client.invoke_method( + app_id='invoke-receiver', + method_name='my-method', + data=b'plain text', + content_type='text/plain', + ) + assert resp.data == b'INVOKE_RECEIVED' diff --git a/tests/integration/test_metadata.py b/tests/integration/test_metadata.py new file mode 100644 index 000000000..88430ebbb --- /dev/null +++ b/tests/integration/test_metadata.py @@ -0,0 +1,42 @@ +import pytest + + +@pytest.fixture(scope='module') +def client(dapr_env): + return dapr_env.start_sidecar(app_id='test-metadata') + + +class TestGetMetadata: + def test_application_id_matches(self, client): + meta = client.get_metadata() + assert meta.application_id == 'test-metadata' + + def test_registered_components_present(self, client): + meta = client.get_metadata() + component_types = {c.type for c in meta.registered_components} + assert any(t.startswith('state.') for t in component_types) + + def test_registered_components_have_names(self, client): + meta = client.get_metadata() + for comp in meta.registered_components: + assert comp.name + assert comp.type + + +class TestSetMetadata: + def test_set_and_get_roundtrip(self, client): + client.set_metadata('test-key', 'test-value') + meta = client.get_metadata() + assert meta.extended_metadata.get('test-key') == 'test-value' + + def test_overwrite_existing_key(self, client): + client.set_metadata('overwrite-key', 'first') + client.set_metadata('overwrite-key', 'second') + meta = client.get_metadata() + assert meta.extended_metadata['overwrite-key'] == 'second' + + def test_empty_value_is_allowed(self, client): + client.set_metadata('empty-key', '') + meta = client.get_metadata() + assert 'empty-key' in meta.extended_metadata + assert meta.extended_metadata['empty-key'] == '' diff --git a/tests/integration/test_pubsub.py b/tests/integration/test_pubsub.py new file mode 100644 index 000000000..cee9cf0cf --- /dev/null +++ b/tests/integration/test_pubsub.py @@ -0,0 +1,49 @@ +import json +import time + +import pytest + +STORE = 'statestore' +PUBSUB = 'pubsub' +TOPIC = 'TOPIC_A' + + +@pytest.fixture(scope='module') +def client(dapr_env, apps_dir): + return dapr_env.start_sidecar( + app_id='test-subscriber', + grpc_port=50001, + app_port=50051, + app_cmd=f'python3 {apps_dir / "pubsub_subscriber.py"}', + wait=10, + ) + + +def test_published_messages_are_received_by_subscriber(client): + for n in range(1, 4): + client.publish_event( + pubsub_name=PUBSUB, + topic_name=TOPIC, + data=json.dumps({'id': n, 'message': 'hello world'}), + data_content_type='application/json', + ) + time.sleep(1) + + time.sleep(3) + + for n in range(1, 4): + state = client.get_state(store_name=STORE, key=f'received-topic-a-{n}') + assert state.data != b'', f'Subscriber did not receive message {n}' + msg = json.loads(state.data) + assert msg['id'] == n + assert msg['message'] == 'hello world' + + +def test_publish_event_succeeds(client): + """Verify publish_event does not raise on a valid topic.""" + client.publish_event( + pubsub_name=PUBSUB, + topic_name=TOPIC, + data=json.dumps({'id': 99, 'message': 'smoke test'}), + data_content_type='application/json', + ) diff --git a/tests/integration/test_secret_store.py b/tests/integration/test_secret_store.py new file mode 100644 index 000000000..b4e8e8679 --- /dev/null +++ b/tests/integration/test_secret_store.py @@ -0,0 +1,19 @@ +import pytest + +STORE = 'localsecretstore' + + +@pytest.fixture(scope='module') +def client(dapr_env, components_dir): + return dapr_env.start_sidecar(app_id='test-secret', components=components_dir) + + +def test_get_secret(client): + resp = client.get_secret(store_name=STORE, key='secretKey') + assert resp.secret == {'secretKey': 'secretValue'} + + +def test_get_bulk_secret(client): + resp = client.get_bulk_secret(store_name=STORE) + assert 'secretKey' in resp.secrets + assert resp.secrets['secretKey'] == {'secretKey': 'secretValue'} diff --git a/tests/integration/test_state_store.py b/tests/integration/test_state_store.py new file mode 100644 index 000000000..26ef51cad --- /dev/null +++ b/tests/integration/test_state_store.py @@ -0,0 +1,102 @@ +import grpc +import pytest + +from dapr.clients.grpc._request import TransactionalStateOperation, TransactionOperationType +from dapr.clients.grpc._state import StateItem + +STORE = 'statestore' + + +@pytest.fixture(scope='module') +def client(dapr_env): + return dapr_env.start_sidecar(app_id='test-state') + + +class TestSaveAndGetState: + def test_save_and_get(self, client): + client.save_state(store_name=STORE, key='k1', value='v1') + state = client.get_state(store_name=STORE, key='k1') + assert state.data == b'v1' + assert state.etag + + def test_save_with_wrong_etag_fails(self, client): + client.save_state(store_name=STORE, key='etag-test', value='original') + with pytest.raises(grpc.RpcError) as exc_info: + client.save_state(store_name=STORE, key='etag-test', value='bad', etag='9999') + assert exc_info.value.code() == grpc.StatusCode.ABORTED + + def test_get_missing_key_returns_empty(self, client): + state = client.get_state(store_name=STORE, key='nonexistent-key') + assert state.data == b'' + + +class TestBulkState: + def test_save_and_get_bulk(self, client): + client.save_bulk_state( + store_name=STORE, + states=[ + StateItem(key='bulk-1', value='v1'), + StateItem(key='bulk-2', value='v2'), + ], + ) + items = client.get_bulk_state(store_name=STORE, keys=['bulk-1', 'bulk-2']).items + by_key = {i.key: i.data for i in items} + assert by_key['bulk-1'] == b'v1' + assert by_key['bulk-2'] == b'v2' + + def test_save_bulk_with_wrong_etag_fails(self, client): + client.save_state(store_name=STORE, key='bulk-etag-1', value='original') + with pytest.raises(grpc.RpcError) as exc_info: + client.save_bulk_state( + store_name=STORE, + states=[StateItem(key='bulk-etag-1', value='updated', etag='9999')], + ) + assert exc_info.value.code() == grpc.StatusCode.ABORTED + + +class TestStateTransactions: + def test_transaction_upsert(self, client): + client.save_state(store_name=STORE, key='tx-1', value='original') + etag = client.get_state(store_name=STORE, key='tx-1').etag + + client.execute_state_transaction( + store_name=STORE, + operations=[ + TransactionalStateOperation( + operation_type=TransactionOperationType.upsert, + key='tx-1', + data='updated', + etag=etag, + ), + TransactionalStateOperation(key='tx-2', data='new'), + ], + ) + + assert client.get_state(store_name=STORE, key='tx-1').data == b'updated' + assert client.get_state(store_name=STORE, key='tx-2').data == b'new' + + def test_transaction_delete(self, client): + client.save_state(store_name=STORE, key='tx-del-1', value='v1') + client.save_state(store_name=STORE, key='tx-del-2', value='v2') + + client.execute_state_transaction( + store_name=STORE, + operations=[ + TransactionalStateOperation( + operation_type=TransactionOperationType.delete, key='tx-del-1' + ), + TransactionalStateOperation( + operation_type=TransactionOperationType.delete, key='tx-del-2' + ), + ], + ) + + assert client.get_state(store_name=STORE, key='tx-del-1').data == b'' + assert client.get_state(store_name=STORE, key='tx-del-2').data == b'' + + +class TestDeleteState: + def test_delete_single(self, client): + client.save_state(store_name=STORE, key='del-1', value='v1') + client.delete_state(store_name=STORE, key='del-1') + assert client.get_state(store_name=STORE, key='del-1').data == b'' diff --git a/tox.ini b/tox.ini index 711206c1b..67ecc4dd2 100644 --- a/tox.ini +++ b/tox.ini @@ -38,13 +38,13 @@ commands = ruff check --fix ruff format -[testenv:integration] -; Pytest-based examples tests that validate the examples/ directory. +[testenv:examples] +; Stdout-based smoke tests that run examples/ and check expected output. ; Usage: tox -e examples # run all ; tox -e examples -- test_state_store.py # run one passenv = HOME basepython = python3 -changedir = ./tests/integration/ +changedir = ./tests/examples/ commands = pytest {posargs} -v --tb=short @@ -63,6 +63,29 @@ commands_pre = opentelemetry-exporter-zipkin \ langchain-ollama +[testenv:integration] +; SDK-based integration tests using DaprClient directly. +; Usage: tox -e integration # run all +; tox -e integration -- test_state_store.py # run one +passenv = HOME +basepython = python3 +changedir = ./tests/integration/ +commands = + pytest {posargs} -v --tb=short + +allowlist_externals=* + +commands_pre = + pip uninstall -y dapr dapr-ext-grpc dapr-ext-fastapi dapr-ext-langgraph dapr-ext-strands dapr-ext-flask dapr-ext-langgraph dapr-ext-strands + pip install -r {toxinidir}/dev-requirements.txt \ + -e {toxinidir}/ \ + -e {toxinidir}/ext/dapr-ext-workflow/ \ + -e {toxinidir}/ext/dapr-ext-grpc/ \ + -e {toxinidir}/ext/dapr-ext-fastapi/ \ + -e {toxinidir}/ext/dapr-ext-langgraph/ \ + -e {toxinidir}/ext/dapr-ext-strands/ \ + -e {toxinidir}/ext/flask_dapr/ + [testenv:type] basepython = python3 usedevelop = False From 1ab132077110734cf9d38ab5b704598e3393f058 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:21:01 +0200 Subject: [PATCH 03/15] Update docs to new test structure Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- AGENTS.md | 33 ++++++++++++++++++++++----------- examples/AGENTS.md | 14 +++++++------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 98550e585..b71b26c9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,9 @@ ext/ # Extension packages (each is a separate PyPI packa └── flask_dapr/ # Flask integration ← see ext/flask_dapr/AGENTS.md tests/ # Unit tests (mirrors dapr/ package structure) -examples/ # Integration test suite ← see examples/AGENTS.md +├── examples/ # Output-based tests that run examples and check stdout +├── integration/ # Programmatic SDK tests using DaprClient directly +examples/ # User-facing example applications ← see examples/AGENTS.md docs/ # Sphinx documentation source tools/ # Build and release scripts ``` @@ -59,16 +61,21 @@ Each extension is a **separate PyPI package** with its own `setup.cfg`, `setup.p | `dapr-ext-langgraph` | `dapr.ext.langgraph` | LangGraph checkpoint persistence to Dapr state store | Moderate | | `dapr-ext-strands` | `dapr.ext.strands` | Strands agent session management via Dapr state store | New | -## Examples (integration test suite) +## Examples and testing -The `examples/` directory serves as both user-facing documentation and the project's integration test suite. Examples are validated by pytest-based integration tests in `tests/integration/`. +The `examples/` directory contains user-facing example applications. These are validated by two test suites: + +- **`tests/examples/`** — Output-based tests that run examples via `dapr run` and check stdout for expected strings. Uses a `DaprRunner` helper to manage process lifecycle. +- **`tests/integration/`** — Programmatic SDK tests that call `DaprClient` methods directly and assert on return values, gRPC status codes, and SDK types. More reliable than output-based tests since they don't depend on print statement formatting. **See `examples/AGENTS.md`** for the full guide on example structure and how to add new examples. Quick reference: ```bash -tox -e examples # Run all examples (needs Dapr runtime) -tox -e examples -- test_state_store.py # Run a single example +tox -e examples # Run output-based example tests +tox -e examples -- test_state_store.py # Run a single example test +tox -e integration # Run programmatic SDK tests +tox -e integration -- test_state_store.py # Run a single integration test ``` ## Python version support @@ -106,8 +113,11 @@ tox -e ruff # Run type checking tox -e type -# Run examples tests / validate examples (requires Dapr runtime) +# Run output-based example tests (requires Dapr runtime) tox -e examples + +# Run programmatic integration tests (requires Dapr runtime) +tox -e integration ``` To run tests directly without tox: @@ -189,8 +199,8 @@ When completing any task on this project, work through this checklist. Not every ### Examples (integration tests) - [ ] If you added a new user-facing feature or building block, add or update an example in `examples/` -- [ ] Add a corresponding pytest integration test in `tests/integration/` -- [ ] If you changed output format of existing functionality, update expected output in the affected integration tests +- [ ] Add a corresponding pytest test in `tests/examples/` (output-based) and/or `tests/integration/` (programmatic) +- [ ] If you changed output format of existing functionality, update expected output in `tests/examples/` - [ ] See `examples/AGENTS.md` for full details on writing examples ### Documentation @@ -202,7 +212,7 @@ When completing any task on this project, work through this checklist. Not every - [ ] Run `tox -e ruff` — linting must be clean - [ ] Run `tox -e py311` (or your Python version) — all unit tests must pass -- [ ] If you touched examples: `tox -e integration -- test_.py` to validate locally +- [ ] If you touched examples: `tox -e examples -- test_.py` to validate locally - [ ] Commits must be signed off for DCO: `git commit -s` ## Important files @@ -217,7 +227,8 @@ When completing any task on this project, work through this checklist. Not every | `dev-requirements.txt` | Development/test dependencies | | `dapr/version/__init__.py` | SDK version string | | `ext/*/setup.cfg` | Extension package metadata and dependencies | -| `tests/integration/` | Pytest-based integration tests that validate examples | +| `tests/examples/` | Output-based tests that validate examples by checking stdout | +| `tests/integration/` | Programmatic SDK tests using DaprClient directly | ## Gotchas @@ -226,6 +237,6 @@ When completing any task on this project, work through this checklist. Not every - **Extension independence**: Each extension is a separate PyPI package. Core SDK changes should not break extensions; extension changes should not require core SDK changes unless intentional. - **DCO signoff**: PRs will be blocked by the DCO bot if commits lack `Signed-off-by`. Always use `git commit -s`. - **Ruff version pinned**: Dev requirements pin `ruff === 0.14.1`. Use this exact version to match CI. -- **Examples are integration tests**: Changing output format (log messages, print statements) can break integration tests. Always check expected output in `tests/integration/` when modifying user-visible output. +- **Examples are tested by output matching**: Changing output format (log messages, print statements) can break `tests/examples/`. Always check expected output there when modifying user-visible output. - **Background processes in examples**: Examples that start background services (servers, subscribers) must include a cleanup step to stop them, or CI will hang. - **Workflow is the most active area**: See `ext/dapr-ext-workflow/AGENTS.md` for workflow-specific architecture and constraints. diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 4b61d3002..e356db9f8 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -1,11 +1,11 @@ # AGENTS.md — Dapr Python SDK Examples -The `examples/` directory serves as both **user-facing documentation** and the project's **integration test suite**. Each example is a self-contained application validated by pytest-based integration tests in `tests/integration/`. +The `examples/` directory serves as both **user-facing documentation** and the project's **integration test suite**. Each example is a self-contained application validated by pytest-based tests in `tests/examples/`. ## How validation works -1. Each example has a corresponding test file in `tests/integration/` (e.g., `test_state_store.py`) -2. Tests use a `DaprRunner` helper (defined in `conftest.py`) that wraps `dapr run` commands +1. Each example has a corresponding test file in `tests/examples/` (e.g., `test_state_store.py`) +2. Tests use a `DaprRunner` helper (defined in `tests/examples/conftest.py`) that wraps `dapr run` commands 3. `DaprRunner.run()` executes a command and captures stdout; `DaprRunner.start()`/`stop()` manage background services 4. Tests assert that expected output lines appear in the captured output @@ -132,17 +132,17 @@ The `workflow` example includes: `simple.py`, `task_chaining.py`, `fan_out_fan_i 2. Add Python source files and a `requirements.txt` referencing the needed SDK packages 3. Add Dapr component YAMLs in a `components/` subdirectory if the example uses state, pubsub, etc. 4. Write a `README.md` with introduction, pre-requisites, install instructions, and running instructions -5. Add a corresponding test in `tests/integration/test_.py`: +5. Add a corresponding test in `tests/examples/test_.py`: - Use the `@pytest.mark.example_dir('')` marker to set the working directory - Use `dapr.run()` for scripts that exit on their own, `dapr.start()`/`dapr.stop()` for long-running services - Assert expected output lines appear in the captured output -6. Test locally: `tox -e integration -- test_.py` +6. Test locally: `tox -e examples -- test_.py` ## Gotchas -- **Output format changes break tests**: If you modify print statements or log output in SDK code, check whether any integration test's expected lines depend on that output. +- **Output format changes break tests**: If you modify print statements or log output in SDK code, check whether any test's expected lines in `tests/examples/` depend on that output. - **Background processes must be cleaned up**: The `DaprRunner` fixture handles cleanup on teardown, but tests should still call `dapr.stop()` to capture output. - **Dapr prefixes output**: Application stdout appears as `== APP == ` when run via `dapr run`. - **Redis is available in CI**: The CI environment has Redis running on `localhost:6379` — most component YAMLs use this. - **Some examples need special setup**: `crypto` generates keys, `configuration` seeds Redis, `conversation` needs LLM config — check individual READMEs. -- **Infinite-loop example scripts**: Some example scripts (e.g., `invoke-caller.py`) have `while True` loops for demo purposes. Integration tests must either bypass these with HTTP API calls or use `dapr.run(until=...)` for early termination. \ No newline at end of file +- **Infinite-loop example scripts**: Some example scripts (e.g., `invoke-caller.py`) have `while True` loops for demo purposes. Tests must either bypass these with HTTP API calls or use `dapr.run(until=...)` for early termination. \ No newline at end of file From 1ef0c22c1b739b4c4bc5de9da4a640317e761568 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:26:35 +0200 Subject: [PATCH 04/15] Address Copilot comments (1) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- AGENTS.md | 6 +- examples/AGENTS.md | 2 +- .../real_llm_providers_example.py | 2 +- tests/clients/test_conversation_helpers.py | 2 +- tests/integration/AGENTS.md | 94 +++++++++++++++++++ tests/integration/conftest.py | 15 +-- tests/integration/test_configuration.py | 3 +- 7 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 tests/integration/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index b71b26c9a..d1c67c21e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,10 +65,8 @@ Each extension is a **separate PyPI package** with its own `setup.cfg`, `setup.p The `examples/` directory contains user-facing example applications. These are validated by two test suites: -- **`tests/examples/`** — Output-based tests that run examples via `dapr run` and check stdout for expected strings. Uses a `DaprRunner` helper to manage process lifecycle. -- **`tests/integration/`** — Programmatic SDK tests that call `DaprClient` methods directly and assert on return values, gRPC status codes, and SDK types. More reliable than output-based tests since they don't depend on print statement formatting. - -**See `examples/AGENTS.md`** for the full guide on example structure and how to add new examples. +- **`tests/examples/`** — Output-based tests that run examples via `dapr run` and check stdout for expected strings. Uses a `DaprRunner` helper to manage process lifecycle. See `examples/AGENTS.md`. +- **`tests/integration/`** — Programmatic SDK tests that call `DaprClient` methods directly and assert on return values, gRPC status codes, and SDK types. More reliable than output-based tests since they don't depend on print statement formatting. See `tests/integration/AGENTS.md`. Quick reference: ```bash diff --git a/examples/AGENTS.md b/examples/AGENTS.md index e356db9f8..36bd171ef 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — Dapr Python SDK Examples -The `examples/` directory serves as both **user-facing documentation** and the project's **integration test suite**. Each example is a self-contained application validated by pytest-based tests in `tests/examples/`. +The `examples/` directory serves as the **user-facing documentation**. Each example is a self-contained application validated by pytest-based tests in `tests/examples/`. ## How validation works diff --git a/examples/conversation/real_llm_providers_example.py b/examples/conversation/real_llm_providers_example.py index e37cec745..2347f4b50 100644 --- a/examples/conversation/real_llm_providers_example.py +++ b/examples/conversation/real_llm_providers_example.py @@ -1237,7 +1237,7 @@ def main(): print(f'\n{"=" * 60}') print('🎉 All Alpha2 tests completed!') - print('✅ Real LLM provider examples with Alpha2 API is working correctly') + print('✅ Real LLM provider integration with Alpha2 API is working correctly') print('🔧 Features demonstrated:') print(' • Alpha2 conversation API with sophisticated message types') print(' • Automatic parameter conversion (raw Python values)') diff --git a/tests/clients/test_conversation_helpers.py b/tests/clients/test_conversation_helpers.py index c9d86db25..e7c69b30e 100644 --- a/tests/clients/test_conversation_helpers.py +++ b/tests/clients/test_conversation_helpers.py @@ -1511,7 +1511,7 @@ def google_function(data: str): class TestIntegrationScenarios(unittest.TestCase): - """Test real-world examples scenarios.""" + """Test real-world integration scenarios.""" def test_restaurant_finder_scenario(self): """Test the restaurant finder example from the documentation.""" diff --git a/tests/integration/AGENTS.md b/tests/integration/AGENTS.md new file mode 100644 index 000000000..bb391a418 --- /dev/null +++ b/tests/integration/AGENTS.md @@ -0,0 +1,94 @@ +# AGENTS.md — Programmatic Integration Tests + +This directory contains **programmatic SDK tests** that call `DaprClient` methods directly and assert on return values, gRPC status codes, and SDK types. Unlike the output-based tests in `tests/examples/` (which run example scripts and check stdout), these tests don't depend on print statement formatting. + +## How it works + +1. `DaprTestEnvironment` (defined in `conftest.py`) manages Dapr sidecar processes +2. `start_sidecar()` launches `dapr run` with explicit ports, waits for the health check, and returns a connected `DaprClient` +3. Tests call SDK methods on that client and assert on the response objects +4. Sidecar stdout is written to temp files (not pipes) to avoid buffer deadlocks +5. Cleanup terminates sidecars, closes clients, and removes log files + +Run locally (requires a running Dapr runtime via `dapr init`): + +```bash +# All integration tests +tox -e integration + +# Single test file +tox -e integration -- test_state_store.py + +# Single test +tox -e integration -- test_state_store.py -k test_save_and_get +``` + +## Directory structure + +``` +tests/integration/ +├── conftest.py # DaprTestEnvironment + fixtures (dapr_env, apps_dir, components_dir) +├── test_*.py # Test files (one per building block) +├── apps/ # Helper apps started alongside sidecars +│ ├── invoke_receiver.py # gRPC method handler for invoke tests +│ └── pubsub_subscriber.py # Subscriber that persists messages to state store +├── components/ # Dapr component YAMLs loaded by all sidecars +│ ├── statestore.yaml # state.redis +│ ├── pubsub.yaml # pubsub.redis +│ ├── lockstore.yaml # lock.redis +│ ├── configurationstore.yaml # configuration.redis +│ └── localsecretstore.yaml # secretstores.local.file +└── secrets.json # Secrets file for localsecretstore component +``` + +## Fixtures + +All fixtures are **module-scoped** — one sidecar per test file. + +| Fixture | Type | Description | +|---------|------|-------------| +| `dapr_env` | `DaprTestEnvironment` | Manages sidecar lifecycle; call `start_sidecar()` to get a client | +| `apps_dir` | `Path` | Path to `tests/integration/apps/` | +| `components_dir` | `Path` | Path to `tests/integration/components/` | + +Each test file defines its own module-scoped `client` fixture that calls `dapr_env.start_sidecar(...)`. + +## Building blocks covered + +| Test file | Building block | SDK methods tested | +|-----------|---------------|-------------------| +| `test_state_store.py` | State management | `save_state`, `get_state`, `save_bulk_state`, `get_bulk_state`, `execute_state_transaction`, `delete_state` | +| `test_invoke.py` | Service invocation | `invoke_method` | +| `test_pubsub.py` | Pub/sub | `publish_event`, `get_state` (to verify delivery) | +| `test_secret_store.py` | Secrets | `get_secret`, `get_bulk_secret` | +| `test_metadata.py` | Metadata | `get_metadata`, `set_metadata` | +| `test_distributed_lock.py` | Distributed lock | `try_lock`, `unlock`, context manager | +| `test_configuration.py` | Configuration | `get_configuration`, `subscribe_configuration`, `unsubscribe_configuration` | + +## Port allocation + +All sidecars default to gRPC port 50001 and HTTP port 3500. Since fixtures are module-scoped and tests run sequentially, only one sidecar is active at a time. If parallel execution is needed in the future, sidecars will need dynamic port allocation. + +## Helper apps + +Some building blocks (invoke, pubsub) require an app process running alongside the sidecar: + +- **`invoke_receiver.py`** — A `dapr.ext.grpc.App` that handles `my-method` and returns `INVOKE_RECEIVED`. +- **`pubsub_subscriber.py`** — Subscribes to `TOPIC_A` and persists received messages to the state store. This lets tests verify message delivery by reading state rather than parsing stdout. + +## Adding a new test + +1. Create `test_.py` +2. Add a module-scoped `client` fixture that calls `dapr_env.start_sidecar(app_id='test-')` +3. If the building block needs a new Dapr component, add a YAML to `components/` +4. If the building block needs a running app, add it to `apps/` and pass `app_cmd` / `app_port` to `start_sidecar()` +5. Use unique keys/resource IDs per test to avoid interference (the sidecar is shared within a module) +6. Assert on SDK return types and gRPC status codes, not on string output + +## Gotchas + +- **Requires `dapr init`** — the tests assume a local Dapr runtime with Redis (`dapr_redis` container on `localhost:6379`), which `dapr init` sets up automatically. +- **Configuration tests seed Redis directly** via `docker exec dapr_redis redis-cli`. +- **Lock and configuration APIs are alpha** and emit `UserWarning` on every call. Tests suppress these with `pytestmark = pytest.mark.filterwarnings('ignore::UserWarning')`. +- **`localsecretstore.yaml` uses a relative path** (`secrets.json`) resolved against `cwd=INTEGRATION_DIR`. +- **Dapr may normalize response fields** — e.g., `content_type` may lose charset parameters when proxied through gRPC. Assert on the media type prefix, not the full string. diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index f8755f50f..5552b2038 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -8,6 +8,7 @@ import pytest from dapr.clients import DaprClient +from dapr.conf import settings INTEGRATION_DIR = Path(__file__).resolve().parent COMPONENTS_DIR = INTEGRATION_DIR / 'components' @@ -42,9 +43,8 @@ def start_sidecar( Args: app_id: Dapr application ID. - grpc_port: Sidecar gRPC port (must match DAPR_GRPC_PORT setting). - http_port: Sidecar HTTP port (must match DAPR_HTTP_PORT setting for - the SDK health check). + grpc_port: Sidecar gRPC port. + http_port: Sidecar HTTP port (also used for the SDK health check). app_port: Port the app listens on (implies ``--app-protocol grpc``). app_cmd: Shell command to start alongside the sidecar. components: Path to component YAML directory. Defaults to @@ -85,9 +85,12 @@ def start_sidecar( # check starts hitting the HTTP endpoint. time.sleep(wait) - # DaprClient constructor calls DaprHealth.wait_for_sidecar(), which - # polls http://localhost:{DAPR_HTTP_PORT}/v1.0/healthz/outbound until - # the sidecar is ready (up to DAPR_HEALTH_TIMEOUT seconds). + # Point the SDK health check at the actual sidecar HTTP port. + # DaprHealth.wait_for_sidecar() reads settings.DAPR_HTTP_PORT, which + # is initialized once at import time and won't reflect a non-default + # http_port unless we update it here. + settings.DAPR_HTTP_PORT = http_port + client = DaprClient(address=f'127.0.0.1:{grpc_port}') self._clients.append(client) return client diff --git a/tests/integration/test_configuration.py b/tests/integration/test_configuration.py index d43fc8c8f..960aed6e0 100644 --- a/tests/integration/test_configuration.py +++ b/tests/integration/test_configuration.py @@ -16,8 +16,7 @@ def _redis_set(key: str, value: str, version: int = 1) -> None: Dapr's Redis configuration store encodes values as ``value||version``. """ subprocess.run( - f'docker exec {REDIS_CONTAINER} redis-cli SET {key} "{value}||{version}"', - shell=True, + args=('docker', 'exec', REDIS_CONTAINER, 'redis-cli', 'SET', key, f'"{value}||{version}"'), check=True, capture_output=True, ) From 0708271e8fc30e2c03a0edbcda1341d5165a13fa Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:31:23 +0200 Subject: [PATCH 05/15] Address Copilot comments (2) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/integration/apps/pubsub_subscriber.py | 3 ++- tests/integration/test_configuration.py | 2 +- tests/integration/test_pubsub.py | 26 ++++++++++++++++++--- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/tests/integration/apps/pubsub_subscriber.py b/tests/integration/apps/pubsub_subscriber.py index 2c1c4761b..110fa14c8 100644 --- a/tests/integration/apps/pubsub_subscriber.py +++ b/tests/integration/apps/pubsub_subscriber.py @@ -17,8 +17,9 @@ @app.subscribe(pubsub_name='pubsub', topic='TOPIC_A') def handle_topic_a(event: v1.Event) -> TopicEventResponse: data = json.loads(event.Data()) + key = f'received-{data["run_id"]}-{data["id"]}' with DaprClient() as d: - d.save_state('statestore', f'received-topic-a-{data["id"]}', event.Data()) + d.save_state('statestore', key, event.Data()) return TopicEventResponse('success') diff --git a/tests/integration/test_configuration.py b/tests/integration/test_configuration.py index 960aed6e0..10e7df835 100644 --- a/tests/integration/test_configuration.py +++ b/tests/integration/test_configuration.py @@ -16,7 +16,7 @@ def _redis_set(key: str, value: str, version: int = 1) -> None: Dapr's Redis configuration store encodes values as ``value||version``. """ subprocess.run( - args=('docker', 'exec', REDIS_CONTAINER, 'redis-cli', 'SET', key, f'"{value}||{version}"'), + args=('docker', 'exec', REDIS_CONTAINER, 'redis-cli', 'SET', key, f'{value}||{version}'), check=True, capture_output=True, ) diff --git a/tests/integration/test_pubsub.py b/tests/integration/test_pubsub.py index cee9cf0cf..e4037a8c1 100644 --- a/tests/integration/test_pubsub.py +++ b/tests/integration/test_pubsub.py @@ -1,15 +1,34 @@ import json +import subprocess import time +import uuid import pytest STORE = 'statestore' PUBSUB = 'pubsub' TOPIC = 'TOPIC_A' +REDIS_CONTAINER = 'dapr_redis' + + +def _flush_redis() -> None: + """Flush the Dapr Redis instance to prevent state leaking between runs. + + Both the state store and the pubsub component point at the same + ``dapr_redis`` container (see ``tests/integration/components/``), so a + previous run's ``received-*`` keys could otherwise satisfy this test's + assertions even if no new message was delivered. + """ + subprocess.run( + args=('docker', 'exec', REDIS_CONTAINER, 'redis-cli', 'FLUSHDB'), + check=True, + capture_output=True, + ) @pytest.fixture(scope='module') def client(dapr_env, apps_dir): + _flush_redis() return dapr_env.start_sidecar( app_id='test-subscriber', grpc_port=50001, @@ -20,11 +39,12 @@ def client(dapr_env, apps_dir): def test_published_messages_are_received_by_subscriber(client): + run_id = uuid.uuid4().hex for n in range(1, 4): client.publish_event( pubsub_name=PUBSUB, topic_name=TOPIC, - data=json.dumps({'id': n, 'message': 'hello world'}), + data=json.dumps({'run_id': run_id, 'id': n, 'message': 'hello world'}), data_content_type='application/json', ) time.sleep(1) @@ -32,7 +52,7 @@ def test_published_messages_are_received_by_subscriber(client): time.sleep(3) for n in range(1, 4): - state = client.get_state(store_name=STORE, key=f'received-topic-a-{n}') + state = client.get_state(store_name=STORE, key=f'received-{run_id}-{n}') assert state.data != b'', f'Subscriber did not receive message {n}' msg = json.loads(state.data) assert msg['id'] == n @@ -44,6 +64,6 @@ def test_publish_event_succeeds(client): client.publish_event( pubsub_name=PUBSUB, topic_name=TOPIC, - data=json.dumps({'id': 99, 'message': 'smoke test'}), + data=json.dumps({'run_id': uuid.uuid4().hex, 'id': 99, 'message': 'smoke test'}), data_content_type='application/json', ) From 9f65704d2a67eb909a888bf28eb1a528b28cdff4 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:11:21 +0200 Subject: [PATCH 06/15] Address Copilot comments (3) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/integration/conftest.py | 23 +++++++++++++++++++---- tests/integration/test_configuration.py | 1 + tests/integration/test_pubsub.py | 1 + 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 5552b2038..207520e1a 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -2,8 +2,9 @@ import subprocess import tempfile import time +from contextlib import contextmanager from pathlib import Path -from typing import Any, Generator +from typing import Any, Generator, Iterator import pytest @@ -113,6 +114,17 @@ def cleanup(self) -> None: self._log_files.clear() +@contextmanager +def _preserve_http_port() -> Iterator[None]: + # start_sidecar() mutates settings.DAPR_HTTP_PORT. + # This restores the original value so it does not leak across test modules. + original = settings.DAPR_HTTP_PORT + try: + yield + finally: + settings.DAPR_HTTP_PORT = original + + @pytest.fixture(scope='module') def dapr_env() -> Generator[DaprTestEnvironment, Any, None]: """Provides a DaprTestEnvironment for programmatic SDK testing. @@ -121,9 +133,12 @@ def dapr_env() -> Generator[DaprTestEnvironment, Any, None]: avoiding port conflicts from rapid start/stop cycles and cutting total test time significantly. """ - env = DaprTestEnvironment() - yield env - env.cleanup() + with _preserve_http_port(): + env = DaprTestEnvironment() + try: + yield env + finally: + env.cleanup() @pytest.fixture(scope='module') diff --git a/tests/integration/test_configuration.py b/tests/integration/test_configuration.py index 10e7df835..d7a953107 100644 --- a/tests/integration/test_configuration.py +++ b/tests/integration/test_configuration.py @@ -19,6 +19,7 @@ def _redis_set(key: str, value: str, version: int = 1) -> None: args=('docker', 'exec', REDIS_CONTAINER, 'redis-cli', 'SET', key, f'{value}||{version}'), check=True, capture_output=True, + timeout=10, ) diff --git a/tests/integration/test_pubsub.py b/tests/integration/test_pubsub.py index e4037a8c1..a9fe6c416 100644 --- a/tests/integration/test_pubsub.py +++ b/tests/integration/test_pubsub.py @@ -23,6 +23,7 @@ def _flush_redis() -> None: args=('docker', 'exec', REDIS_CONTAINER, 'redis-cli', 'FLUSHDB'), check=True, capture_output=True, + timeout=10, ) From 7161d9cf906ffeb6e2719af41aa4ac801878e9d0 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:54:19 +0200 Subject: [PATCH 07/15] Replace sleep() with polls when possible Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/integration/conftest.py | 92 +++++++++++++++++++++---- tests/integration/test_configuration.py | 1 - tests/integration/test_pubsub.py | 16 ++--- 3 files changed, 85 insertions(+), 24 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 207520e1a..858c7f6a6 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -4,13 +4,16 @@ import time from contextlib import contextmanager from pathlib import Path -from typing import Any, Generator, Iterator +from typing import Any, Callable, Generator, Iterator, TypeVar +import httpx import pytest from dapr.clients import DaprClient from dapr.conf import settings +T = TypeVar('T') + INTEGRATION_DIR = Path(__file__).resolve().parent COMPONENTS_DIR = INTEGRATION_DIR / 'components' APPS_DIR = INTEGRATION_DIR / 'apps' @@ -38,7 +41,6 @@ def start_sidecar( app_port: int | None = None, app_cmd: str | None = None, components: Path | None = None, - wait: int = 5, ) -> DaprClient: """Start a Dapr sidecar and return a connected DaprClient. @@ -50,7 +52,6 @@ def start_sidecar( app_cmd: Shell command to start alongside the sidecar. components: Path to component YAML directory. Defaults to ``tests/integration/components/``. - wait: Seconds to sleep after launching (before the SDK health check). """ resources = components or self._default_components @@ -82,18 +83,22 @@ def start_sidecar( ) self._processes.append(proc) - # Give the sidecar a moment to bind its ports before the SDK health - # check starts hitting the HTTP endpoint. - time.sleep(wait) - # Point the SDK health check at the actual sidecar HTTP port. # DaprHealth.wait_for_sidecar() reads settings.DAPR_HTTP_PORT, which # is initialized once at import time and won't reflect a non-default - # http_port unless we update it here. + # http_port unless we update it here. The DaprClient constructor + # polls /healthz/outbound on this port, so we don't need to sleep first. settings.DAPR_HTTP_PORT = http_port client = DaprClient(address=f'127.0.0.1:{grpc_port}') self._clients.append(client) + + # /healthz/outbound (polled by DaprClient) only checks sidecar-side + # readiness. When we launched an app alongside the sidecar, also wait + # for /v1.0/healthz so invoke_method et al. don't race the app's server. + if app_cmd is not None: + _wait_for_app_health(http_port) + return client def cleanup(self) -> None: @@ -114,15 +119,68 @@ def cleanup(self) -> None: self._log_files.clear() +def _wait_until( + predicate: Callable[[], T | None], + timeout: float = 10.0, + interval: float = 0.1, +) -> T: + """Poll `predicate` until it returns a truthy value. eaises `TimeoutError` if it never does.""" + deadline = time.monotonic() + timeout + while True: + result = predicate() + if result: + return result + if time.monotonic() >= deadline: + raise TimeoutError(f'wait_until timed out after {timeout}s') + time.sleep(interval) + + +def _wait_for_app_health(http_port: int, timeout: float = 30.0) -> None: + """Poll Dapr's app-facing /v1.0/healthz endpoint until it returns 2xx. + + ``/v1.0/healthz`` requires the app behind the sidecar to be reachable, + unlike ``/v1.0/healthz/outbound`` which only checks sidecar readiness. + """ + url = f'http://127.0.0.1:{http_port}/v1.0/healthz' + + def _check() -> bool: + try: + response = httpx.get(url, timeout=2.0) + except httpx.HTTPError: + return False + return response.is_success + + _wait_until(_check, timeout=timeout, interval=0.2) + + @contextmanager -def _preserve_http_port() -> Iterator[None]: - # start_sidecar() mutates settings.DAPR_HTTP_PORT. - # This restores the original value so it does not leak across test modules. - original = settings.DAPR_HTTP_PORT +def _isolate_dapr_settings() -> Iterator[None]: + """Pin SDK HTTP settings to the local test sidecar for the duration. + + ``DaprHealth.get_api_url()`` consults three settings (see + ``dapr/clients/http/helpers.py``): + + - ``DAPR_HTTP_ENDPOINT``, if set, wins and bypasses host/port entirely. + - ``DAPR_RUNTIME_HOST`` is the host component of the fallback URL. + - ``DAPR_HTTP_PORT`` is the port component of the fallback URL. + + Any of these may be populated from the developer's environment (the Dapr + CLI sets them); without an override the SDK health check could target the + wrong sidecar. All three are snapshotted and restored so the test's + mutations don't leak across modules either. + """ + originals = { + 'DAPR_HTTP_ENDPOINT': settings.DAPR_HTTP_ENDPOINT, + 'DAPR_RUNTIME_HOST': settings.DAPR_RUNTIME_HOST, + 'DAPR_HTTP_PORT': settings.DAPR_HTTP_PORT, + } + settings.DAPR_HTTP_ENDPOINT = None + settings.DAPR_RUNTIME_HOST = '127.0.0.1' try: yield finally: - settings.DAPR_HTTP_PORT = original + for name, value in originals.items(): + setattr(settings, name, value) @pytest.fixture(scope='module') @@ -133,7 +191,7 @@ def dapr_env() -> Generator[DaprTestEnvironment, Any, None]: avoiding port conflicts from rapid start/stop cycles and cutting total test time significantly. """ - with _preserve_http_port(): + with _isolate_dapr_settings(): env = DaprTestEnvironment() try: yield env @@ -141,6 +199,12 @@ def dapr_env() -> Generator[DaprTestEnvironment, Any, None]: env.cleanup() +@pytest.fixture +def wait_until() -> Callable[..., Any]: + """Returns the ``_wait_until(predicate, timeout=10, interval=0.1)`` helper.""" + return _wait_until + + @pytest.fixture(scope='module') def apps_dir() -> Path: return APPS_DIR diff --git a/tests/integration/test_configuration.py b/tests/integration/test_configuration.py index d7a953107..e73f1a16a 100644 --- a/tests/integration/test_configuration.py +++ b/tests/integration/test_configuration.py @@ -86,6 +86,5 @@ def test_unsubscribe_returns_true(self, client): keys=['cfg-unsub-key'], handler=lambda _id, _resp: None, ) - time.sleep(1) ok = client.unsubscribe_configuration(store_name=STORE, id=sub_id) assert ok diff --git a/tests/integration/test_pubsub.py b/tests/integration/test_pubsub.py index a9fe6c416..612405b89 100644 --- a/tests/integration/test_pubsub.py +++ b/tests/integration/test_pubsub.py @@ -1,6 +1,5 @@ import json import subprocess -import time import uuid import pytest @@ -35,11 +34,10 @@ def client(dapr_env, apps_dir): grpc_port=50001, app_port=50051, app_cmd=f'python3 {apps_dir / "pubsub_subscriber.py"}', - wait=10, ) -def test_published_messages_are_received_by_subscriber(client): +def test_published_messages_are_received_by_subscriber(client, wait_until): run_id = uuid.uuid4().hex for n in range(1, 4): client.publish_event( @@ -48,14 +46,14 @@ def test_published_messages_are_received_by_subscriber(client): data=json.dumps({'run_id': run_id, 'id': n, 'message': 'hello world'}), data_content_type='application/json', ) - time.sleep(1) - - time.sleep(3) for n in range(1, 4): - state = client.get_state(store_name=STORE, key=f'received-{run_id}-{n}') - assert state.data != b'', f'Subscriber did not receive message {n}' - msg = json.loads(state.data) + key = f'received-{run_id}-{n}' + data = wait_until( + lambda k=key: client.get_state(store_name=STORE, key=k).data or None, + timeout=10, + ) + msg = json.loads(data) assert msg['id'] == n assert msg['message'] == 'hello world' From e6f12a2398b0038d90a0651d5c17ceab414f3412 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:54:32 +0200 Subject: [PATCH 08/15] Address Copilot comments (4) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- README.md | 8 +++++++- tox.ini | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f6b203412..441c37e5b 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,13 @@ tox -e py311 tox -e type ``` -8. Run integration tests (validates the examples) +8. Run integration tests + +```bash +tox -e integration +``` + +9. Validate the examples ```bash tox -e examples diff --git a/tox.ini b/tox.ini index 67ecc4dd2..ce3b4279d 100644 --- a/tox.ini +++ b/tox.ini @@ -76,7 +76,7 @@ commands = allowlist_externals=* commands_pre = - pip uninstall -y dapr dapr-ext-grpc dapr-ext-fastapi dapr-ext-langgraph dapr-ext-strands dapr-ext-flask dapr-ext-langgraph dapr-ext-strands + pip uninstall -y dapr dapr-ext-grpc dapr-ext-fastapi dapr-ext-langgraph dapr-ext-strands dapr-ext-flask pip install -r {toxinidir}/dev-requirements.txt \ -e {toxinidir}/ \ -e {toxinidir}/ext/dapr-ext-workflow/ \ From b963b1d98e5215f7923c1a522eb1f71e81275775 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:07:44 +0200 Subject: [PATCH 09/15] Address Copilot comments (5) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/integration/conftest.py | 3 ++- tox.ini | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 858c7f6a6..551d0e6d9 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -124,7 +124,8 @@ def _wait_until( timeout: float = 10.0, interval: float = 0.1, ) -> T: - """Poll `predicate` until it returns a truthy value. eaises `TimeoutError` if it never does.""" + """Poll `predicate` until it returns a truthy value. + Raises `TimeoutError` if it never returns.""" deadline = time.monotonic() + timeout while True: result = predicate() diff --git a/tox.ini b/tox.ini index ce3b4279d..ca2a5c66f 100644 --- a/tox.ini +++ b/tox.ini @@ -76,7 +76,7 @@ commands = allowlist_externals=* commands_pre = - pip uninstall -y dapr dapr-ext-grpc dapr-ext-fastapi dapr-ext-langgraph dapr-ext-strands dapr-ext-flask + pip uninstall -y dapr dapr-ext-grpc dapr-ext-fastapi dapr-ext-langgraph dapr-ext-strands flask_dapr pip install -r {toxinidir}/dev-requirements.txt \ -e {toxinidir}/ \ -e {toxinidir}/ext/dapr-ext-workflow/ \ From 341fccb4966d4dd6d82a1e3406cef0e47eb374cc Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:28:52 +0200 Subject: [PATCH 10/15] Update README to include both test suites Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 441c37e5b..f47160556 100644 --- a/README.md +++ b/README.md @@ -133,11 +133,11 @@ tox -e integration tox -e examples ``` -If you need to run the examples against a pre-released version of the runtime, you can use the following command: +If you need to run the examples or integration tests against a pre-released version of the runtime, you can use the following command: - Get your daprd runtime binary from [here](https://github.com/dapr/dapr/releases) for your platform. - Copy the binary to your dapr home folder at $HOME/.dapr/bin/daprd. Or using dapr cli directly: `dapr init --runtime-version ` -- Now you can run the examples with `tox -e integration`. +- Now you can run the examples with `tox -e examples` or the integration tests with `tox -e integration`. ## Documentation From 56a780d6b8d7464cce51d9fc77a7da9f82dbcbb6 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:01:39 +0200 Subject: [PATCH 11/15] Document wait_until() in AGENTS.md Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/integration/AGENTS.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/integration/AGENTS.md b/tests/integration/AGENTS.md index bb391a418..2f40750f8 100644 --- a/tests/integration/AGENTS.md +++ b/tests/integration/AGENTS.md @@ -43,13 +43,14 @@ tests/integration/ ## Fixtures -All fixtures are **module-scoped** — one sidecar per test file. - -| Fixture | Type | Description | -|---------|------|-------------| -| `dapr_env` | `DaprTestEnvironment` | Manages sidecar lifecycle; call `start_sidecar()` to get a client | -| `apps_dir` | `Path` | Path to `tests/integration/apps/` | -| `components_dir` | `Path` | Path to `tests/integration/components/` | +Sidecar and client fixtures are **module-scoped** — one sidecar per test file. Helper fixtures may use a different scope; see the table below. + +| Fixture | Scope | Type | Description | +|---------|-------|------|-------------| +| `dapr_env` | module | `DaprTestEnvironment` | Manages sidecar lifecycle; call `start_sidecar()` to get a client | +| `apps_dir` | module | `Path` | Path to `tests/integration/apps/` | +| `components_dir` | module | `Path` | Path to `tests/integration/components/` | +| `wait_until` | function | `Callable` | Polling helper `(predicate, timeout=10, interval=0.1)` for eventual-consistency assertions | Each test file defines its own module-scoped `client` fixture that calls `dapr_env.start_sidecar(...)`. From e7db3977a545c9ab39301a9aec1d0e7c14ff0190 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:38:31 +0200 Subject: [PATCH 12/15] Update CLAUDE.md Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/integration/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 551d0e6d9..faca00341 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -105,6 +105,7 @@ def cleanup(self) -> None: for client in self._clients: client.close() self._clients.clear() + for proc in self._processes: if proc.poll() is None: proc.terminate() @@ -114,6 +115,7 @@ def cleanup(self) -> None: proc.kill() proc.wait() self._processes.clear() + for log_path in self._log_files: log_path.unlink(missing_ok=True) self._log_files.clear() From 0f1b9917a1043410b306c144bde2d6be3d097ae3 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:05:10 +0200 Subject: [PATCH 13/15] Fix package name Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index ca2a5c66f..4d448210e 100644 --- a/tox.ini +++ b/tox.ini @@ -76,7 +76,7 @@ commands = allowlist_externals=* commands_pre = - pip uninstall -y dapr dapr-ext-grpc dapr-ext-fastapi dapr-ext-langgraph dapr-ext-strands flask_dapr + pip uninstall -y dapr dapr-ext-grpc dapr-ext-fastapi dapr-ext-langgraph dapr-ext-strands flask-dapr pip install -r {toxinidir}/dev-requirements.txt \ -e {toxinidir}/ \ -e {toxinidir}/ext/dapr-ext-workflow/ \ From 1d128d763ceace31052aa0dfcc78d14feceb2c63 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:27:00 +0200 Subject: [PATCH 14/15] Clean up entire process group Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- ...{validate_examples.yaml => run-tests.yaml} | 0 tests/integration/conftest.py | 33 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) rename .github/workflows/{validate_examples.yaml => run-tests.yaml} (100%) diff --git a/.github/workflows/validate_examples.yaml b/.github/workflows/run-tests.yaml similarity index 100% rename from .github/workflows/validate_examples.yaml rename to .github/workflows/run-tests.yaml diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index faca00341..8af2c92ee 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,5 +1,8 @@ +import os import shlex +import signal import subprocess +import sys import tempfile import time from contextlib import contextmanager @@ -19,6 +22,31 @@ APPS_DIR = INTEGRATION_DIR / 'apps' +def _new_process_group_kwargs() -> dict[str, Any]: + """Popen kwargs that place the child at the head of its own process group. + + ``dapr run`` spawns ``daprd`` and the user's app as siblings; signaling + only the immediate process can orphan them if the signal isn't forwarded, + which leaves stale listeners on the test ports across runs. Putting the + whole subtree in its own group lets cleanup take them all down together. + """ + if sys.platform == 'win32': + return {'creationflags': subprocess.CREATE_NEW_PROCESS_GROUP} + return {'start_new_session': True} + + +def _terminate_process_group(proc: subprocess.Popen[str], *, force: bool = False) -> None: + """Sends the right termination signal to an entire process group.""" + if sys.platform == 'win32': + if force: + proc.kill() + else: + proc.send_signal(signal.CTRL_BREAK_EVENT) + else: + cleanup_signal_unix = signal.SIGKILL if force else signal.SIGTERM + os.killpg(os.getpgid(proc.pid), cleanup_signal_unix) + + class DaprTestEnvironment: """Manages Dapr sidecars and returns SDK clients for programmatic testing. @@ -80,6 +108,7 @@ def start_sidecar( stdout=log, stderr=subprocess.STDOUT, text=True, + **_new_process_group_kwargs(), ) self._processes.append(proc) @@ -108,11 +137,11 @@ def cleanup(self) -> None: for proc in self._processes: if proc.poll() is None: - proc.terminate() + _terminate_process_group(proc) try: proc.wait(timeout=10) except subprocess.TimeoutExpired: - proc.kill() + _terminate_process_group(proc, force=True) proc.wait() self._processes.clear() From c67aea2c920a1314dad757af8c3ee251ef2bc746 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:53:47 +0200 Subject: [PATCH 15/15] PR cleanup (1) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/integration/conftest.py | 43 ++++------------------------------- 1 file changed, 5 insertions(+), 38 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 8af2c92ee..554ef918d 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,8 +1,5 @@ -import os import shlex -import signal import subprocess -import sys import tempfile import time from contextlib import contextmanager @@ -14,6 +11,7 @@ from dapr.clients import DaprClient from dapr.conf import settings +from tests._process_utils import get_kwargs_for_process_group, terminate_process_group T = TypeVar('T') @@ -22,31 +20,6 @@ APPS_DIR = INTEGRATION_DIR / 'apps' -def _new_process_group_kwargs() -> dict[str, Any]: - """Popen kwargs that place the child at the head of its own process group. - - ``dapr run`` spawns ``daprd`` and the user's app as siblings; signaling - only the immediate process can orphan them if the signal isn't forwarded, - which leaves stale listeners on the test ports across runs. Putting the - whole subtree in its own group lets cleanup take them all down together. - """ - if sys.platform == 'win32': - return {'creationflags': subprocess.CREATE_NEW_PROCESS_GROUP} - return {'start_new_session': True} - - -def _terminate_process_group(proc: subprocess.Popen[str], *, force: bool = False) -> None: - """Sends the right termination signal to an entire process group.""" - if sys.platform == 'win32': - if force: - proc.kill() - else: - proc.send_signal(signal.CTRL_BREAK_EVENT) - else: - cleanup_signal_unix = signal.SIGKILL if force else signal.SIGTERM - os.killpg(os.getpgid(proc.pid), cleanup_signal_unix) - - class DaprTestEnvironment: """Manages Dapr sidecars and returns SDK clients for programmatic testing. @@ -57,7 +30,6 @@ class returns real DaprClient instances so tests can make assertions against SDK def __init__(self, default_components: Path = COMPONENTS_DIR) -> None: self._default_components = default_components self._processes: list[subprocess.Popen[str]] = [] - self._log_files: list[Path] = [] self._clients: list[DaprClient] = [] def start_sidecar( @@ -100,15 +72,14 @@ def start_sidecar( if app_cmd is not None: cmd.extend(['--', *shlex.split(app_cmd)]) - with tempfile.NamedTemporaryFile(mode='w', suffix=f'-{app_id}.log', delete=False) as log: - self._log_files.append(Path(log.name)) + with tempfile.NamedTemporaryFile(mode='w', suffix=f'-{app_id}.log') as log: proc = subprocess.Popen( cmd, cwd=INTEGRATION_DIR, stdout=log, stderr=subprocess.STDOUT, text=True, - **_new_process_group_kwargs(), + **get_kwargs_for_process_group(), ) self._processes.append(proc) @@ -137,18 +108,14 @@ def cleanup(self) -> None: for proc in self._processes: if proc.poll() is None: - _terminate_process_group(proc) + terminate_process_group(proc) try: proc.wait(timeout=10) except subprocess.TimeoutExpired: - _terminate_process_group(proc, force=True) + terminate_process_group(proc, force=True) proc.wait() self._processes.clear() - for log_path in self._log_files: - log_path.unlink(missing_ok=True) - self._log_files.clear() - def _wait_until( predicate: Callable[[], T | None],