Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
96a419e
feat: enhance DX with auto-generated copy-paste assertion blocks
fmartins Apr 4, 2026
12598fd
test: test_assertion_error_copy_paste_block_no_params
fmartins Apr 4, 2026
c0c0557
test: test_assertion_error_copy_paste_block_empty_query
fmartins Apr 4, 2026
142e85b
feat: add snapshot support
fmartins Apr 4, 2026
5ef24ab
feat: add assert_snapshot support
fmartins Apr 4, 2026
c390ca9
feat: add pytest-watcher and tdd Makefile target
fmartins Apr 4, 2026
856d487
test: register pytest assertion rewrite for pytest_capquery plugin
fmartins Apr 4, 2026
185dc24
feat: add `assert_matches_snapshot` method to capquery plugin for sna…
fmartins Apr 4, 2026
0b11041
Revert "feat: add `assert_matches_snapshot` method to capquery plugin…
fmartins Apr 4, 2026
81c65a0
feat: support multi-phase query capture and snapshot serialization
fmartins Apr 4, 2026
f9362d0
refactor: replace `capquery` usage with `CapQueryWrapper` and `Snapsh…
fmartins Apr 4, 2026
c9c869d
refactor: consolidate `TxEvent` and `NormalizedStringStmt` into a sin…
fmartins Apr 4, 2026
5087808
feat: modularize and refactor `pytest_capquery` components into separ…
fmartins Apr 4, 2026
f0fc417
chore: update IDE project settings with dictionary and test runner co…
fmartins Apr 4, 2026
00e86c6
chore: add descriptive help text for Makefile targets
fmartins Apr 4, 2026
28c602e
chore: reorganize and update dependencies in `pyproject.toml`
fmartins Apr 4, 2026
f6b051a
chore: remove outdated test files and unused logic
fmartins Apr 4, 2026
2a6dd55
chore: remove outdated test files and unused logic
fmartins Apr 4, 2026
111f656
refactor: replace `db_session` with `sqlite_session` in tests
fmartins Apr 4, 2026
8e677cb
refactor: replace `db_session` with `sqlite_session` in tests
fmartins Apr 4, 2026
ae4cda7
refactor: enhance type hints and docstrings for improved readability
fmartins Apr 4, 2026
0ddaa06
refactor: optimize cleanup logic and adjust pytest registration
fmartins Apr 4, 2026
e3cf3fc
chore: refine `Makefile` test command for improved test runner usage
fmartins Apr 4, 2026
895fc3d
chore: remove unnecessary blank line in `conftest.py`
fmartins Apr 4, 2026
d3f4191
test: add snapshot assertion and resource cleanup tests
fmartins Apr 4, 2026
21a90a4
refactor: reformat multi-line docstrings for consistency
fmartins Apr 4, 2026
8c9dc0d
refactor: split `conftest.py` into MySQL and PostgreSQL-specific files
fmartins Apr 4, 2026
8d89ccc
refactor: split `conftest.py` into MySQL and PostgreSQL-specific files
fmartins Apr 4, 2026
93b2b6a
docs: enhance README with detailed feature descriptions and usage exa…
fmartins Apr 4, 2026
9646468
chore: bump version to 0.3.0 in pyproject.toml
fmartins Apr 4, 2026
43a3637
test: add comprehensive SQL snapshot tests for single and multi-phase…
fmartins Apr 4, 2026
8ecf22f
test: improve SQLite teardown to prevent resource warnings
fmartins Apr 4, 2026
ad61915
chore: suppress ResourceWarnings for SQLite connections in pytest config
fmartins Apr 4, 2026
67e2cf3
test: enhance CapQuery plugin tests with isolation, sequential captur…
fmartins Apr 4, 2026
c6ac413
test: remove legacy SQL snapshot tests for single and multi-phase cap…
fmartins Apr 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .idea/dictionaries/project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions .idea/pytest-capquery.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 12 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
.DEFAULT_GOAL := help
.PHONY: setup setup-env install test db-up db-down clean format check-format help
.PHONY: setup setup-env install test tdd db-up db-down clean format check-format help

help: ## Show this help message
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nTargets:\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nTargets:\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1,$$2 }' $(MAKEFILE_LIST)

setup: setup-env install ## Full local setup: install pyenv python, create venv, and install deps

Expand All @@ -29,13 +29,21 @@ test: db-up ## Run all tests with code coverage and test analytics
./.venv/bin/pytest -p no:capquery -n auto -vvv --cov=pytest_capquery --cov-report=term-missing --cov-report=xml --junitxml=junit.xml -o junit_family=legacy tests/ || (make db-down && exit 1)
make db-down

tdd: db-up ## Run tests in watch mode for test-driven development
./.venv/bin/ptw . --now --runner ./.venv/bin/pytest tests/ -vvv -p no:capquery --capquery-update || (make db-down && exit 1)
make db-down

clean: ## Remove virtual environment and cached files
rm -rf .venv
find . -type d -name "__pycache__" -exec rm -rf {} +
rm -rf build/ dist/ *.egg-info/ .pytest_cache/

format: ## Run Prettier to format markdown, yaml, and json files
format: ## Run formatters for python, markdown, yaml, and json files
./.venv/bin/docformatter --in-place --wrap-summaries 100 --wrap-descriptions 100 -r src tests || test $$? -eq 3
./.venv/bin/ruff format src tests
npx prettier --write .

check-format: ## Check if files comply with Prettier formatting (for CI)
check-format: ## Check if files comply with formatting rules (for CI)
./.venv/bin/docformatter --check --wrap-summaries 100 --wrap-descriptions 100 -r src tests
./.venv/bin/ruff format --check src tests
npx prettier --check .
120 changes: 88 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,36 @@
![Python Version](https://img.shields.io/badge/python-3.13%2B-blue)
![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)

Testing your business logic is good, but **testing your database interactions is critical**.
Testing your business logic is good, but **documenting and testing your database interactions is
critical**.

`pytest-capquery` treats your SQL queries as first-class citizens in your test suite. By asserting
the exact queries executed, you create living documentation of what is truly happening behind the
scenes. This guarantees deterministic performance, catches N+1 regressions instantly, and ensures
your application behaves exactly as intended.
`pytest-capquery` treats your SQL queries as first-class citizens in your Pytest suite. By capturing
and asserting the exact queries executed, you create a living documentation of what is truly
happening behind the ORM abstraction.

Designed for modern Python applications, `pytest-capquery` is a strict, strongly-typed SQLAlchemy
pytest plugin that enforces exact chronological query execution, validating precise SQL strings,
parameter bindings, and transaction boundaries (`BEGIN`, `COMMIT`, `ROLLBACK`).
This plugin does not force any specific SQLAlchemy architectural changes or optimization strategies.
It delegates all design decisions to the developer, acting strictly as a deterministic guardrail.
Once you've optimized your query footprint, `pytest-capquery` locks it in, ensuring cross-dialect
equality, validating exact transaction boundaries (`BEGIN`, `COMMIT`, `ROLLBACK`), and catching
silent N+1 regressions the second they are introduced.

## Key Features

- **Contextual Isolation:** Use the `capture()` context manager to track queries locally without
global state leakage or manual resets.
- **SQL Snapshots:** Automatically generate and track expected `.sql` snapshots to easily document
executed queries without cluttering test files.
- **Strict Timeline Assertion:** Validate the exact chronological sequence of SQL strings and
transaction events.
- **Heuristic N+1 Guards:** Use "loose assertion" mode to enforce maximum query counts without
binding tests to fragile ORM implementation details.
- **Deterministic Parameter Matching:** Ensures cross-dialect equality for parameter structures.
- **Async Ready:** Seamlessly integrates with standard and `AsyncSession` environments.
- **Auto-Generating Assertions:** When explicit assertions fail, the plugin drops a fully formatted,
copy-paste-ready Python block into stdout.
- **Heuristic Guards:** Use "loose assertion" mode to enforce maximum query counts.

## Used By

`pytest-capquery` is actively used to protect the database performance of:

- [macafe CLOUD](https://macafe.cloud/)
- [macafe.cloud](https://macafe.cloud/)

---

Expand All @@ -48,32 +51,42 @@ pip install pytest-capquery
The `capquery` fixture captures all SQLAlchemy statements executed by your code. The best practice
is to use the `capture()` context manager to isolate specific execution phases.

### 1. Preventing N+1 Queries (Loose Assertion)
### 1. Documenting with SQL Snapshots (Recommended)

If you want to protect a block of code against N+1 regressions without hardcoding exact SQL strings,
you can enforce a strict expected query count at the context boundary:
The most efficient way to document and protect your queries is by utilizing physical snapshots. This
automatically compares execution behavior against tracked `.sql` files stored in a
`__capquery_snapshots__` directory.

```python
def test_fetch_users(db_session, capquery):
# Enforce that exactly 1 query is executed inside this block.
# If a lazy-loading loop triggers extra queries, this will raise an AssertionError.
with capquery.capture(expected_count=1):
users = db_session.query(User).all()
for user in users:
_ = user.address
def test_update_user_status(sqlite_session, capquery):
# Enable assert_snapshot to verify execution against the disk
with capquery.capture(assert_snapshot=True):
user = sqlite_session.query(User).filter_by(id=1).first()
user.status = "active"
sqlite_session.commit()
```

### 2. Asserting Exact SQL Execution (Strict Assertion)
**Workflow:** When writing a new test or updating existing query logic, run Pytest with the update
flag to automatically generate or overwrite the snapshot files:

For mission-critical operations, you can capture a phase and rigorously assert the exact SQL and
parameters executed:
```bash
pytest --capquery-update
```

Future runs without the flag will strictly assert that the runtime queries perfectly match the
generated `.sql` file.

### 2. Manual Explicit Assertions (Verbose)

If you prefer to explicitly document the executed SQL directly inside your test cases, you can use
strict manual assertions.

```python
def test_update_user_status(db_session, capquery):
def test_update_user_status(sqlite_session, capquery):
with capquery.capture() as phase:
user = db_session.query(User).filter_by(id=1).first()
user = sqlite_session.query(User).filter_by(id=1).first()
user.status = "active"
db_session.commit()
sqlite_session.commit()

