Skip to content
Back to skills

Hex Test Repository Contract

ASecurity

Use when testing an aggregate's repository adapter — an `IFooRepository` implementation — against the real backend rather than a fake, in both halves of that contract, relational over Postgres through the `session_factory` rollback fixture (constraints on insert and on update, cascades, the pinned constraint name) and client-store (key-value, document) isolated by a fresh per-test namespace. Not a flat-layered service's storage-package write path, which is `flat-test-persistence`, in the `pyh...

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 14, 2026
developmentpythongosqlfastapidockertestingapidatabasebackend

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 14, 2026

npx -y skills add wntic/pyhouse --skill hex-test-repository-contract --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Hex Test Repository Contract?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Hex Test Repository Contract
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/wntic-hex-test-repository-contract/badge)](https://www.skillsdirectory.com/skills/wntic-hex-test-repository-contract)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: hex-test-repository-contract
description: Use when testing an aggregate's repository adapter — an `IFooRepository` implementation — against the real backend rather than a fake, in both halves of that contract, relational over Postgres through the `session_factory` rollback fixture (constraints on insert and on update, cascades, the pinned constraint name) and client-store (key-value, document) isolated by a fresh per-test namespace. Not a flat-layered service's storage-package write path, which is `flat-test-persistence`, in the `pyhouse-flat` plugin, not a capability port's adapter, which is `hex-test-capability-adapter`, and not the fixtures it consumes — containers, the migration run and `session_factory` are `hex-test-integration-setup`'s.
---

# Hex Test — Repository Contract

Consult `test-principles` for the testing constitution. Where this skill contradicts `test-principles`, the constitution wins.

One integration-test file per repository adapter, driven against the **real** backend. This layer catches
what unit coverage cannot.

## When to use vs. neighbours

- A repository adapter on a relational store, under `infrastructure/postgres/repositories/` → the **relational** half of this skill.
- A repository adapter on a client-style store (key-value, document), under `infrastructure/<store-kind>/repositories/` → the **client-style** half of this skill.
- An adapter behind an `ICan<Verb>` capability port rather than an `IFooRepository` → `hex-test-capability-adapter`, not this skill — even when it is driven against a container.
- Schema-only checks (an index exists, a migration carries data correctly) → separate flat files under `tests/integration/postgres/` (`test_indexes.py`, `test_<NNNN>_migration.py`) that take the `run_alembic` fixture (`hex-test-integration-setup`), not `session_factory`.
- HTTP-layer integration (route, OpenAPI) → `hex-test-restapi-endpoint`; the authenticated and role-gated variants of those tests → `hex-test-restapi-auth`.
- The rollback `conftest.py`, the session containers and the migration run themselves — including "add a testcontainer fixture for Postgres" → `hex-test-integration-setup`. This skill consumes `session_factory`; it never defines it.
- A pure domain test, with no backend at all → `hex-test-domain`.
- The repository being tested → `hex-persistence` (relational, with `REPOSITORY.md` and `TABLE.md`) or `hex-store-repository` (client store).
- An in-memory fake of the same protocol, for handler unit tests → `hex-test-application-handler`.
- The exceptions the adapter translates integrity errors and SDK errors into → `exception-catalog`.
- Speed targets, fixture placement and the substitution ladder → `test-principles`.
- The write path is a flat-layered service's own data-access package rather than a hexagonal `IFooRepository` adapter → `flat-test-persistence`, in the `pyhouse-flat` plugin; it consumes `flat-test-integration-setup`'s fixtures there, not `session_factory`.

## Template(s) — pytest, testcontainers, SQLAlchemy async over Postgres, redis-py

- **Relational** — real `UNIQUE` and `FK` violations, real `ON DELETE CASCADE` semantics, the
  `IntegrityError`-to-domain-exception translator's constraint-name map, and the `updated_at` the
  repository writes on update.
- **Client-style** (key-value, document) — the entity-record mapping round-trip and the
  SDK-error-to-domain-exception translation the adapter performs at its boundary. There is no SQL
  transaction and no constraint map here, so both the isolation mechanism and the load-bearing assertion
  are different.

The two halves share their purpose and their shape; they differ in isolation, and that difference is the
first thing to get right.

### Isolation — the one thing that differs

**Relational: transaction rollback.** Every test takes `session_factory: async_sessionmaker[AsyncSession]`, the fixture from
`hex-test-integration-setup`. The database is empty at test start and everything the test wrote is
discarded at teardown. No marker, no other DB fixture.

**Client-style: a fresh namespace.** A client store has no nested transaction, so the `session_factory`-rollback model does not apply (the rollback fixture is relational-only). **Each test owns a namespace** (rule 14), created or emptied in a fixture and dropped or emptied at teardown. Which conftest holds that fixture and which holds the session-scoped container is `hex-test-integration-setup`'s scope split, not this skill's.

### Relational

```
tests/integration/postgres/
└── test_<aggregate_snake>_repository.py
```

Default is one file per repository. **Concern-split when the single file stops being navigable** — when finding the test for one method means scrolling past several unrelated concerns, or when a change to one concern keeps forcing rereads of the others. The trigger is that loss of navigability, not a line count: a flat file of twenty near-identical `create` cases stays readable far longer than a short one mixing create, update and filter semantics. When it splits, **the filename names the concern** (`test_<aggregate>_repository_create.py`, `test_<aggregate>_repository_filters.py`), so the split is navigable in the directory listing and not just inside the file.

### Standard CRUD test file — SQLAlchemy async session factory

One test group per method the port declares and no others (rule 1); the conflict tests exist only where
the table has a natural key (`hex-persistence`, `TABLE.md`).

```python
import uuid
from dataclasses import asdict
from datetime import UTC, datetime

