Skip to content

Commit e204f9f

Browse files
refactor: update BDD testing documentation and enhance async step implementations
- Clarified the usage of the Behave BDD framework, specifying version 1.3.3. - Added details on the directory structure for BDD tests, including new files for global setup and shared utilities. - Updated async step implementations to use `await` instead of `asyncio.run()`, ensuring proper async handling. - Removed deprecated utility functions from `test_helpers.py` to streamline the codebase. - Adjusted linting exclusions and documentation for better clarity and organization.
1 parent b3b41de commit e204f9f

7 files changed

Lines changed: 42 additions & 122 deletions

File tree

.cursor/rules/testing-bdd.mdc

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,37 +6,32 @@ alwaysApply: false
66

77
# Testing with BDD (Behave)
88

9-
ArchiPy uses the **Behave** BDD framework. Tests are written as Gherkin scenarios.
9+
ArchiPy uses the **Behave** BDD framework (v1.3.3). Tests are written as Gherkin scenarios.
1010

1111
## Directory Structure
1212

1313
```
1414
features/
15-
├── *.feature # Gherkin scenario files
15+
├── *.feature # Gherkin scenario files
16+
├── environment.py # Global setup/teardown hooks
17+
├── scenario_context.py # Per-scenario state container
18+
├── test_helpers.py # Shared async/sync utilities
1619
└── steps/
17-
└── *.py # Step implementations (Given/When/Then)
20+
└── *.py # Step implementations (Given/When/Then)
1821
```
1922

2023
## Running Tests
2124

2225
```bash
23-
# All tests
2426
make behave
25-
# or:
26-
uv run --extra behave behave
27-
28-
# Single feature file
2927
uv run --extra behave behave features/file_name.feature
30-
31-
# Specific scenario (by line number)
3228
uv run --extra behave behave features/file_name.feature:42
3329
```
3430

3531
## Writing Feature Files
3632

3733
- Keep `.feature` files **declarative** — describe behavior, not implementation.
3834
- Use `@tags` to categorize scenarios for selective runs.
39-
- Avoid logic in `.feature` files; all logic belongs in `features/steps/`.
4035

4136
```gherkin
4237
# ✅ GOOD
@@ -52,29 +47,32 @@ Feature: Cache adapter
5247

5348
- Step functions are exempt from: `ANN001`, `ANN201`, `ARG001`, `PLR0913`, `F811`.
5449
- Step redefinition (`F811`) is allowed — Behave reuses steps across features.
55-
- Use `context` to share state between steps within a scenario.
56-
57-
```python
58-
# ✅ GOOD
59-
from behave import given, then, when
50+
- Use `get_current_scenario_context(context)` to share state between steps.
6051

61-
@given("a running Redis instance")
62-
def step_redis_running(context):
63-
context.redis = FakeRedisAdapter()
52+
## Async Steps
6453

65-
@when('I store "{value}" under key "{key}"')
66-
def step_store_value(context, value, key):
67-
context.redis.set(key, value)
54+
Behave 1.3.3 supports `async def` step functions **natively** — no `asyncio.run()` or `@async_run_until_complete` wrapper needed. Always declare async steps with `async def` and use `await` directly.
6855

69-
@then('I can retrieve "{value}" from key "{key}"')
70-
def step_retrieve_value(context, value, key):
71-
assert context.redis.get(key) == value
56+
```python
57+
# ❌ BAD — never use asyncio.run() inside a step
58+
@when("something async happens")
59+
def step_impl(context):
60+
result = asyncio.run(some_async_call())
61+
62+
# ✅ GOOD — declare the step as async def and await directly
63+
@when("something async happens")
64+
async def step_impl(context):
65+
result = await some_async_call()
7266
```
7367

68+
Sync operations work fine inside `async def` steps, so steps with mixed sync/async branches (e.g. if/elif over frameworks) should be converted to `async def` entirely.
69+
70+
The only place `asyncio.run()` is acceptable is in **non-step** callbacks (e.g. `scenario_context.py` cleanup methods) that may be called from outside an event loop.
71+
7472
## Parallel Execution
7573

76-
Behave is configured to run with **8 parallel workers** (multiprocessing). Ensure steps do not share mutable global state across scenarios.
74+
Behave is configured to run with **8 parallel workers** (multiprocessing). Steps must not share mutable global state across scenarios.
7775

7876
## Linting Exclusions
7977

80-
`features/` and `scripts/` directories are excluded from Ruff linting (`exclude` in `pyproject.toml`). Do not add type annotations to step functions.
78+
`features/` and `scripts/` are excluded from Ruff linting. Do not add type annotations to step functions.

