Test Refactoring
Reduce test duplication and improve maintainability by applying pytest patterns systematically.
Essential Principles
When three or more test functions call the same function with different inputs and assert the same way, they should be one parametrized test. Individual functions hide the pattern — parametrize makes it explicit. This catches missing cases (gaps in the parameter table are visible) and makes adding new cases a one-line change.
Duplicating test logic across sync and async classes means every bug fix or new assertion must be applied twice. Use a _maybe_await helper or parametrized fixture to run both variants from the same test function body. The source of truth for test logic should exist in exactly one place.
Large files degrade readability and make pytest -k targeting harder. Split by domain (basic CRUD, raw CQL, keyspace management, extended types, views) rather than by execution model. Each module should have a clear, non-overlapping scope described by its filename.
Parametrized tests with bare tuples produce failure output like test_foo[param0], which is useless for debugging. Every parametrize entry should produce a human-readable test ID. Use pytest.param with id= or ensure the first tuple element is a descriptive string.
When multiple test files need the same Document subclass, define it once and import it. Duplicated model definitions drift apart silently and make refactoring harder.
Prerequisites
See setup-environment.md for the shared environment
setup. Install pre-commit hooks before making any commits:
uv sync --all-groups
uv run pre-commit install
When to Use
- A test file exceeds 400 lines and contains tests for multiple feature areas
- Three or more test functions follow the same structure with different inputs
- Sync and async test classes contain mirrored test methods with identical logic
- Adding a new test case requires copying an existing function and changing one value
- Extended-type roundtrip tests repeat the same save/read/assert pattern per type
- A new contributor asks how to write tests for this project
When NOT to Use
- Tests that genuinely differ in logic, not just inputs — keep them as separate functions
- Integration test infrastructure changes (fixtures, containers) — use the
integration-tests skill instead
- Adding brand-new test coverage for untested features — write the tests first, refactor later
- Performance benchmarks in
benchmarks/ — those follow different conventions
Refactoring Decision Tree
Look at the test file you want to refactor:
│
├─ Multiple functions calling the same function with different inputs?
│ └─ Collapse into @pytest.mark.parametrize
│ See: references/parametrize-patterns.md
│
├─ Parallel sync and async test classes with mirrored methods?
│ └─ Merge into single parametrized tests with _maybe_await
│ See: references/sync-async-dedup.md
│
├─ File exceeds 400 lines?
│ └─ Split by feature area into separate modules
│ See: workflows/refactor-test-file.md
│
└─ None of the above?
└─ File is fine — don't refactor for the sake of refactoring
Quick Reference: Parametrize Patterns
| Pattern |
When |
Example |
| Simple type mapping |
f(input) == expected repeated N times |
@pytest.mark.parametrize("py_type,cql", [(str,"text"), ...]) |
| Collection operations |
Same builder, different op/value/fragment |
@pytest.mark.parametrize("op,value,fragment", [...]) |
| Filter operators |
Same parser, different kwargs/expected |
@pytest.mark.parametrize("kwargs,expected", [...]) |
| Roundtrip tests |
Save value → read back → assert for N types |
@pytest.mark.parametrize("field,write_val,check", [...]) |
| Error cases |
Same function, different bad inputs, same exception |
@pytest.mark.parametrize("bad_input", [...]) |
Quick Reference: File Size Targets (coodie project)
| File |
Current |
Target |
Action |
tests/test_types.py |
~242 lines |
~120 lines |
Parametrize type mappings and coercion tests |
tests/test_cql_builder.py |
~706 lines |
~450 lines |
Parametrize filter/collection/USING variants |
tests/test_integration.py |
~2,435 lines |
Split into 5 modules |
Move to tests/integration/ package |
tests/sync/test_document.py |
~699 lines |
Merge with async |
Shared models + _maybe_await pattern |
tests/aio/test_document.py |
~609 lines |
Merge with async |
Shared models + _maybe_await pattern |
Quick Reference: Conventions
| Convention |
Rule |
| Parametrize threshold |
≥ 3 functions with same structure → parametrize |
| Test IDs |
Always use pytest.param(..., id="name") for non-obvious params |
| Shared models |
Define in conftest.py or dedicated models.py |
| Sync/async parity |
One function + _maybe_await helper, not two classes |
| File size |
Target < 400 lines, split at 500 lines |
| Session fixtures |
Expensive resources (containers, drivers) in conftest.py, session-scoped |
| Function fixtures |
State-clearing fixtures stay function-scoped |
Reference Index
| File |
Content |
| parametrize-patterns.md |
Concrete before/after examples for each parametrize pattern in this codebase |
| sync-async-dedup.md |
The _maybe_await pattern, shared model extraction, fixture parametrization |
| Workflow |
Purpose |
| refactor-test-file.md |
5-phase process for refactoring a test file from analysis to verification |
Success Criteria
A well-refactored test file:
1---2name: test-refactoring3description: Guides refactoring of Python test suites to reduce duplication using pytest.mark.parametrize, split large monolithic test files into focused modules, and deduplicate mirrored sync/async test classes. Use when test files exceed 400 lines, when multiple test functions share identical structure with different inputs, or when sync and async test classes are copy-pasted mirrors of each other.4---56# Test Refactoring78Reduce test duplication and improve maintainability by applying pytest patterns systematically.910## Essential Principles1112<essential_principles>1314<principle name="parametrize-over-copy-paste">15**Replace groups of structurally identical tests with `@pytest.mark.parametrize`.**1617When three or more test functions call the same function with different inputs and assert the same way, they should be one parametrized test. Individual functions hide the pattern — parametrize makes it explicit. This catches missing cases (gaps in the parameter table are visible) and makes adding new cases a one-line change.18</principle>1920<principle name="one-function-both-variants">21**Sync and async tests that mirror each other must share a single test function.**2223Duplicating test logic across sync and async classes means every bug fix or new assertion must be applied twice. Use a `_maybe_await` helper or parametrized fixture to run both variants from the same test function body. The source of truth for test logic should exist in exactly one place.24</principle>2526<principle name="small-focused-modules">27**Keep test files under 400 lines; split by feature area, not by sync/async.**2829Large files degrade readability and make `pytest -k` targeting harder. Split by domain (basic CRUD, raw CQL, keyspace management, extended types, views) rather than by execution model. Each module should have a clear, non-overlapping scope described by its filename.30</principle>3132<principle name="readable-parametrize-ids">33**Always use `pytest.param(..., id="description")` or descriptive tuple values.**3435Parametrized tests with bare tuples produce failure output like `test_foo[param0]`, which is useless for debugging. Every parametrize entry should produce a human-readable test ID. Use `pytest.param` with `id=` or ensure the first tuple element is a descriptive string.36</principle>3738<principle name="shared-models-in-conftest">39**Shared test model definitions belong in `conftest.py` or a `models.py` module, never duplicated across files.**4041When multiple test files need the same Document subclass, define it once and import it. Duplicated model definitions drift apart silently and make refactoring harder.42</principle>4344</essential_principles>4546## Prerequisites4748See [setup-environment.md](../setup-environment.md) for the shared environment49setup. **Install pre-commit hooks before making any commits:**5051```bash52uv sync --all-groups53uv run pre-commit install54```5556## When to Use5758- A test file exceeds 400 lines and contains tests for multiple feature areas59- Three or more test functions follow the same structure with different inputs60- Sync and async test classes contain mirrored test methods with identical logic61- Adding a new test case requires copying an existing function and changing one value62- Extended-type roundtrip tests repeat the same save/read/assert pattern per type63- A new contributor asks how to write tests for this project6465## When NOT to Use6667- Tests that genuinely differ in logic, not just inputs — keep them as separate functions68- Integration test infrastructure changes (fixtures, containers) — use the `integration-tests` skill instead69- Adding brand-new test coverage for untested features — write the tests first, refactor later70- Performance benchmarks in `benchmarks/` — those follow different conventions7172## Refactoring Decision Tree7374```75Look at the test file you want to refactor:76│77├─ Multiple functions calling the same function with different inputs?78│ └─ Collapse into @pytest.mark.parametrize79│ See: references/parametrize-patterns.md80│81├─ Parallel sync and async test classes with mirrored methods?82│ └─ Merge into single parametrized tests with _maybe_await83│ See: references/sync-async-dedup.md84│85├─ File exceeds 400 lines?86│ └─ Split by feature area into separate modules87│ See: workflows/refactor-test-file.md88│89└─ None of the above?90 └─ File is fine — don't refactor for the sake of refactoring91```9293## Quick Reference: Parametrize Patterns9495| Pattern | When | Example |96|---------|------|---------|97| Simple type mapping | `f(input) == expected` repeated N times | `@pytest.mark.parametrize("py_type,cql", [(str,"text"), ...])` |98| Collection operations | Same builder, different op/value/fragment | `@pytest.mark.parametrize("op,value,fragment", [...])` |99| Filter operators | Same parser, different kwargs/expected | `@pytest.mark.parametrize("kwargs,expected", [...])` |100| Roundtrip tests | Save value → read back → assert for N types | `@pytest.mark.parametrize("field,write_val,check", [...])` |101| Error cases | Same function, different bad inputs, same exception | `@pytest.mark.parametrize("bad_input", [...])` |102103## Quick Reference: File Size Targets (coodie project)104105| File | Current | Target | Action |106|------|---------|--------|--------|107| `tests/test_types.py` | ~242 lines | ~120 lines | Parametrize type mappings and coercion tests |108| `tests/test_cql_builder.py` | ~706 lines | ~450 lines | Parametrize filter/collection/USING variants |109| `tests/test_integration.py` | ~2,435 lines | Split into 5 modules | Move to `tests/integration/` package |110| `tests/sync/test_document.py` | ~699 lines | Merge with async | Shared models + `_maybe_await` pattern |111| `tests/aio/test_document.py` | ~609 lines | Merge with async | Shared models + `_maybe_await` pattern |112113## Quick Reference: Conventions114115| Convention | Rule |116|-----------|------|117| Parametrize threshold | ≥ 3 functions with same structure → parametrize |118| Test IDs | Always use `pytest.param(..., id="name")` for non-obvious params |119| Shared models | Define in `conftest.py` or dedicated `models.py` |120| Sync/async parity | One function + `_maybe_await` helper, not two classes |121| File size | Target < 400 lines, split at 500 lines |122| Session fixtures | Expensive resources (containers, drivers) in `conftest.py`, session-scoped |123| Function fixtures | State-clearing fixtures stay function-scoped |124125## Reference Index126127| File | Content |128|------|---------|129| [parametrize-patterns.md](references/parametrize-patterns.md) | Concrete before/after examples for each parametrize pattern in this codebase |130| [sync-async-dedup.md](references/sync-async-dedup.md) | The `_maybe_await` pattern, shared model extraction, fixture parametrization |131132| Workflow | Purpose |133|----------|---------|134| [refactor-test-file.md](workflows/refactor-test-file.md) | 5-phase process for refactoring a test file from analysis to verification |135136## Success Criteria137138A well-refactored test file:139140- [ ] Has no groups of 3+ functions with identical structure differing only in inputs141- [ ] Uses `pytest.param(..., id="...")` for all non-obvious parametrize entries142- [ ] Has no duplicated model definitions across files143- [ ] Has no mirrored sync/async test classes with identical logic144- [ ] Stays under 400 lines (or has a documented reason for exceeding)145- [ ] All tests pass: `uv run pytest tests/ -v --ignore=tests/test_integration.py`146- [ ] Test count is unchanged or increased (refactoring must not drop coverage)