import pytest
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from myapp.domain.exceptions import (
    FooConflictError,  # only where Foo has a natural key
    NotFoundError,
    UpstreamError,
)
from myapp.domain.foos import (
    Foo,
    FooListFilter,  # only with a paged list
    FooSort,  # only with a paged list
)
from myapp.infrastructure.postgres.repositories import FooRepository
from myapp.infrastructure.postgres.tables.foos import foos_table

_PLANTED = datetime(2000, 1, 1, tzinfo=UTC)


def _foo(*, name: str = "alpha") -> Foo:
    return Foo(id=uuid.uuid4(), name=name, note="a note")


async def test_create_then_get_returns_every_field(session_factory: async_sessionmaker[AsyncSession]) -> None:
    repository = FooRepository(session_factory=session_factory)
    foo = _foo()

    await repository.create(foo)

    assert asdict(await repository.get_by_id(foo.id)) == asdict(foo)


async def test_get_by_id_of_absent_row_raises_not_found(session_factory: async_sessionmaker[AsyncSession]) -> None:
    repository = FooRepository(session_factory=session_factory)
    missing = uuid.uuid4()

    with pytest.raises(NotFoundError) as exc_info:
        await repository.get_by_id(missing)

    assert exc_info.value.context["id"] == str(missing)


# only where the port declares update
async def test_update_persists_the_new_values(session_factory: async_sessionmaker[AsyncSession]) -> None:
    repository = FooRepository(session_factory=session_factory)
    foo = _foo(name="alpha")
    await repository.create(foo)
    foo.name = "beta"
    foo.note = "another note"

    await repository.update(foo)

    assert asdict(await repository.get_by_id(foo.id)) == asdict(foo)


# only where the port declares update
async def test_update_of_absent_row_raises_not_found(session_factory: async_sessionmaker[AsyncSession]) -> None:
    repository = FooRepository(session_factory=session_factory)
    foo = _foo()

    with pytest.raises(NotFoundError) as exc_info:
        await repository.update(foo)

    assert exc_info.value.context["id"] == str(foo.id)


# only where the port declares delete
async def test_delete_removes_the_row(session_factory: async_sessionmaker[AsyncSession]) -> None:
    repository = FooRepository(session_factory=session_factory)
    foo = _foo()
    await repository.create(foo)

    await repository.delete(foo.id)

    with pytest.raises(NotFoundError) as exc_info:
        await repository.get_by_id(foo.id)

    assert exc_info.value.context["id"] == str(foo.id)