# Verify the precise chronological timeline of the transaction
phase.assert_executed_queries(
Expand All @@ -96,6 +109,26 @@ def test_update_user_status(db_session, capquery):
)
```

**Auto-Generation on Failure:** Maintaining long SQL strings can be tedious. If your code changes
and the assertion fails, `pytest-capquery` will intercept the failure and drop the _correct_ Python
assertion block directly into your terminal's stdout. Simply copy and paste the block from your
terminal directly into your test to instantly fix the regression!

### 3. Preventing N+1 Queries (Loose Assertion)

If you want to protect a block of code against N+1 regressions without hardcoding exact SQL strings,
you can enforce a strict expected query count at the context boundary:

```python
def test_fetch_users(sqlite_session, capquery):
# Enforce that exactly 1 query is executed inside this block.
# If a lazy-loading loop triggers extra queries, this will raise an AssertionError.
with capquery.capture(expected_count=1):
users = sqlite_session.query(User).all()
for user in users:
_ = user.address
```

---

## Contributing
Expand All @@ -112,7 +145,8 @@ these steps:

### Developer Setup

To get your local environment ready for contribution, run the following commands:
To get your local environment ready for contribution, run the following commands. We prioritize a
Test-Driven Development (TDD) workflow to continuously monitor database interactions.

```bash
# Clone the repository
Expand All @@ -122,8 +156,30 @@ cd pytest-capquery
# Install Python, dependencies, and pre-commit hooks
make setup

