Skip to content

Commit e401e5c

Browse files
sehervdependabot[bot]sicoyle
authored
Integration tests on DaprClient responses (#981)
* Update pyyaml requirement from >=6.0.2 to >=6.0.3 (#985) Updates the requirements on [pyyaml](https://github.com/yaml/pyyaml) to permit the latest version. - [Release notes](https://github.com/yaml/pyyaml/releases) - [Changelog](https://github.com/yaml/pyyaml/blob/6.0.3/CHANGES) - [Commits](yaml/pyyaml@6.0.2...6.0.3) --- updated-dependencies: - dependency-name: pyyaml dependency-version: 6.0.3 dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Sam <sam@diagrid.io> Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Move old integration tests to examples/ Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Test DaprClient directly Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Update docs to new test structure Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Address Copilot comments (1) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Address Copilot comments (2) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Address Copilot comments (3) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Replace sleep() with polls when possible Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Address Copilot comments (4) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Address Copilot comments (5) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Update README to include both test suites Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Document wait_until() in AGENTS.md Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Update CLAUDE.md Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Fix package name Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Clean up entire process group Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * PR cleanup (1) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Fix possible race running example test Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Assert on pubsub smoke test Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Revert "Fix possible race running example test" This reverts commit df5b0fc. Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Address PR feedback (2) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Update pydantic requirement from >=2.0.0 to >=2.13.3 (#987) Updates the requirements on [pydantic](https://github.com/pydantic/pydantic) to permit the latest version. - [Release notes](https://github.com/pydantic/pydantic/releases) - [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md) - [Commits](pydantic/pydantic@v2.0...v2.13.3) --- updated-dependencies: - dependency-name: pydantic dependency-version: 2.13.3 dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Rename all appareances of components to resources Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> * Rename components/ to resources/ Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Sam <sam@diagrid.io>
1 parent bfd8dbd commit e401e5c

45 files changed

Lines changed: 1141 additions & 259 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,5 +104,8 @@ jobs:
104104
sleep 10
105105
ollama pull llama3.2:latest
106106
- name: Check examples
107+
run: |
108+
tox -e examples
109+
- name: Run integration tests
107110
run: |
108111
tox -e integration

AGENTS.md

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ ext/ # Extension packages (each is a separate PyPI packa
3333
└── flask_dapr/ # Flask integration ← see ext/flask_dapr/AGENTS.md
3434
3535
tests/ # Unit tests (mirrors dapr/ package structure)
36-
examples/ # Integration test suite ← see examples/AGENTS.md
36+
├── examples/ # Output-based tests that run examples and check stdout
37+
├── integration/ # Programmatic SDK tests using DaprClient directly
38+
examples/ # User-facing example applications ← see examples/AGENTS.md
3739
docs/ # Sphinx documentation source
3840
tools/ # Build and release scripts
3941
```
@@ -59,16 +61,19 @@ Each extension is a **separate PyPI package** with its own `setup.cfg`, `setup.p
5961
| `dapr-ext-langgraph` | `dapr.ext.langgraph` | LangGraph checkpoint persistence to Dapr state store | Moderate |
6062
| `dapr-ext-strands` | `dapr.ext.strands` | Strands agent session management via Dapr state store | New |
6163

62-
## Examples (integration test suite)
64+
## Examples and testing
6365

64-
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/`.
66+
The `examples/` directory contains user-facing example applications. These are validated by two test suites:
6567

66-
**See `examples/AGENTS.md`** for the full guide on example structure and how to add new examples.
68+
- **`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`.
69+
- **`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`.
6770

6871
Quick reference:
6972
```bash
70-
tox -e integration # Run all examples (needs Dapr runtime)
71-
tox -e integration -- test_state_store.py # Run a single example
73+
tox -e examples # Run output-based example tests
74+
tox -e examples -- test_state_store.py # Run a single example test
75+
tox -e integration # Run programmatic SDK tests
76+
tox -e integration -- test_state_store.py # Run a single integration test
7277
```
7378

7479
## Python version support
@@ -106,7 +111,10 @@ tox -e ruff
106111
# Run type checking
107112
tox -e type
108113

109-
# Run integration tests / validate examples (requires Dapr runtime)
114+
# Run output-based example tests (requires Dapr runtime)
115+
tox -e examples
116+
117+
# Run programmatic integration tests (requires Dapr runtime)
110118
tox -e integration
111119
```
112120

@@ -189,8 +197,8 @@ When completing any task on this project, work through this checklist. Not every
189197
### Examples (integration tests)
190198

191199
- [ ] If you added a new user-facing feature or building block, add or update an example in `examples/`
192-
- [ ] Add a corresponding pytest integration test in `tests/integration/`
193-
- [ ] If you changed output format of existing functionality, update expected output in the affected integration tests
200+
- [ ] Add a corresponding pytest test in `tests/examples/` (output-based) and/or `tests/integration/` (programmatic)
201+
- [ ] If you changed output format of existing functionality, update expected output in `tests/examples/`
194202
- [ ] See `examples/AGENTS.md` for full details on writing examples
195203

196204
### Documentation
@@ -202,7 +210,7 @@ When completing any task on this project, work through this checklist. Not every
202210

203211
- [ ] Run `tox -e ruff` — linting must be clean
204212
- [ ] Run `tox -e py311` (or your Python version) — all unit tests must pass
205-
- [ ] If you touched examples: `tox -e integration -- test_<example-name>.py` to validate locally
213+
- [ ] If you touched examples: `tox -e examples -- test_<example-name>.py` to validate locally
206214
- [ ] Commits must be signed off for DCO: `git commit -s`
207215

208216
## Important files
@@ -217,7 +225,8 @@ When completing any task on this project, work through this checklist. Not every
217225
| `dev-requirements.txt` | Development/test dependencies |
218226
| `dapr/version/__init__.py` | SDK version string |
219227
| `ext/*/setup.cfg` | Extension package metadata and dependencies |
220-
| `tests/integration/` | Pytest-based integration tests that validate examples |
228+
| `tests/examples/` | Output-based tests that validate examples by checking stdout |
229+
| `tests/integration/` | Programmatic SDK tests using DaprClient directly |
221230

222231
## Gotchas
223232

@@ -226,6 +235,6 @@ When completing any task on this project, work through this checklist. Not every
226235
- **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.
227236
- **DCO signoff**: PRs will be blocked by the DCO bot if commits lack `Signed-off-by`. Always use `git commit -s`.
228237
- **Ruff version pinned**: Dev requirements pin `ruff === 0.14.1`. Use this exact version to match CI.
229-
- **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.
238+
- **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.
230239
- **Background processes in examples**: Examples that start background services (servers, subscribers) must include a cleanup step to stop them, or CI will hang.
231240
- **Workflow is the most active area**: See `ext/dapr-ext-workflow/AGENTS.md` for workflow-specific architecture and constraints.

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,17 +121,23 @@ tox -e py311
121121
tox -e type
122122
```
123123

124-
8. Run integration tests (validates the examples)
124+
8. Run integration tests
125125

126126
```bash
127127
tox -e integration
128128
```
129129

130-
If you need to run the examples against a pre-released version of the runtime, you can use the following command:
130+
9. Validate the examples
131+
132+
```bash
133+
tox -e examples
134+
```
135+
136+
If you need to run the examples or integration tests against a pre-released version of the runtime, you can use the following command:
131137
- Get your daprd runtime binary from [here](https://github.com/dapr/dapr/releases) for your platform.
132138
- Copy the binary to your dapr home folder at $HOME/.dapr/bin/daprd.
133139
Or using dapr cli directly: `dapr init --runtime-version <release version>`
134-
- Now you can run the examples with `tox -e integration`.
140+
- Now you can run the examples with `tox -e examples` or the integration tests with `tox -e integration`.
135141

136142

137143
## Documentation

examples/AGENTS.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
# AGENTS.md — Dapr Python SDK Examples
22

3-
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/`.
3+
The `examples/` directory serves as the **user-facing documentation**. Each example is a self-contained application validated by pytest-based tests in `tests/examples/`.
44

55
## How validation works
66

7-
1. Each example has a corresponding test file in `tests/integration/` (e.g., `test_state_store.py`)
8-
2. Tests use a `DaprRunner` helper (defined in `conftest.py`) that wraps `dapr run` commands
7+
1. Each example has a corresponding test file in `tests/examples/` (e.g., `test_state_store.py`)
8+
2. Tests use a `DaprRunner` helper (defined in `tests/examples/conftest.py`) that wraps `dapr run` commands
99
3. `DaprRunner.run()` executes a command and captures stdout; `DaprRunner.start()`/`stop()` manage background services
1010
4. Tests assert that expected output lines appear in the captured output
1111

1212
Run examples locally (requires a running Dapr runtime via `dapr init`):
1313

1414
```bash
1515
# All examples
16-
tox -e integration
16+
tox -e examples
1717

1818
# Single example
19-
tox -e integration -- test_state_store.py
19+
tox -e examples -- test_state_store.py
2020
```
2121

2222
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.
@@ -132,17 +132,17 @@ The `workflow` example includes: `simple.py`, `task_chaining.py`, `fan_out_fan_i
132132
2. Add Python source files and a `requirements.txt` referencing the needed SDK packages
133133
3. Add Dapr component YAMLs in a `components/` subdirectory if the example uses state, pubsub, etc.
134134
4. Write a `README.md` with introduction, pre-requisites, install instructions, and running instructions
135-
5. Add a corresponding test in `tests/integration/test_<example_name>.py`:
135+
5. Add a corresponding test in `tests/examples/test_<example_name>.py`:
136136
- Use the `@pytest.mark.example_dir('<example-name>')` marker to set the working directory
137137
- Use `dapr.run()` for scripts that exit on their own, `dapr.start()`/`dapr.stop()` for long-running services
138138
- Assert expected output lines appear in the captured output
139-
6. Test locally: `tox -e integration -- test_<example_name>.py`
139+
6. Test locally: `tox -e examples -- test_<example_name>.py`
140140

141141
## Gotchas
142142

143-
- **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.
143+
- **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.
144144
- **Background processes must be cleaned up**: The `DaprRunner` fixture handles cleanup on teardown, but tests should still call `dapr.stop()` to capture output.
145145
- **Dapr prefixes output**: Application stdout appears as `== APP == <line>` when run via `dapr run`.
146146
- **Redis is available in CI**: The CI environment has Redis running on `localhost:6379` — most component YAMLs use this.
147147
- **Some examples need special setup**: `crypto` generates keys, `configuration` seeds Redis, `conversation` needs LLM config — check individual READMEs.
148-
- **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.
148+
- **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.

tests/examples/conftest.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import shlex
2+
import subprocess
3+
import tempfile
4+
import threading
5+
import time
6+
from pathlib import Path
7+
from typing import IO, Any, Generator
8+
9+
import pytest
10+
11+
from tests._process_utils import get_kwargs_for_process_group, terminate_process_group
12+
13+
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
14+
EXAMPLES_DIR = REPO_ROOT / 'examples'
15+
16+
17+
def pytest_configure(config: pytest.Config) -> None:
18+
config.addinivalue_line('markers', 'example_dir(name): set the example directory for a test')
19+
20+
21+
class DaprRunner:
22+
"""Helper to run `dapr run` commands and capture output."""
23+
24+
def __init__(self, cwd: Path) -> None:
25+
self._cwd = cwd
26+
self._bg_process: subprocess.Popen[str] | None = None
27+
self._bg_output_file: IO[str] | None = None
28+
29+
@staticmethod
30+
def _terminate(proc: subprocess.Popen[str]) -> None:
31+
if proc.poll() is not None:
32+
return
33+
34+
terminate_process_group(proc)
35+
try:
36+
proc.wait(timeout=10)
37+
except subprocess.TimeoutExpired:
38+
terminate_process_group(proc, force=True)
39+
proc.wait()
40+
41+
def run(self, args: str, *, timeout: int = 30, until: list[str] | None = None) -> str:
42+
"""Run a foreground command, block until it finishes, and return output.
43+
44+
Use this for short-lived processes (e.g. a publisher that exits on its
45+
own). For long-lived background services, use ``start()``/``stop()``.
46+
47+
Args:
48+
args: Arguments passed to ``dapr run``.
49+
timeout: Maximum seconds to wait before killing the process.
50+
until: If provided, the process is terminated as soon as every
51+
string in this list has appeared in the accumulated output.
52+
"""
53+
proc = subprocess.Popen(
54+
args=('dapr', 'run', *shlex.split(args)),
55+
cwd=self._cwd,
56+
stdout=subprocess.PIPE,
57+
stderr=subprocess.STDOUT,
58+
text=True,
59+
**get_kwargs_for_process_group(),
60+
)
61+
lines: list[str] = []
62+
assert proc.stdout is not None
63+
64+
# Kill the process if it exceeds the timeout. A background timer is
65+
# needed because `for line in proc.stdout` blocks indefinitely when
66+
# the child never exits.
67+
timer = threading.Timer(
68+
interval=timeout, function=lambda: terminate_process_group(proc, force=True)
69+
)
70+
timer.start()
71+
72+
try:
73+
for line in proc.stdout:
74+
print(line, end='', flush=True)
75+
lines.append(line)
76+
if until and all(exp in ''.join(lines) for exp in until):
77+
break
78+
finally:
79+
timer.cancel()
80+
self._terminate(proc)
81+
82+
return ''.join(lines)
83+
84+
def start(self, args: str, *, wait: int = 5) -> None:
85+
"""Start a long-lived background service.
86+
87+
Use this for servers/subscribers that must stay alive while a second
88+
process runs via ``run()``. Call ``stop()`` to terminate and collect
89+
output. Stdout is written to a temp file to avoid pipe-buffer deadlocks.
90+
"""
91+
output_file = tempfile.NamedTemporaryFile(mode='w+', suffix='.log')
92+
proc = subprocess.Popen(
93+
args=('dapr', 'run', *shlex.split(args)),
94+
cwd=self._cwd,
95+
stdout=output_file,
96+
stderr=subprocess.STDOUT,
97+
text=True,
98+
**get_kwargs_for_process_group(),
99+
)
100+
self._bg_process = proc
101+
self._bg_output_file = output_file
102+
time.sleep(wait)
103+
104+
def stop(self) -> str:
105+
"""Stop the background service and return its captured output."""
106+
if self._bg_process is None:
107+
return ''
108+
self._terminate(self._bg_process)
109+
self._bg_process = None
110+
return self._read_and_close_output()
111+
112+
def _read_and_close_output(self) -> str:
113+
if self._bg_output_file is None:
114+
return ''
115+
self._bg_output_file.seek(0)
116+
output = self._bg_output_file.read()
117+
self._bg_output_file.close()
118+
self._bg_output_file = None
119+
print(output, end='', flush=True)
120+
return output
121+
122+
123+
@pytest.fixture
124+
def dapr(request: pytest.FixtureRequest) -> Generator[DaprRunner, Any, None]:
125+
"""Provides a DaprRunner scoped to an example directory.
126+
127+
Use the ``example_dir`` marker to select which example:
128+
129+
@pytest.mark.example_dir('state_store')
130+
def test_something(dapr):
131+
...
132+
133+
Defaults to the examples root if no marker is set.
134+
"""
135+
marker = request.node.get_closest_marker('example_dir')
136+
cwd = EXAMPLES_DIR / marker.args[0] if marker else EXAMPLES_DIR
137+
138+
runner = DaprRunner(cwd)
139+
yield runner
140+
runner.stop()
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import subprocess
2+
import time
3+
4+
import pytest
5+
6+
REDIS_CONTAINER = 'dapr_redis'
7+
8+
EXPECTED_LINES = [
9+
'Got key=orderId1 value=100 version=1 metadata={}',
10+
'Got key=orderId2 value=200 version=1 metadata={}',
11+
'Subscribe key=orderId2 value=210 version=2 metadata={}',
12+
'Unsubscribed successfully? True',
13+
]
14+
15+
16+
@pytest.fixture()
17+
def redis_config():
18+
"""Seed configuration values in Redis before the test."""
19+
subprocess.run(
20+
('docker', 'exec', 'dapr_redis', 'redis-cli', 'SET', 'orderId1', '100||1'),
21+
check=True,
22+
capture_output=True,
23+
)
24+
subprocess.run(
25+
('docker', 'exec', 'dapr_redis', 'redis-cli', 'SET', 'orderId2', '200||1'),
26+
check=True,
27+
capture_output=True,
28+
)
29+
30+
31+
@pytest.mark.example_dir('configuration')
32+
def test_configuration(dapr, redis_config):
33+
dapr.start(
34+
'--app-id configexample --resources-path components/ -- python3 configuration.py',
35+
wait=5,
36+
)
37+
# Update Redis to trigger the subscription notification
38+
subprocess.run(
39+
('docker', 'exec', 'dapr_redis', 'redis-cli', 'SET', 'orderId2', '210||2'),
40+
check=True,
41+
capture_output=True,
42+
)
43+
# configuration.py sleeps 10s after subscribing before it unsubscribes.
44+
# Wait long enough for the full script to finish.
45+
time.sleep(10)
46+
47+
output = dapr.stop()
48+
for line in EXPECTED_LINES:
49+
assert line in output, f'Missing in output: {line}'
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import pytest
2+
3+
EXPECTED_LINES = [
4+
'Will try to acquire a lock from lock store named [lockstore]',
5+
'The lock is for a resource named [example-lock-resource]',
6+
'The client identifier is [example-client-id]',
7+
'The lock will expire in 60 seconds.',
8+
'Lock acquired successfully!!!',
9+
'We already released the lock so unlocking will not work.',
10+
'We tried to unlock it anyway and got back [UnlockResponseStatus.lock_does_not_exist]',
11+
]
12+
13+
14+
@pytest.mark.example_dir('distributed_lock')
15+
def test_distributed_lock(dapr):
16+
output = dapr.run(
17+
'--app-id=locksapp --app-protocol grpc --resources-path components/ -- python3 lock.py',
18+
timeout=10,
19+
)
20+
for line in EXPECTED_LINES:
21+
assert line in output, f'Missing in output: {line}'

0 commit comments

Comments
 (0)