# only where the port declares delete
async def test_delete_of_absent_row_raises_not_found(session_factory: async_sessionmaker[AsyncSession]) -> None:
    repository = FooRepository(session_factory=session_factory)
    missing = uuid.uuid4()

    with pytest.raises(NotFoundError) as exc_info:
        await repository.delete(missing)

    assert exc_info.value.context["id"] == str(missing)


async def test_read_and_write_against_unreachable_store_raise_upstream_error() -> None:
    dead = create_async_engine("postgresql+asyncpg://u:p@127.0.0.1:1/none")  # nothing listening
    foo = _foo()
    try:
        repository = FooRepository(session_factory=async_sessionmaker(dead))
        with pytest.raises(UpstreamError) as read:
            await repository.get_by_id(foo.id)
        with pytest.raises(UpstreamError) as write:
            await repository.create(foo)
    finally:
        await dead.dispose()

    assert read.value.context == {"id": str(foo.id)}
    assert write.value.context == {"id": str(foo.id)}


# only where Foo has a natural key
async def test_duplicate_name_on_insert_raises_conflict(session_factory: async_sessionmaker[AsyncSession]) -> None:
    repository = FooRepository(session_factory=session_factory)
    await repository.create(_foo(name="alpha"))

    with pytest.raises(FooConflictError) as exc_info:
        await repository.create(_foo(name="alpha"))

    assert exc_info.value.context["constraint"] == "uq_foos_name"


# only where Foo has a natural key and the port declares update
async def test_duplicate_name_on_update_raises_conflict(session_factory: async_sessionmaker[AsyncSession]) -> None:
    repository = FooRepository(session_factory=session_factory)
    await repository.create(_foo(name="alpha"))
    second = _foo(name="beta")
    await repository.create(second)

    second.name = "alpha"
    with pytest.raises(FooConflictError) as exc_info:
        await repository.update(second)

    assert exc_info.value.context["constraint"] == "uq_foos_name"


# only where the port declares update
async def test_update_writes_updated_at(session_factory: async_sessionmaker[AsyncSession]) -> None:
    repository = FooRepository(session_factory=session_factory)
    foo = _foo()
    await repository.create(foo)
    async with session_factory() as session:
        await session.execute(update(foos_table).where(foos_table.c.id == foo.id).values(updated_at=_PLANTED))
        await session.commit()
    foo.name = "beta"

    await repository.update(foo)

    async with session_factory() as session:
        written: datetime = (
            await session.execute(select(foos_table.c.updated_at).where(foos_table.c.id == foo.id))
        ).scalar_one()
    assert written > _PLANTED


# only where the port declares a lookup by a natural key
async def test_get_by_name_returns_match(session_factory: async_sessionmaker[AsyncSession]) -> None:
    repository = FooRepository(session_factory=session_factory)
    await repository.create(_foo(name="alpha"))

    loaded = await repository.get_by_name("alpha")
    assert loaded is not None
    assert loaded.name == "alpha"


# only where the port declares a lookup by a natural key
async def test_get_by_name_returns_none_when_absent(
    session_factory: async_sessionmaker[AsyncSession],
) -> None:
    repository = FooRepository(session_factory=session_factory)

    assert await repository.get_by_name("alpha") is None


# only where the port declares a paged, sorted list
async def test_list_respects_pagination_and_sort(session_factory: async_sessionmaker[AsyncSession]) -> None:
    repository = FooRepository(session_factory=session_factory)
    await repository.create(_foo(name="c"))
    await repository.create(_foo(name="a"))
    await repository.create(_foo(name="b"))

    page = await repository.list(filter=FooListFilter(sort=FooSort.NAME_ASC, limit=2, offset=0))
    assert [foo.name for foo in page] == ["a", "b"]


# only where the port declares a paged, sorted list
async def test_count_applies_the_filter(session_factory: async_sessionmaker[AsyncSession]) -> None:
    repository = FooRepository(session_factory=session_factory)
    await repository.create(_foo(name="a"))
    await repository.create(_foo(name="b"))

    assert await repository.count(filter=FooListFilter(name="a")) == 1