# Run the full test suite (handles DB spin-up and coverage)
make test
# Start the TDD watcher (auto-runs tests and updates snapshots on file changes)
make tdd
```

### Makefile Reference

```bash
make help

Usage:
make <target>

Targets:
help Show this help message
setup Full local setup: install pyenv python, create venv, and install deps
setup-env Install local python version via pyenv (macOS/Linux dev only)
install Create venv and install dependencies
db-up Start Docker Compose databases
db-down Tear down Docker Compose databases
test Run all tests with code coverage and test analytics
tdd Run tests in watch mode for test-driven development
clean Remove virtual environment and cached files
format Run formatters for python, markdown, yaml, and json files
check-format Check if files comply with formatting rules (for CI)
```

## License
Expand Down
2 changes: 0 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
version: '3.8'

services:
postgres:
image: postgres:15-alpine
Expand Down
29 changes: 21 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "pytest-capquery"
version = "0.2.0"
version = "0.3.0"
description = "A pytest fixture for high-precision SQL testing in SQLAlchemy."
readme = "README.md"
requires-python = ">=3.13"
Expand All @@ -20,15 +20,21 @@ capquery = "pytest_capquery.plugin"

[project.optional-dependencies]
test = [
"pytest",
"pytest-cov",
"pytest-xdist",
"sqlalchemy-capture-sql",
"sqlparse",
"cryptography",
"docformatter",
"pre-commit",
"psycopg2-binary",
"pymysql",
"cryptography",
"pre-commit"
"pytest-cov",
"pytest-watcher",
"pytest-xdist",
"ruff"
]

[tool.pytest.ini_options]
filterwarnings = [
# Silence Python 3.13 GC warnings when cleaning up SQLAlchemy SQLite memory connections
"ignore:unclosed database in <sqlite3.Connection:ResourceWarning",
]

[tool.coverage.run]
Expand All @@ -37,3 +43,10 @@ omit = ["tests/*"]

[tool.coverage.report]
show_missing = true

[tool.ruff]
line-length = 100

[tool.ruff.format]
docstring-code-format = true
docstring-code-line-length = 100
Loading
Loading