Testing Guide

How to write, run, and benchmark tests for pypaginate.


Running Tests

All Tests (excluding benchmarks)

uv run pytest tests/ --ignore=tests/perf -q

By Category

# Unit tests (fastest, bulk of the suite)
uv run pytest tests/unit/ -q

# Integration (cross-module + database)
uv run pytest tests/integration/ -q

# End-to-end (full workflows with FastAPI)
uv run pytest tests/e2e/ -q

# Property-based (Hypothesis invariants)
uv run pytest tests/property/ -q

# Architecture enforcement (file limits, imports, protocol compliance)
uv run pytest tests/architecture/ -q

Specific Module

# All filtering tests
uv run pytest tests/unit/filtering/ -v

# Single test class
uv run pytest tests/unit/filtering/test_engine.py::TestFilterEngineSingle -v

# Single test method
uv run pytest tests/unit/filtering/test_engine.py::TestFilterEngineSingle::test_eq_filter -v

With Coverage

uv run pytest tests/unit/ --cov=pypaginate --cov-config=pyproject.toml --cov-report=term-missing

Test Categories

Unit Tests (tests/unit/)

Per-module tests that exercise individual classes and functions in isolation. Each source file maps to a test file:

src/pypaginate/filtering/engine.py  -->  tests/unit/filtering/test_engine.py
src/pypaginate/search/engine.py     -->  tests/unit/search/test_engine.py
src/pypaginate/domain/pages.py      -->  tests/unit/domain/test_pages.py

Integration Tests (tests/integration/)

Cross-module tests that verify multiple components work together. May use real SQLAlchemy sessions with in-memory SQLite.

End-to-End Tests (tests/e2e/)

Full workflow tests using FastAPI’s TestClient. These exercise the complete request path from HTTP query params through pagination and back to JSON response.

Property-Based Tests (tests/property/)

Hypothesis-driven tests that verify invariants hold across randomized inputs. These catch edge cases that unit tests miss.

Architecture Tests (tests/architecture/)

Automated enforcement of structural rules:

  • File limits – no source file exceeds 250 code lines (excluding comments, docstrings, blanks)

  • Import rules – no circular imports, layer violations detected

  • Protocol compliance – all backends satisfy their declared protocols


Writing Tests

Structure

Use classes for grouping, AAA (Arrange-Act-Assert) pattern:

"""Tests for SQLAlchemyFilterBackend."""

from __future__ import annotations

from pypaginate.domain.specs import FilterSpec
from pypaginate.adapters.sqlalchemy import SQLAlchemyFilterBackend


class TestFilterBackendEquality:
    def test_eq_filter_applies_where_clause(self) -> None:
        # Arrange
        backend = SQLAlchemyFilterBackend()
        specs = [FilterSpec(field="name", operator="eq", value="Alice")]

        # Act
        result = backend.apply_filters(query, specs)

        # Assert
        assert "WHERE" in str(result)

Fixtures

Use shared fixtures from conftest.py – don’t create objects inline when a fixture exists:

# GOOD -- uses shared fixture
def test_filter(self, filter_engine: FilterEngine) -> None: ...

# BAD -- duplicated setup
def test_filter(self) -> None:
    engine = FilterEngine()

Parametrize for Coverage

@pytest.mark.parametrize(
    ("operator", "value", "expected_count"),
    [
        ("eq", "Alice", 1),
        ("ne", "Alice", 3),
        ("contains", "li", 1),
        ("gte", 30, 2),
    ],
    ids=["eq", "ne", "contains", "gte"],
)
def test_filter_operator(self, operator, value, expected_count) -> None:
    specs = [FilterSpec(field="name", operator=operator, value=value)]
    result = filter_engine.apply(sample_users, specs)
    assert len(result) == expected_count

Test Naming

Test names should describe the behavior being tested:

# GOOD -- describes behavior
def test_between_requires_two_element_sequence(self) -> None: ...
def test_cursor_after_and_before_are_mutually_exclusive(self) -> None: ...

# BAD -- vague
def test_filter(self) -> None: ...
def test_error(self) -> None: ...

Coverage Expectations

  • Unit tests – cover happy path and error cases for every public method

  • New features – must include tests before merging

  • Bug fixes – must include a regression test that fails without the fix


Benchmarking

Running Benchmarks

# Quick comparison benchmarks
uv run pytest tests/perf/test_comparison.py --benchmark-enable --benchmark-only -q

# Save baseline before optimizing
uv run pytest tests/perf/test_comparison.py --benchmark-enable --benchmark-save=before

# Compare after a change
uv run pytest tests/perf/test_comparison.py --benchmark-enable --benchmark-compare=0001

Benchmark Suites

Suite

Purpose

test_comparison.py

pypaginate vs raw Python at 10K

test_scaling.py

1K to 1M across backends

test_competitors.py

vs other pagination libraries

test_fastapi_scaling.py

HTTP endpoint scaling

test_overhead.py

ops to paginate to serialize to HTTP

Writing Benchmarks

@pytest.mark.benchmark(group="filter-memory")
def test_bench_filter_10k(benchmark, memory_env_10k) -> None:
    """Benchmark single filter on 10K items."""
    specs = [FilterSpec(field="age", operator="gte", value=30)]
    result = benchmark(memory_env_10k.do_filter, memory_env_10k.query, specs)
    assert len(result) <= 10_000

Rules:

  1. Always use @pytest.mark.benchmark(group="...") for grouping

  2. Assert correctness inside the benchmark

  3. Use benchmark() callable – it handles warmup, calibration, and rounds

  4. Focus on Median when reading results (Mean is skewed by outliers)