```

**Compare every field, never the entity.** An entity's equality is by id (`hex-domain-model`), so
`loaded == foo` passes a repository that maps the id and drops or swaps every other column;
`asdict(...)` on both sides compares what the row actually carried back. The builder gives every
optional field a non-default value, and the update test changes every field `update` writes; otherwise
a column the mapping drops comes back as its default and the comparison passes.

The planting `UPDATE` is not seed data (rule 9): no repository method can set the column, which is the
point.

**Seed 3, page 2.** The page size must be *smaller* than the seeded set or the test proves nothing: at
`limit=3` the page holds every row, so the same assertion passes a repository that ignores the bound
entirely. 3 and 2 are the smallest pair that fails on a missing `LIMIT` and, because the rows are seeded
out of order (`c`, `a`, `b`) and asserted in order, on a missing `ORDER BY` as well.

### `test_migration_round_trip.py` — every revision undoes and redoes

```python
import subprocess
from collections.abc import Callable


def test_migrations_round_trip(
    run_alembic: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
    down = run_alembic("downgrade", "base")
    assert down.returncode == 0, down.stderr

    up = run_alembic("upgrade", "head")
    assert up.returncode == 0, up.stderr
```

The session starts at head, so this is head → base → head against the real database: a `downgrade()`
that fails, or leaves behind an object the next `upgrade()` trips over, reds here. It ends at head, so
the tests that assume head still find it; a suite run in parallel gives it a database of its own.

### Cascade — an owned child collection

For an aggregate that owns a child collection in a table that cascades from its own, rule 7's test
creates the parent, adds a child through the repository, deletes the parent and asserts through the
repository that the parent's child count is zero; a child looked up under the wrong parent raises the
not-found exception. An aggregate with no owned children has neither test.

### Client-style store — redis-py

```
tests/integration/
├── conftest.py                         # + redis_url (session), redis_client (per test, emptied after)
│                                       #   — hex-test-integration-setup's
└── redis/
    └── test_baz_repository.py
```

A client store gives the test an **SDK client** and a **namespace** the test owns. Redis is the worked
binding — `hex-store-repository`'s own — whose adapter fixes its key prefix in code, so the namespace
a test owns is the suite's own container, emptied after each test. The
adapter is `hex-store-repository`'s `BazRepository`, whose `IBazRepository` declares three verbs, so the
contract is those three (rule 1): create, fetch by id and delete, each with its absent-record case, plus
the translation of a store failure on a read and a write.

The fixtures — the Redis container for the run (`redis_url`) and the per-test client whose teardown
empties it (`redis_client`) — are `hex-test-integration-setup`'s, in its `CONFTEST.md`: the client sits
up-tree beside the session half because `container` binds it too. This skill writes only the test
module below.

### `test_baz_repository.py`

```python
import uuid
from dataclasses import asdict

import pytest
from redis.asyncio import Redis

from myapp.domain.bazs import Baz
from myapp.domain.exceptions import NotFoundError, UpstreamError
from myapp.infrastructure.redis.repositories import BazRepository


def _baz(*, name: str = "alpha") -> Baz:
    return Baz(id=uuid.uuid4(), name=name)


async def test_create_then_get_returns_every_field(redis_client: Redis) -> None:
    repository = BazRepository(client=redis_client)
    baz = _baz()

    await repository.create(baz)

    assert asdict(await repository.get_by_id(baz.id)) == asdict(baz)


async def test_create_writes_under_the_documented_key(redis_client: Redis) -> None:
    repository = BazRepository(client=redis_client)
    baz = _baz()

    await repository.create(baz)

    assert await redis_client.exists(f"bazs:{baz.id}") == 1


async def test_get_by_id_of_absent_record_raises_not_found(redis_client: Redis) -> None:
    repository = BazRepository(client=redis_client)
    missing = uuid.uuid4()

    with pytest.raises(NotFoundError) as exc_info:
        await repository.get_by_id(missing)

    assert exc_info.value.context["id"] == str(missing)


async def test_delete_removes_the_record(redis_client: Redis) -> None:
    repository = BazRepository(client=redis_client)
    baz = _baz()
    await repository.create(baz)

    await repository.delete(baz.id)

    with pytest.raises(NotFoundError) as exc_info:
        await repository.get_by_id(baz.id)

    assert exc_info.value.context["id"] == str(baz.id)


async def test_delete_of_absent_record_raises_not_found(redis_client: Redis) -> None:
    repository = BazRepository(client=redis_client)
    missing = uuid.uuid4()

    with pytest.raises(NotFoundError) as exc_info:
        await repository.delete(missing)

    assert exc_info.value.context["id"] == str(missing)


async def test_read_and_write_against_unreachable_store_raise_upstream_error() -> None:
    dead = Redis.from_url("redis://127.0.0.1:1/0")  # nothing listening
    baz = _baz()
    try:
        repository = BazRepository(client=dead)
        with pytest.raises(UpstreamError) as read:
            await repository.get_by_id(baz.id)
        with pytest.raises(UpstreamError) as write:
            await repository.create(baz)
    finally:
        await dead.aclose()

    assert read.value.context["key"] == f"bazs:{baz.id}"
    assert write.value.context["key"] == f"bazs:{baz.id}"
```

The key test reads the raw key through the test's client: an adapter whose key layout drifted would
still round-trip through its own `get_by_id`, and only a read that goes around it proves where the
record landed — which is what every record already stored depends on.

## Other bindings

- **A pre-provisioned database instead of a disposable container.** Provisioning is
  `hex-test-integration-setup`'s (`## Other bindings`); every rule below is unchanged.
- **An in-process engine (SQLite) for speed.** The rollback shape survives; the contract does not.
  Constraint names, cascade semantics and the error the driver raises all differ, so rules 4, 5 and 7 —
  the assertions this layer exists for — would be pinning a database the service never runs on. A smoke
  suite, never the repository contract.
- **Another engine or driver.** The constraint names, the error code and the attribute the adapter's
  translator reads off it change together (`hex-persistence`); what the test must cover — insert *and*
  update on every unique field, one test per cascade, found and not-found per lookup — does not.
- **Another client store** — a document store, a wide-column store, or an index kept beside the
  authoritative store. The SDK client, its testcontainer and the namespace token change (a collection,
  an index, a database); the session/per-test split, the per-test namespace with teardown, the
  round-trip, ordering and translation assertions do not. Where no testcontainers module exists for the store, a pinned
  `DockerContainer` with an explicit wait strategy is the same fixture.

## Rules

Follow `test-principles` for the testing constitution. Follow `naming` for names and `exception-catalog` for the error catalogue and boundary translation.

### Both halves

1. **Exercise exactly the protocol the port declares** — every method, CRUD verbs and the port's own
   alike (`delete_by_<field>`, range/scan), each with its absent-record case, and no test for a method
   the port does not declare. A verb that promises an order asserts that order, not just membership. An
   out-of-order write and a batch write, where the adapter takes them → `test-principles`,
   *Datastore contract* rules 6 and 7.

### Relational

2. **Every test takes the rollback-scoped handle the integration setup provides — `session_factory` under this binding (`hex-test-integration-setup`) — and opens nothing of its own** (`test-principles` reliability rule 6). The one exception is a handle to a store nothing listens on, which writes nothing (rule 4's forced error).
3. Follow `test-principles` for the `_<aggregate>()` builder form. Defaults must be valid; no-override construction succeeds.
4. Every conflict pins its constraint's name on the translated exception → `test-principles`, *Datastore contract* rule 2; here `assert exc_info.value.context["constraint"] == "<constraint_name>"` on the `ConflictError` subclass the translator raises for it (`FooConflictError` for `uq_foos_name`). A forced driver error on a write and a read → *Datastore contract* rule 4.
5. Insert and update paths for every unique field → `test-principles`, *Datastore contract* rule 3. Where
   a create a delivered unit drives does nothing on a conflict on `id` (`hex-persistence`
   `REPOSITORY.md` rule 8), `test_create_with_an_existing_id_changes_nothing` creates a second entity
   sharing the first's id and differing in every other field, and asserts no raise and the stored row
   keeps the first's values, beside the natural-key conflict that still raises.
6. **The update timestamp is not an entity field** (`hex-domain-model`, Entity rule 6), so it is read from the row through the test's own session handle, against a value planted before the act → `test-principles` reliability rule 4.
7. **Every cascade gets its own test**, `test_cascade_delete_removes_<child>` (`test-principles`, *Test naming*). Test the count after parent-delete is zero — only this proves the schema's `ON DELETE CASCADE` works.
8. **Every `get_by_<field>` gets both a found and a not-found test.**
9. Seed data on the table under test goes through the repository's own `create`, never a raw INSERT, and a row `Foo` only references is seeded raw → `test-principles`, *Datastore contract* rule 5; the ban binds only a test of the repository under test.
10. **No web framework, no HTTP client, no DI container in this file.** The test imports the repository class, takes the session factory, calls methods and asserts. Reaching the adapter through a route tests the route as well, and a failure no longer says which of the two is broken; the HTTP surface is `hex-test-restapi-endpoint`'s.
11. **A test that moves the schema takes the migration runner (`run_alembic`, `hex-test-integration-setup`), never the fixture that assumes head**, and lives in its own flat file (`tests/integration/postgres/test_<NNNN>_migration.py`); a downgrade underneath an ordinary repository test takes the rest of its file with it. The round trip `persistence` rule 20 requires is one of those files, always present.
12. **Where the store has a unit of work, its implementation gets one test over `session_factory`**: an exception inside the block leaves nothing from any member, read back through a fresh session; the session-injected form is tested through the unit of work, never with a hand-opened session.

### Client-style store

13. Each test runs against the real store, never a fake or a mock → `test-principles`, *Datastore contract* rule 1; as in the relational half, the file holds no web framework, HTTP client or DI container (rule 10).
14. **Isolate by a per-test namespace, not rollback** (`test-principles` reliability rule 2) — the suite's own container emptied after each test (`redis_client`'s teardown) where the adapter fixes its namespace in code. There is no transaction to roll back, so no `session_factory` here.
15. **The container is session-scoped; the namespace is function-scoped** — `test-principles`, *Fixture scope rules*. A store the environment supplies instead is opted into as `test-principles` reliability rules 1 and 6 state.
16. **Assert the entity↔record mapping round-trips.** What was written comes back as the same entity — every field the record carries, compared field by field, since entity equality is by id. A compound return asserts every element it carries, not just the entity.
17. A forced store failure is asserted on the catalogue's upstream class and the `context` the adapter sets — a write and a read (`test-principles`, *Datastore contract* rule 4) — and an absent record on the not-found class (`exception-catalog`); a store with no constraints has no rule-4 constraint pin.
18. **Assert where the record landed.** The key layout is the adapter's persisted contract (its key prefix is a module constant, `hex-store-repository` rule 5), and an adapter that changes it still round-trips through its own reads; one test reads the raw record through the client, under the key the adapter documents, to prove the layout is the one used.

## Inlined typing / import rules

- No `myapp.application.*`, no `myapp.restapi.*`.
- Full annotations on every test signature including `session_factory: async_sessionmaker[AsyncSession]`.
- Builder `_<aggregate>()` returns the entity type; overrides keyword-only.
- No `from __future__ import annotations`.

Where a client-store test needs the client and a settings object both, they arrive as two fixtures, each
annotated with its own type — never a bare tuple (`python-style`) — and a yielding fixture uses
`AsyncIterator[T]` / `Iterator[T]`.

## Hard stops

- A relational test and nothing up-tree provides a session handle whose writes are discarded when the test ends (`session_factory` under this catalogue's binding), or a client-store test and nothing up-tree provides the store client emptied per test → stop, use `hex-test-integration-setup`; what is missing is the isolation guarantee, not a fixture name.
- A test references FastAPI, `httpx2` or the DI container → stop, use `hex-test-restapi-endpoint`.
- Asked to mock the store SDK or assert against a fake → stop, use `hex-test-application-handler` at the handler-unit layer; this layer drives the real backend.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.