archipy/configs/config_template.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class ElasticsearchConfig(BaseModel):
4949
RETRY_ON_TIMEOUT: bool = Field(default=True, description="Retry on connection timeouts")
5050
RETRY_ON_STATUS: tuple[int, ...] = Field(default=(429, 502, 503, 504), description="HTTP status codes to retry on")
5151
IGNORE_STATUS: tuple[int, ...] = Field(default=(), description="HTTP status codes to ignore as errors")
52-
SNIFF_ON_START: bool = Field(default=True, description="Sniff nodes on client instantiation")
52+
SNIFF_ON_START: bool = Field(default=False, description="Sniff nodes on client instantiation")
5353
SNIFF_BEFORE_REQUESTS: bool = Field(default=False, description="Sniff nodes before requests")
5454
SNIFF_ON_NODE_FAILURE: bool = Field(default=True, description="Sniff nodes on node failure")
5555
MIN_DELAY_BETWEEN_SNIFFING: float = Field(

features/steps/atomic_transaction_steps.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
atomic transaction scenarios.
55
"""
66

7-
import asyncio
87
import logging
98
import os
109
import tempfile
@@ -141,7 +140,7 @@ def _get_adapter_classes(db_type: str):
141140

142141

143142
@given("the application database is initialized for {db_type}")
144-
def step_given_database_initialized(context, db_type: str):
143+
async def step_given_database_initialized(context, db_type: str):
145144
"""Initialize the database for testing with the specified database type.
146145
147146
Args:
@@ -189,7 +188,7 @@ def step_given_database_initialized(context, db_type: str):
189188

190189
# Create schema with async adapter
191190
logger.info("Creating database schema with async PostgreSQL adapter")
192-
asyncio.run(async_schema_setup(async_adapter))
191+
await async_schema_setup(async_adapter)
193192

194193
logger.info("Async PostgreSQL adapter and schema setup completed")
195194
except Exception as e:
@@ -251,7 +250,7 @@ def step_given_database_initialized(context, db_type: str):
251250

252251
# Create schema with async adapter
253252
logger.info("Creating database schema with async SQLite adapter")
254-
asyncio.run(async_schema_setup(async_adapter))
253+
await async_schema_setup(async_adapter)
255254

256255
logger.info("Async SQLite adapter and schema setup completed")
257256
except Exception as e:

features/steps/error_utils_steps.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import asyncio
21
from http import HTTPStatus
32
from unittest.mock import patch
43

@@ -78,7 +77,7 @@ def step_given_fastapi_error(context, error_type):
7877

7978

8079
@when("an async FastAPI error is handled")
81-
def step_when_fastapi_error_is_handled(context):
80+
async def step_when_fastapi_error_is_handled(context):
8281
scenario_context = get_current_scenario_context(context)
8382
fastapi_error = scenario_context.get("fastapi_error")
8483

@@ -90,7 +89,7 @@ async def handle_error():
9089
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
9190
content={"detail": "Error occurred"},
9291
)
93-
http_status = asyncio.run(handle_error()).status_code
92+
http_status = (await handle_error()).status_code
9493
scenario_context.store("http_status", http_status)
9594

9695

features/steps/grpc_error_handling_steps.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
"""Step definitions for gRPC error handling tests."""
22

3-
import asyncio
4-
53
from behave import given, then, when
64

