Skip to content

Commit 7c9a0b1

Browse files
authored
fix: background steps, rglob scan, __init__.py, lowercased function names (v0.4.0) (#17)
Four bug fixes discovered during pytest-beehave e2e integration: 1. ScenarioInfo.steps now includes background steps (was only scenario steps) 2. check_all uses rglob() to find features in subdirectories 3. generate_stubs creates __init__.py in generated directories 4. Function names are lowercased, matching path slug behavior Adds 9 new tests. Updates spec and README to document lowercasing.
1 parent ca3ae9c commit 7c9a0b1

10 files changed

Lines changed: 135 additions & 9 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ Warnings exit 0. Errors exit 1. Stubs (bodies with only `pass` or `...`) skip bo
192192

193193
## How it maps
194194

195-
- **Scenario title → function name:** `Honey Production From Nectar``test_honey_production_from_nectar`. Globally unique across all features.
195+
- **Scenario title → function name:** `Honey Production From Nectar``test_honey_production_from_nectar`. Lowercased. Globally unique across all features.
196196
- **Rule → test file:** Top-level scenarios go to `default_test.py`. Scenarios inside a Rule go to `<rule>_test.py`.
197197
- **Feature title → directory:** `Hive Activity``tests/features/hive_activity/`.
198198
- **Strategy inference:** Examples table column values are typed — all integers → `st.integers()`, all floats → `st.floats()`, all booleans → `st.booleans()`, else → `st.text()`.

beehave/check.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ def check_all(config: Config) -> list[Violation]:
230230
feature_paths: dict[str, Path] = {}
231231

232232
seen_fn: dict[str, str] = {}
233-
for feature_file in sorted(features_dir.glob("*.feature")):
233+
for feature_file in sorted(features_dir.rglob("*.feature")):
234234
try:
235235
scenarios = parse_feature(feature_file, config, seen_function_names=seen_fn)
236236
except GherkinError as e:

beehave/generate.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,9 @@ def _write_file(
173173
config: Config,
174174
) -> None:
175175
test_file.parent.mkdir(parents=True, exist_ok=True)
176+
init_file = test_file.parent / "__init__.py"
177+
if not init_file.exists():
178+
init_file.touch()
176179

177180
existing_functions: set[str] = set()
178181
existing_strategies: set[str] = set()

beehave/gherkin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def _derive_path_slug(title: str) -> str:
4343

4444
def _derive_function_name(title: str) -> str:
4545
trimmed = title.strip()
46-
collapsed = re.sub(r"\s+", "_", trimmed)
46+
collapsed = re.sub(r"\s+", "_", trimmed).lower()
4747
name = f"test_{collapsed}"
4848
if not name.isidentifier():
4949
raise GherkinError(
@@ -192,7 +192,7 @@ def _build_scenario(
192192
return ScenarioInfo(
193193
title=title,
194194
function_name=function_name,
195-
steps=tuple(steps),
195+
steps=tuple(merged),
196196
placeholders=_collect_placeholders(merged),
197197
literals=_collect_literals(
198198
steps, feature_bg + rule_bg, check_numeric, check_string

docs/spec/v3/beehave_v3_spec.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,14 @@ Every `Scenario` and `Scenario Outline` maps to exactly one test function. The f
6666
1. **Trim** leading and trailing whitespace.
6767
2. **Collapse** consecutive internal spaces to a single space.
6868
3. **Replace** each space with an underscore (`_`).
69-
4. **Prepend** `test_`.
70-
5. **Validate** that the result is a valid Python identifier (`str.isidentifier()` returns `True`). If not, raise a parse error.
69+
4. **Lowercase** the result.
70+
5. **Prepend** `test_`.
71+
6. **Validate** that the result is a valid Python identifier (`str.isidentifier()` returns `True`). If not, raise a parse error.
7172

7273
```
7374
Scenario: deposit increases balance → test_deposit_increases_balance
7475
Scenario: extra spaces here → test_extra_spaces_here
76+
Scenario: Add Single Item → test_add_single_item
7577
```
7678

7779
No `@scenario` decorator. No `@id` tags. No cache file. At collection time, beehave re-parses all `.feature` files and AST-parses all test files, then joins on function name.
@@ -80,7 +82,7 @@ No `@scenario` decorator. No `@id` tags. No cache file. At collection time, beeh
8082

8183
- **Characters:** Unicode letters, digits, and spaces only. Applies to Scenario, Scenario Outline, Feature, and Rule titles equally. Special characters would break generated file paths or Python identifiers.
8284
- **Non-empty:** The title must be non-empty after trimming.
83-
- **Scenario titles:** Globally unique across all features. Two scenario titles that collapse to the same function name produce a parse error.
85+
- **Scenario titles:** Globally unique across all features. Two scenario titles that collapse to the same function name (case-insensitive) produce a parse error.
8486
- **Rule titles:** Unique within their parent Feature. Rule titles are used internally as keys for background lookup and in error messages — duplicate rule titles within a Feature produce a parse error. Per the Gherkin specification, rule names must be unique within their parent feature.
8587
- **Feature titles:** Globally unique across all features. Feature titles determine the generated folder structure — `Feature: Bank` generates `tests/features/bank/`. Duplicate or special-character feature titles would create path collisions or invalid directories.
8688

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "beehave"
7-
version = "0.3.1"
7+
version = "0.4.0"
88
description = "A thin layer on Hypothesis for Gherkin-style BDD testing with vocabulary enforcement"
99
readme = "README.md"
1010
requires-python = ">=3.14"

tests/test_check.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,30 @@ def test_unmapped():
377377
violations = check_all(config)
378378
types = [v.error_type for v in violations]
379379
assert "unmapped-test" in types
380+
381+
def test_subdirectory_features_found(
382+
self, tmp_project: Path, config: Config
383+
) -> None:
384+
write_feature(
385+
tmp_project,
386+
"cart/shopping",
387+
"""\
388+
Feature: Shopping
389+
Scenario: add item
390+
Given stuff
391+
""",
392+
)
393+
write_feature(
394+
tmp_project,
395+
"smoke",
396+
"""\
397+
Feature: Smoke
398+
Scenario: everything is fine
399+
Given stuff
400+
""",
401+
)
402+
403+
generate_stubs("cart/shopping", config)
404+
generate_stubs("smoke", config)
405+
violations = check_all(config)
406+
assert violations == []

tests/test_generate.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,3 +252,36 @@ def test_scenario_outline_with_examples(
252252
def test_nonexistent_feature_exits(self, tmp_project: Path, config: Config) -> None:
253253
with pytest.raises(SystemExit):
254254
generate_stubs("nonexistent", config)
255+
256+
def test_creates_init_py(self, tmp_project: Path, config: Config) -> None:
257+
write_feature(
258+
tmp_project,
259+
"initcheck",
260+
"""\
261+
Feature: Initcheck
262+
Scenario: hello
263+
Given stuff
264+
""",
265+
)
266+
generate_stubs("initcheck", config)
267+
init_file = tmp_project / "tests" / "features" / "initcheck" / "__init__.py"
268+
assert init_file.exists()
269+
270+
def test_does_not_overwrite_existing_init_py(
271+
self, tmp_project: Path, config: Config
272+
) -> None:
273+
write_feature(
274+
tmp_project,
275+
"initexist",
276+
"""\
277+
Feature: Initexist
278+
Scenario: hello
279+
Given stuff
280+
""",
281+
)
282+
test_dir = tmp_project / "tests" / "features" / "initexist"
283+
test_dir.mkdir(parents=True, exist_ok=True)
284+
init_file = test_dir / "__init__.py"
285+
init_file.write_text("# custom init\n", encoding="utf-8")
286+
generate_stubs("initexist", config)
287+
assert init_file.read_text() == "# custom init\n"

tests/test_gherkin.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,22 @@ def test_invalid_identifier_raises(self) -> None:
5959
with pytest.raises(GherkinError, match="not a valid Python identifier"):
6060
_derive_function_name("hello-world")
6161

62+
def test_uppercase_lowered(self) -> None:
63+
assert _derive_function_name("Add Single Item") == "test_add_single_item"
64+
65+
def test_mixed_case_lowered(self) -> None:
66+
assert _derive_function_name("EvErYtHiNg") == "test_everything"
67+
68+
def test_case_insensitive_collision(self) -> None:
69+
assert _derive_function_name("Test") == _derive_function_name("tEsT")
70+
6271
@given(st.from_regex(r"[a-zA-Z_][a-zA-Z0-9_]*", fullmatch=True))
6372
@settings(max_examples=50)
6473
def test_single_word_always_valid(self, word: str) -> None:
6574
result = _derive_function_name(word)
6675
assert result.startswith("test_")
6776
assert result.isidentifier()
77+
assert result == result.lower()
6878

6979

7080
class TestDeriveFeaturePath:
@@ -392,6 +402,57 @@ def test_background_literals_configurable(self, tmp_project: Path) -> None:
392402
assert all(not isinstance(lit.value, int) for lit in si.literals)
393403
assert all(not isinstance(lit.value, str) for lit in si.literals)
394404

405+
def test_background_steps_included_in_scenario_info(
406+
self, tmp_project: Path, config: Config
407+
) -> None:
408+
fp = write_feature(
409+
tmp_project,
410+
"bgsteps",
411+
"""\
412+
Feature: BG Steps
413+
Background:
414+
Given a user exists
415+
And the user has an empty cart
416+
417+
Scenario: add item
418+
When the user adds "Widget" to the cart
419+
Then the cart contains 1 item
420+
""",
421+
)
422+
result = parse_feature(fp, config)
423+
si = result["test_add_item"]
424+
assert len(si.steps) == 4
425+
assert si.steps[0].text == "a user exists"
426+
assert si.steps[1].text == "the user has an empty cart"
427+
assert si.steps[2].text == 'the user adds "Widget" to the cart'
428+
assert si.steps[3].text == "the cart contains 1 item"
429+
430+
def test_rule_background_steps_included_in_scenario_info(
431+
self, tmp_project: Path, config: Config
432+
) -> None:
433+
fp = write_feature(
434+
tmp_project,
435+
"rulebgsteps",
436+
"""\
437+
Feature: Rule BG Steps
438+
Background:
439+
Given feature bg step
440+
441+
Rule: Sub
442+
Background:
443+
Given rule bg step
444+
445+
Scenario: combined
446+
Given scenario step
447+
""",
448+
)
449+
result = parse_feature(fp, config)
450+
si = result["test_combined"]
451+
assert len(si.steps) == 3
452+
assert si.steps[0].text == "feature bg step"
453+
assert si.steps[1].text == "rule bg step"
454+
assert si.steps[2].text == "scenario step"
455+
395456
def test_rule_background_composes(self, tmp_project: Path, config: Config) -> None:
396457
fp = write_feature(
397458
tmp_project,

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)