pytest#
Test Data Workbench generates code, and generated code is exactly the kind of artifact that is easy to get subtly wrong while looking right: it parses, it has a plausible shape, and it still fails the moment something calls it. pytest is the tool that catches that gap.
Why pytest#
The project needs a test runner that can express three things without
fighting the framework: fixtures composed from other fixtures (a schema
built from tables built from columns), async tests (much of the analysis
and deployment path is async def), and enough test files that plain
assert statements with readable failure output matter more than they
would in a five-file project. pytest gives all three natively: fixtures,
pytest-asyncio integration, and assertion introspection that reports
what a failed assert actually compared, with no custom assertion
methods to learn. The suite defines 358 test functions across 18 modules
under tests/ (more once parametrized cases are expanded), runs on every
push across three Python versions and two operating systems, and it is the
reason the project can claim its generated generators actually work rather
than merely compile.
The idea underneath#
pytest’s fixture system is dependency injection applied to test setup.
A test function declares what it needs as parameters, for example
def test_x(self, user_table, defensive_manager):, and pytest resolves
each name against a fixture function, calls it, and hands the return value
in. Compare that to the older xUnit style (Python’s own unittest.TestCase,
Java’s JUnit 3/4), where setup lives in an inherited setUp/tearDown
pair that runs unconditionally for every test in the class whether that test
needs it or not, and where sharing setup across classes means subclassing.
pytest fixtures are requested, not inherited: a test asks for exactly the
fixtures it needs, fixtures can themselves request other fixtures, and a
fixture can be swapped for a different implementation at any scope without
touching the tests that consume it. The official documentation calls this
out directly as the design’s reason for existing, better than the classic
xUnit style of setup/teardown functions.
Every test in this codebase still follows the older, more universal shape
underneath the fixture plumbing: arrange, act, assert. Build the input
(often by requesting a fixture that already built it), call the thing
under test, check the result. tests/test_pii_detection.py makes the
three phases almost mechanically visible:
def test_flags_email_ssn_and_address(self, analyzer, mixed_schema_tables):
pii = analyzer._detect_pii_columns(mixed_schema_tables) # act
flagged = {(p.table, p.column): p.category for p in pii}
assert flagged[("users", "email")] == "email" # assert
assert flagged[("users", "ssn")] == "ssn"
assert flagged[("users", "street_address")] == "address"
analyzer and mixed_schema_tables are the arrange step, supplied as
fixtures rather than written inline.
A second idea worth naming explicitly: the vocabulary distinction between
test doubles. Martin Fowler’s “Mocks Aren’t Stubs” is the canonical
reference: a stub returns canned answers, a fake is a real, working,
simplified implementation, and a mock additionally records how it was
called so the test can assert on those calls after the fact (behavior
verification, as opposed to the state verification a stub or fake supports).
tests/test_defensive_manager.py uses this distinction concretely without
naming it: GoodGenerator, BadGenerator, and FallbackGenerator
are hand-written classes with a real, working generate() method, that
is, fakes, used to drive DefensiveGeneratorManager through its success,
failure, and fallback paths deterministically. None of them record calls
for later assertion, so nothing in that file is a mock in Fowler’s strict
sense, even though unittest.mock.MagicMock is imported at the top of
that file and of tests/test_integration.py. Neither import is actually
used; the suite reaches for fakes, not mocks, everywhere it needs a test
double. See Sharp edges and limits for what that implies about the
pytest-mock dependency.
The last idea is specific to testing a code generator rather than a
function: the unit under test does not return a value, it returns source
code that, when run, produces a value. Asserting on the string is weak,
because syntactically valid Python can still be behaviorally wrong.
tests/test_generated_execution.py names this directly in its module
docstring, and the fix it applies, actually executing the generated
artifact, is the strongest form of testing a generator: closer in spirit to
an acceptance test of the generator’s output than a unit test of the
generator’s text.
How it fits this project#
pytest is a dev extra, not a runtime dependency: pyproject.toml
lists pytest>=7.4.3, pytest-asyncio>=0.21.1, and pytest-mock>=3.12.0
under [project.optional-dependencies] dev, installed with
pip install -e ".[dev]". Configuration lives in [tool.pytest.ini_options]
in the same file:
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
markers = [
"asyncio: mark a test as an async test",
"slow: mark a test as slow-running",
]
testpaths scopes collection to tests/. The suite is organised as
18 test_*.py modules plus one shared tests/conftest.py, with no
per-subdirectory conftest.py files, no separate unit/ and
integration/ trees, and one file, tests/test_postgres_integration.py,
that is real-database rather than SQLite-backed. Every module groups its
tests into one or more class Test...: containers (18 files, each with
at least one such class); pytest does not require this, it collects bare
def test_... functions equally well, but grouping by class here also
groups the fixtures and helper methods those tests share.
Fixtures and conftest#
tests/conftest.py is the spine of the suite: fixtures defined there are
visible to every test module without an import, because pytest auto-discovers
conftest.py files by directory scope. It defines the shared schema
vocabulary the rest of the suite builds on:
@pytest.fixture
def user_table():
"""Minimal users table."""
return Table(
name="users",
columns=[
Column("id", "integer", False, constraints=[ConstraintType.PRIMARY_KEY]),
Column("username", "varchar(50)", False),
Column("email", "varchar(100)", False),
...
],
row_count=1000,
entity_type=EntityType.USER,
)
@pytest.fixture
def demo_schema(user_table, product_table, order_table,
sample_relationships, sample_business_rules):
"""Full demo schema with tables, relationships, and rules."""
return SchemaInfo(
database_name="test_ecommerce",
tables=[user_table, product_table, order_table],
relationships=sample_relationships,
business_rules=sample_business_rules,
)
demo_schema requesting user_table, product_table, and
order_table as its own arguments is fixtures composing fixtures: pytest
resolves the whole dependency graph before the test ever runs. Individual
test files add their own local fixtures on top of the shared ones, scoped
to that file only, for example customer_table and generic_table in
tests/test_generated_execution.py, or the nested factory fixture
redeclared inside several TestXxx classes across the suite (a fixture
defined inside a class is only visible to tests in that class).
Testing generated code by executing it#
tests/test_generated_execution.py exists because
tests/test_generator_factory.py only ever runs
compile(code, ..., "exec") on generated source, which proves the code
parses and never calls generate(). That gap let a real bug ship: a
type-based fallback emitted a bare fake.xxx() expression, but only the
user/customer template defines a module-level fake = Faker(); every
other template raised NameError the moment generate() actually ran.
The fix is a small harness that execs the generated source into an
isolated namespace, pulls out the one class ending in Generator, and
then calls it for real:
def _load_generator_class(code: str):
namespace: dict = {}
exec(compile(code, "<generated>", "exec"), namespace)
matches = [
value for key, value in namespace.items()
if key.endswith("Generator") and isinstance(value, type)
]
assert len(matches) == 1, ...
return matches[0]
def test_user_generator_executes(self, factory, user_table):
schema = SchemaInfo("test", [user_table], [], [])
code = factory.create_generators_from_schema(schema).generators["users"]
generator_cls = _load_generator_class(code)
records = generator_cls().generate(5)
_assert_valid_records(records, user_table, 5)
Every entity template (user, customer, product, order, review, generic,
fallback) gets this same treatment, plus dedicated classes for sequential
primary-key behavior (TestSequentialPrimaryKeys) and name-based
temporal-column detection (TestNameBasedTemporalDetection), each again
executing the generated class rather than reading its source text.
asyncio_mode auto and testing async code#
asyncio_mode = "auto" tells pytest-asyncio to treat every async def
test_... function as an asyncio test automatically, with no per-test
marker required. 22 test functions across the suite are async def, and
every one of them uses async/await because the code under test is:
SchemaAnalyzer.analyze_production_schema and the CLI’s analyze/
deploy entry points are coroutines, so exercising them means awaiting
them.
@pytest.mark.asyncio
async def test_no_select_statements_touch_table_data(self, tmp_path):
conn_str = _make_db_with_data(tmp_path / "meta.db")
analyzer = SchemaAnalyzer()
with _SqlCapture() as capture:
schema_info = await analyzer.analyze_production_schema(conn_str, metadata_only=True)
The explicit @pytest.mark.asyncio on that test (and on the rest of the
async tests in the suite) is redundant under auto mode, pytest-asyncio
would collect and run it correctly without the decorator, but it is not
wrong: the decorator is harmless in auto mode and documents intent at the
call site. markers = ["asyncio: ...", "slow: ..."] in pyproject.toml
registers both names so pytest does not emit an unknown-marker warning when
it sees them, independent of whether asyncio_mode needs the marker to
function.
Markers and selecting subsets#
Two custom markers are registered in pyproject.toml: asyncio (used
throughout, as above) and slow. slow is declared but not currently
applied to any test in the suite, there is no -m slow or
-m "not slow" invocation anywhere in CI or the docs either. The
mechanism to select subsets exists (pytest -m asyncio would run only
the async tests, pytest -m "not slow" would skip anything tagged slow
once something is), it is just unused today. The suite instead splits by
file: the Postgres-only module is run as a separate, explicit invocation
rather than filtered out of a combined run with a marker, see
Integration tests against a real database below.
pytest.mark.skipif is used for conditional skipping instead, gating an
entire module rather than individual tests:
pytestmark = pytest.mark.skipif(
not TDW_TEST_POSTGRES_URL,
reason="TDW_TEST_POSTGRES_URL is not set - skipping real Postgres integration test",
)
Assigning to the module-level pytestmark name applies the mark to every
test collected from that module, which is how
tests/test_postgres_integration.py skips itself cleanly in any
environment without the environment variable set, rather than erroring.
pytest.mark.parametrize is the suite’s other marker, used for one thing:
turning a long list of name/category pairs into individually reported test
cases in tests/test_pii_detection.py:
@pytest.mark.parametrize("column_name,expected_category", [
("email", "email"),
("user_email", "email"),
("ssn", "ssn"),
("social_security_number", "ssn"),
...
])
def test_flags_expected_category(self, column_name, expected_category):
assert classify_column_pii(column_name) == expected_category
Each tuple becomes its own test ID in the pytest report, so a failure names the exact column-name pattern that broke rather than reporting one failure for a loop over 30-odd cases.
Mocking, and mostly not needing it#
pytest-mock is a declared dev dependency, but its mocker fixture
does not appear anywhere in tests/. The only mock-adjacent imports in
the whole suite are from unittest.mock import MagicMock in
tests/test_defensive_manager.py and tests/test_integration.py, and
neither file actually constructs a MagicMock anywhere, both imports are
dead. Where the suite needs a substitute implementation, it writes a small,
real class instead, a fake in Fowler’s terms:
class BadGenerator:
"""Always raises."""
def __init__(self, ref_data=None):
pass
def generate(self, count):
raise RuntimeError("primary exploded")
def test_fallback_on_primary_failure(self, defensive_manager):
defensive_manager.register_generator(
"users", BadGenerator, fallback_class=FallbackGenerator
)
result = defensive_manager.generate_safely("users", 4)
assert result.generator_used == "fallback"
This fits the project generally: most of the suite exercises real SQLite
databases through tmp_path rather than mocking a database connection,
and, as described above, executes real generated code rather than mocking
what a generator would return. A BadGenerator that genuinely raises is a
closer approximation of a broken user-contributed generator than a
Mock(side_effect=RuntimeError) would be, and it exercises the real
generate(count) call signature rather than an unconstrained mock that
would accept any call shape.
Integration tests against a real database#
Most of the suite runs entirely against SQLite, built fresh per test with
pytest’s tmp_path fixture (a pathlib.Path unique to that test,
cleaned up automatically). tests/test_metadata_only.py is a strong
example of testing a negative with this pattern: proving that
metadata_only=True issues zero SQL against table data cannot be done
by inspecting the returned SchemaInfo alone, since an absent field does
not prove a query was never sent, only that its result was discarded. The
test instead attaches a SQLAlchemy event listener to every engine created
during the call and captures the literal SQL text:
class _SqlCapture:
def __init__(self):
self.statements = []
def __call__(self, conn, cursor, statement, parameters, context, executemany):
self.statements.append(statement)
def __enter__(self):
event.listen(sa.engine.Engine, "before_cursor_execute", self)
return self
def __exit__(self, *exc_info):
event.remove(sa.engine.Engine, "before_cursor_execute", self)
@pytest.mark.asyncio
async def test_no_select_statements_touch_table_data(self, tmp_path):
...
with _SqlCapture() as capture:
schema_info = await analyzer.analyze_production_schema(conn_str, metadata_only=True)
offending = _select_statements_touching(capture.statements, TABLE_NAMES)
assert offending == [], f"metadata_only=True issued SQL against table data: {offending}"
The file also carries a deliberate contrast class,
TestDefaultModeStillReadsTableData, that asserts the opposite on the
default (non-metadata_only) path: SELECTs against table data ARE issued.
Its docstring explains why: if that assertion ever failed, it would mean the
SQL-capture mechanism itself was broken, which would make the
zero-queries test above a false pass rather than a real one. Testing the
capture mechanism’s ability to detect data access is what gives the
negative assertion teeth.
tests/test_postgres_integration.py goes one step further and tests
against a real PostgreSQL server rather than SQLite, because SQLite and
Postgres do not reflect identically (self-referential foreign keys, for
one, mirrored deliberately in this test’s schema). The whole module is
gated behind an environment variable so the default local and CI run is
unaffected:
TDW_TEST_POSTGRES_URL = os.environ.get("TDW_TEST_POSTGRES_URL")
pytestmark = pytest.mark.skipif(
not TDW_TEST_POSTGRES_URL,
reason="TDW_TEST_POSTGRES_URL is not set - skipping real Postgres integration test",
)
Locally that means docker compose up -d (docker-compose.yml at the
repo root) plus exporting the variable; in CI, a dedicated postgres job
in .github/workflows/ci.yml starts a postgres:16 service container,
waits on its health check, and runs
pytest tests/test_postgres_integration.py -v as its own step, separate
from the main SQLite-only matrix. It requires the postgres extra
(psycopg2-binary), installed only in that job.
Determinism and seeded tests#
Reproducibility is treated as a property to assert on, not an assumption.
tests/test_generated_execution.py defines one small helper used across
every generator-template test:
def _assert_seed_determinism(generator_cls, **extra_kwargs):
"""Two instances built with the same seed must produce identical output."""
first = generator_cls(seed=42, **extra_kwargs).generate(5)
second = generator_cls(seed=42, **extra_kwargs).generate(5)
assert first == second
TestSequentialPrimaryKeys extends the same idea to auto-increment
primary keys, checking that a counter continues across multiple
generate() calls on one instance ([1, 2, 3] then [4, 5], not
[1, 2, 3] then [1, 2]), and that a non-integer (UUID) primary key is
never coerced into that sequential strategy in the first place. This is the
test-level half of the byte-level reproducibility described in
Design Principles; the design document explains why
determinism matters for CI fixtures, this suite is what actually checks it
holds.
The CI matrix#
.github/workflows/ci.yml defines three jobs. The main test job runs
python -m pytest --tb=short -q across a matrix of
os: [ubuntu-latest, windows-latest] times
python-version: ["3.10", "3.11", "3.12"] with fail-fast: false, six
combinations, none of which touch Postgres. A separate coverage job runs
once, on ubuntu-latest with Python 3.12, installing pytest-cov on
top of the dev extra and running
pytest --cov=test_data_workbench --cov-report=term --cov-report=xml -q,
uploading coverage.xml as a build artifact. The third job, postgres,
is described above. Nothing in the workflow fails the build on a coverage
threshold; see Sharp edges and limits.
Learn more resources#
Official documentation#
How to use fixtures, the practical guide to the fixture mechanics this page builds on.
About fixtures, the design rationale, including the explicit contrast with xUnit-style
setUp/tearDown.How to mark test functions with attributes, covering custom markers,
skipif, and marker registration, exactly the mechanismasyncio/slowandpytest.mark.skipifuse in this repo.How to parametrize fixtures and test functions, covering
@pytest.mark.parametrizeas used intest_pii_detection.py.How to use temporary directories and files in tests, the
tmp_pathfixture used throughout for on-disk SQLite databases.How to capture stdout/stderr output, the
capsysfixture used intests/test_cli.pyto assert on console output, for example that it contains no emoji.pytest-asyncio: Concepts, explains strict versus auto mode and what auto mode actually changes, directly relevant to
asyncio_mode = "auto"in this project’s config.pytest-asyncio: Configuration reference, the full list of configuration options including
asyncio_mode.pytest-mock, the
mockerfixture this project depends on but does not currently use, useful background for anyone deciding whether to start using it or drop it.
Tutorials and blogs#
Martin Fowler, Mocks Aren’t Stubs, the source for the mock/stub/fake vocabulary used in The idea underneath above, and the article that names the classical-versus-mockist testing styles this project’s fake-preferring approach falls on the classical side of.
Brian Okken, pytest fixtures: nuts and bolts, revisited, a from-the-ground-up walkthrough of fixture mechanics from the author of Python Testing with pytest, useful for the fixture-composition patterns
conftest.pyin this repo relies on.
Videos#
“Sharing is Caring - Sharing pytest Fixtures” by Brian Okken (PyCascades 2023), on organising and reusing fixtures across a growing test suite, the same problem this project’s single shared
conftest.pyplus per-file local fixtures is an answer to.