75
from archipy.models.errors import (
@@ -116,7 +114,7 @@ def step_when_sync_grpc_raises_error(context, error_type: str):
116114

117115

118116
@when('an async gRPC method raises "{error_type}" error')
119-
def step_when_async_grpc_raises_error(context, error_type: str):
117+
async def step_when_async_grpc_raises_error(context, error_type: str):
120118
"""Test an async gRPC method that raises a specific error using real gRPC calls."""
121119
scenario_context = get_current_scenario_context(context)
122120

@@ -149,7 +147,7 @@ async def make_call():
149147

150148
return grpc_error
151149

152-
grpc_error = asyncio.run(make_call())
150+
grpc_error = await make_call()
153151

154152
scenario_context.store("grpc_error", error_instance)
155153
scenario_context.store("grpc_rpc_error", grpc_error)
@@ -195,7 +193,7 @@ def step_when_sync_grpc_invalid_request(context):
195193

196194

197195
@when("an async gRPC method receives invalid request")
198-
def step_when_async_grpc_invalid_request(context):
196+
async def step_when_async_grpc_invalid_request(context):
199197
"""Test an async gRPC method that receives invalid request using real gRPC calls."""
200198
scenario_context = get_current_scenario_context(context)
201199

@@ -220,7 +218,7 @@ async def make_call():
220218

221219
return grpc_error
222220

223-
grpc_error = asyncio.run(make_call())
221+
grpc_error = await make_call()
224222

225223
# The interceptor converts ValidationError to InvalidArgumentError
226224
from archipy.models.errors import InvalidArgumentError
@@ -277,7 +275,7 @@ def step_when_sync_grpc_unexpected_error(context):
277275

278276

279277
@when("an async gRPC method raises an unexpected exception")
280-
def step_when_async_grpc_unexpected_error(context):
278+
async def step_when_async_grpc_unexpected_error(context):
281279
"""Test an async gRPC method that raises an unexpected exception using real gRPC calls."""
282280
scenario_context = get_current_scenario_context(context)
283281

@@ -303,7 +301,7 @@ async def make_call():
303301

304302
return grpc_error
305303

306-
grpc_error = asyncio.run(make_call())
304+
grpc_error = await make_call()
307305

308306
# The interceptor converts unexpected errors to InternalError
309307
from archipy.models.errors import InternalError
@@ -521,7 +519,7 @@ def step_when_sync_grpc_raises_validation_error_with_lang(context, error_type: s
521519

522520

523521
@when('an async gRPC method raises "{error_type}" validation error with value "{invalid_value}" in language "{lang}"')
524-
def step_when_async_grpc_raises_validation_error_with_lang(context, error_type: str, invalid_value: str, lang: str):
522+
async def step_when_async_grpc_raises_validation_error_with_lang(context, error_type: str, invalid_value: str, lang: str):
525523
"""Test an async gRPC method that raises a validation error with a specific value and language."""
526524
scenario_context = get_current_scenario_context(context)
527525

@@ -564,7 +562,7 @@ async def make_call():
564562

565563
return grpc_error
566564

567-
grpc_error = asyncio.run(make_call())
565+
grpc_error = await make_call()
568566

569567
scenario_context.store("grpc_error", error_instance)
570568
scenario_context.store("grpc_rpc_error", grpc_error)

features/steps/metric_interceptor_steps.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ def step_when_metric_interceptor_setup(context, framework):
167167

168168

169169
@when("a {framework} request is made")
170-
def step_when_request_made(context, framework):
170+
async def step_when_request_made(context, framework):
171171
scenario_context = get_current_scenario_context(context)
172172

173173
if framework == "FastAPI":
@@ -226,8 +226,6 @@ def mock_method(request, context):
226226
scenario_context.store("final_active_requests", final_active)
227227

228228
elif framework == "AsyncgRPC":
229-
import asyncio
230-
231229
interceptors = scenario_context.get("interceptors")
232230

233231
if not interceptors or not isinstance(interceptors[0], AsyncGrpcServerMetricInterceptor):
@@ -255,7 +253,7 @@ async def mock_method(request, context):
255253
)
256254

257255
# Run the async interceptor
258-
result = asyncio.run(interceptor.intercept(mock_method, {}, mock_context, method_name_model))
256+
result = await interceptor.intercept(mock_method, {}, mock_context, method_name_model)
259257
scenario_context.store("result", result)
260258

261259
final_metrics = _get_metric_samples("grpc_async_response_time_seconds")

features/test_helpers.py

Lines changed: 1 addition & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
"""This module provides utilities to help with async testing in behave.
2-
3-
It focuses on solving the problem of SQLAlchemy async scoped sessions
4-
using current_task() for scoping, which can cause issues in behave tests.
5-
"""
1+
"""Shared utilities for Behave BDD step implementations."""
62

73
from archipy.models.entities import BaseEntity
84

@@ -58,74 +54,6 @@ def get_async_adapter(context):
5854
return async_adapter
5955

6056

61-
def safe_has_attr(obj, attr):
62-
"""A truly safe way to check if an attribute exists on an object.
63-
64-
This uses the __dict__ directly rather than hasattr() which can
65-
trigger __getattr__ and raise exceptions.
66-
67-
Args:
68-
obj: The object to check
69-
attr: The attribute name to check for
70-
71-
Returns:
72-
bool: True if the attribute exists, False otherwise
73-
"""
74-
# For Context objects in behave, check the dictionary directly
75-
if hasattr(obj, "__dict__"):
76-
return attr in obj.__dict__
77-
78-
# Fallback to normal hasattr for other objects
79-
try:
80-
return hasattr(obj, attr)
81-
except:
82-
return False
83-
84-
85-
def safe_get_attr(obj, attr, default=None):
86-
"""A truly safe way to get an attribute from an object.
87-
88-
This uses the __dict__ directly rather than getattr() which can
89-
trigger __getattr__ and raise exceptions.
90-
91-
Args:
92-
obj: The object to get the attribute from
93-
attr: The attribute name to get
94-
default: The default value to return if the attribute doesn't exist
95-
96-
Returns:
97-
The attribute value, or the default if it doesn't exist
98-
"""
99-
# For Context objects in behave, check the dictionary directly
100-
if hasattr(obj, "__dict__"):
101-
return obj.__dict__.get(attr, default)
102-
103-
# Fallback to normal getattr for other objects
104-
try:
105-
return getattr(obj, attr, default)
106-
except:
107-
return default
108-
109-
110-
def safe_set_attr(obj, attr, value):
111-
"""A truly safe way to set an attribute on an object.
112-
113-
This uses the __dict__ directly rather than setattr() which can
114-
trigger __getattr__ and raise exceptions.
115-
116-
Args:
117-
obj: The object to set the attribute on
118-
attr: The attribute name to set
119-
value: The value to set
120-
"""
121-
# For Context objects in behave, set the dictionary directly
122-
if hasattr(obj, "__dict__"):
123-
obj.__dict__[attr] = value
124-
else:
125-
# Fallback to normal setattr for other objects
126-
setattr(obj, attr, value)
127-
128-
12957
async def async_schema_setup(async_adapter):
13058
"""Set up database schema for async adapter."""
13159
# Use AsyncEngine.begin() for proper transaction handling

0 commit comments

Comments
 (0)