Skip to content
Back to skills

Hex Test Capability Adapter

ASecurity

Use when testing the infrastructure adapter behind an `ICan<Verb>` capability port in one of its three flavours — a containerized backend through testcontainers, an HTTP gateway driven over a real `httpx2` client whose transport is a stub, or a pure-CPU parser, renderer or verifier — asserting the SDK-error to catalogue-exception translation row by row. Not a repository adapter, which is `hex-test-repository-contract`, and not a token verifier, which is `hex-test-restapi-auth`.

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

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 14, 2026

npx -y skills add wntic/pyhouse --skill hex-test-capability-adapter --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Hex Test Capability Adapter?

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

Security grade badge for Hex Test Capability Adapter
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/wntic-hex-test-capability-adapter/badge)](https://www.skillsdirectory.com/skills/wntic-hex-test-capability-adapter)

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-capability-adapter
description: Use when testing the infrastructure adapter behind an `ICan<Verb>` capability port in one of its three flavours — a containerized backend through testcontainers, an HTTP gateway driven over a real `httpx2` client whose transport is a stub, or a pure-CPU parser, renderer or verifier — asserting the SDK-error to catalogue-exception translation row by row. Not a repository adapter, which is `hex-test-repository-contract`, and not a token verifier, which is `hex-test-restapi-auth`.
---

# Hex Test — Capability Adapter

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

Produces one test file per capability adapter. Catches what unit-level coverage cannot: real SDK exception shapes, real upstream wire format, real crypto/parsing behavior, and the SDK-exception-to-domain-exception translator's mapping. This is the capability-adapter analogue of `hex-test-repository-contract` for repository adapters.

## When to use vs. neighbours

- A new or modified adapter under `infrastructure/<adapter>/` (not `infrastructure/postgres/repositories/`) → this skill, in whichever of the three flavours the adapter is.
- A repository adapter, relational or client-store → `hex-test-repository-contract`, not this skill. The port tells them apart — `IFooRepository` there, `ICan<Verb>` here.
- The adapter under test itself → `hex-capability-adapter`; the `ICan<Verb>` protocol it satisfies → `hex-domain-ports`.
- A fake of this capability that the unit-test layer consumes → `hex-test-application-handler`. The fake's exception contract must match what the integration test pins here.
- A handler test that consumes such a fake → `hex-test-application-handler`.
- The rollback / container `conftest.py` itself — including "add a testcontainer fixture" → `hex-test-integration-setup` (one-shot). This skill only consumes the resource fixtures it defines.
- HTTP-layer (route + OpenAPI) → `hex-test-restapi-endpoint`.
- The token-verifier adapter's own test, and every other auth fixture → `hex-test-restapi-auth` (`UNIT.md` for the verifier); it is this skill's pure-CPU flavor bound to auth, and it lives there so auth is taught in one place.
- The catalogue exception the translator raises, or a new row in it → `exception-catalog`.
- Speed targets, the substitution ladder and marker rules → `test-principles`.
- A pure domain entity, value object, enum or service, with no adapter and no IO → `hex-test-domain`.
- A flat-layered service's `*_client.py` tested over a stub transport → `flat-test-service-client`, in the `pyhouse-flat` plugin; this skill's HTTP-gateway flavor is for an adapter behind an `ICan<Verb>` port, not for a flat client class.

## Template(s) — pytest, `httpx2.MockTransport` under a real `httpx2` client

The test file mirrors the adapter's module (`test-principles`, *Test naming*).

### Pick the flavor

- **Containerized backend.** Adapter speaks to a service that runs in a Testcontainer (an object store, a broker, a cache). Drives the real client against the real container; consumes the resource fixtures from the integration conftest. **Lives under `tests/integration/<adapter>/`.**
- **HTTP gateway over a stub transport.** Adapter speaks `httpx2` to a third-party HTTP API. Drives a real `httpx2.AsyncClient` whose transport is the stub and asserts the request shape on the way out (`test-principles`, *Intercepting HTTP* rule 5) and the translated response on the way back. The adapter code is real; only the network is replaced. Nothing runs, so it is a boundary unit test (`test-principles`) and **lives under `tests/unit/infrastructure/<adapter>/`**, clear of the integration tree's container and migration fixtures.
- **Pure-CPU.** Adapter does no IO — a parser, a renderer over in-memory bytes, a verifier. Stdlib + the real parsing / crypto library. No fixtures, no containers. **Lives under `tests/unit/infrastructure/<adapter>/`.**

The flavor mirrors the adapter's form in `hex-capability-adapter` — an SDK client, an HTTP gateway (the one it templates) or sync pure CPU. If two flavors are asked for in one file, split — one file per adapter, but `integration/` for a test that needs a running backend and `unit/` for everything else means a containerized adapter and a gateway or CPU adapter live in different roots regardless.

### Containerized backend

`tests/integration/<adapter>/`, in the file mirroring the adapter's module, driving the real SDK client against
a container the integration conftest starts (`hex-test-integration-setup`), in a per-test namespace
(`test-principles` reliability rule 2) whose worked instance is that skill's key-value add-on. One happy-path test per public method, observed by reading the
backend directly; one test per error code the adapter translates, each triggered where the backend
really reports it and asserting the catalogue class and its `context`; and the fallback — a client the
backend refuses, or one pointed at no backend — landing on the upstream error (rule 8). A reversing method the
compensation path calls (a delete) pins success on a key that was never written, where the backend
answers success for it, rather than a not-found it can never raise.

### HTTP gateway over a stub transport

```
tests/unit/infrastructure/<adapter>/
└── test_http_foo_classifier.py
```

```python
import json
import uuid
from collections.abc import AsyncIterator

import httpx2
import pytest
from pydantic import SecretStr  # only where the upstream takes a credential

from myapp.domain.exceptions import (
    UpstreamError,
    ValidationError,  # only where the upstream judges input the caller can correct
)
from myapp.domain.foos import Foo, FooKind
from myapp.infrastructure.http import FooClassifierSettings, HttpFooClassifier

_BASE_URL = "https://classifier.example"
_SETTINGS = FooClassifierSettings(
    base_url=_BASE_URL,
    api_key=SecretStr("test-key"),  # only where the upstream takes a credential
    timeout_seconds=5.0,
)


@pytest.fixture
def upstream_requests() -> list[httpx2.Request]:
    return []


@pytest.fixture
def upstream_answers() -> list[httpx2.Response | httpx2.TransportError]:
    return []


@pytest.fixture
async def client(
    upstream_requests: list[httpx2.Request], upstream_answers: list[httpx2.Response | httpx2.TransportError]
) -> AsyncIterator[httpx2.AsyncClient]:
    def answer(request: httpx2.Request) -> httpx2.Response:
        upstream_requests.append(request)
        if not upstream_answers:
            pytest.fail(f"no answer queued for {request.method} {request.url}")
        next_answer = upstream_answers.pop(0)
        if isinstance(next_answer, httpx2.TransportError):
            raise next_answer
        return next_answer

    async with httpx2.AsyncClient(transport=httpx2.MockTransport(answer)) as http_client:
        yield http_client


async def test_classify_happy_path(
    upstream_requests: list[httpx2.Request],
    upstream_answers: list[httpx2.Response | httpx2.TransportError],
    client: httpx2.AsyncClient,
) -> None:
    upstream_answers.append(httpx2.Response(200, json={"kind": "B"}))
    adapter = HttpFooClassifier(client=client, settings=_SETTINGS)

    kind = await adapter.classify(Foo(id=uuid.uuid4(), name="alpha"))

    assert kind == FooKind.B
    [request] = upstream_requests
    assert (request.method, str(request.url)) == ("POST", f"{_BASE_URL}/classifications")
    assert json.loads(request.content) == {"name": "alpha"}
    assert request.headers["Authorization"] == "Bearer test-key"  # only where the upstream takes a credential


@pytest.mark.parametrize("body", [b'{"kind": "UNKNOWN"}', b"not json"], ids=["unknown-kind", "not-json"])
async def test_classify_unparseable_body_raises_upstream(
    upstream_answers: list[httpx2.Response | httpx2.TransportError], client: httpx2.AsyncClient, body: bytes
) -> None:
    upstream_answers.append(httpx2.Response(200, content=body))
    adapter = HttpFooClassifier(client=client, settings=_SETTINGS)
    foo = Foo(id=uuid.uuid4(), name="alpha")

    with pytest.raises(UpstreamError, match="malformed body") as exc_info:
        await adapter.classify(foo)

    assert exc_info.value.context == {"foo_id": str(foo.id)}


# only where the upstream judges input the caller can correct
async def test_classify_400_raises_validation(
    upstream_answers: list[httpx2.Response | httpx2.TransportError], client: httpx2.AsyncClient
) -> None:
    upstream_answers.append(httpx2.Response(400))
    adapter = HttpFooClassifier(client=client, settings=_SETTINGS)
    foo = Foo(id=uuid.uuid4(), name="alpha")

    with pytest.raises(ValidationError) as exc_info:
        await adapter.classify(foo)

    assert exc_info.value.context == {"foo_id": str(foo.id), "status": 400}


async def test_classify_503_raises_upstream(
    upstream_answers: list[httpx2.Response | httpx2.TransportError], client: httpx2.AsyncClient
) -> None:
    upstream_answers.append(httpx2.Response(503))
    adapter = HttpFooClassifier(client=client, settings=_SETTINGS)
    foo = Foo(id=uuid.uuid4(), name="alpha")

    with pytest.raises(UpstreamError) as exc_info:
        await adapter.classify(foo)

    assert exc_info.value.context == {"foo_id": str(foo.id), "status": 503}


async def test_classify_network_error_raises_upstream(
    upstream_answers: list[httpx2.Response | httpx2.TransportError], client: httpx2.AsyncClient
) -> None:
    upstream_answers.append(httpx2.ConnectError("boom"))
    adapter = HttpFooClassifier(client=client, settings=_SETTINGS)
    foo = Foo(id=uuid.uuid4(), name="alpha")

    with pytest.raises(UpstreamError, match="unreachable") as exc_info:
        await adapter.classify(foo)

    assert exc_info.value.context == {"foo_id": str(foo.id)}


async def test_classify_read_timeout_raises_upstream(
    upstream_answers: list[httpx2.Response | httpx2.TransportError], client: httpx2.AsyncClient
) -> None:
    upstream_answers.append(httpx2.ReadTimeout("slow"))
    adapter = HttpFooClassifier(client=client, settings=_SETTINGS)
    foo = Foo(id=uuid.uuid4(), name="alpha")

    with pytest.raises(UpstreamError, match="unreachable") as exc_info:
        await adapter.classify(foo)

    assert exc_info.value.context == {"foo_id": str(foo.id)}
```

The stub is the client's transport, built in the `client` fixture: its handler records each request in
`upstream_requests`, which a test reads back for what was sent (rules 2, 3 and 5 of `test-principles`'
*Intercepting HTTP*), and answers it with the next of the `upstream_answers` the test queued — a response,
or a transport error it raises, which the client hands the adapter carrying the request. A sequence, such
as a 429 and then a 200 for an adapter that retries, is two answers queued in order. A request the test
queued no answer for fails the test, even under a caller that catches every exception, so nothing the
test did not expect is answered quietly.

Both halves of parsing are one parametrized test — a `200` whose body is not JSON, and one carrying a
value the domain type refuses: without the adapter's translation around the parse, the decode error or
the `ValueError` escapes and the test reds. Where the adapter sends a credential, the happy-path test
also asserts the header carrying it against the literal test value (`test-principles`, *Intercepting HTTP* rule 5) — the one assertion that reds on a secret sent masked or not at all. Where one class and
one `context` cover several arms, the test tells them apart by the part of the message each sets
(`test-principles`, *Assert strength* recipe 6).

The outgoing body is compared as parsed JSON, never as bytes: separators and key order are the
client library's serialisation choice, not the upstream's contract, and a byte comparison breaks on a
library upgrade that changed nothing on the wire that matters.

### Pure-CPU

`tests/unit/infrastructure/<adapter>/`, in the file mirroring the adapter's module. The worked instance is the token verifier's
test in `hex-test-restapi-auth`'s `UNIT.md`: module-level settings and inputs, the adapter built inline
in each test, real library calls, and one test per check, asserting its `context` (rule 15).

## Other bindings

- **Another stub mechanism.** A stub server on a loopback port, or recorded cassettes, hold the adapter
  real while the socket is not, as the stub transport does, which is all rules 8 and 13 ask for; what
  changes is how the stub is installed and how the outgoing request is read back.
- **A project already on `respx`.** It keeps it — `respx` through `pytest-httpx2` over `httpx2`, routes
  answered with `.respond(...)` and read back from each route's call record. The cases and rule 13 are
  unchanged.
- **An adapter on `httpx`, httpx2's predecessor, or on `aiohttp`.** Under `httpx` the API is the same
  under the old import, `httpx.MockTransport` included, so only the import changes. Under `aiohttp`,
  which takes no transport of this kind, `aioresponses` registers the routes. The cases and rule 13 are
  unchanged.
- **A pre-provisioned backend instead of a disposable container.** Provisioning is
  `hex-test-integration-setup`'s (`## Other bindings`). The per-test namespace and its teardown (rule
  11) get *more* load-bearing, not less — a shared instance keeps whatever a test leaves behind.

## Rules

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

### Form

1. **One test file per adapter**, mirroring its module (`test-principles`, *Test naming*).
2. **Path follows the flavor, and the flavor follows whether the test needs something running.** A test that needs a live backend is an integration test (`tests/integration/<adapter>/`). A test whose socket is intercepted, and a pure-CPU adapter that touches nothing, need nothing running and are unit tests (`tests/unit/infrastructure/<adapter>/`) — the intercepted one is `test-principles`' boundary unit. Putting either under `integration/` makes it inherit that tree's container and migration fixtures and pay for infrastructure it never uses. A pure-CPU adapter's test takes neither interception nor a container; an adapter whose test seems to need one has IO and is not the CPU flavor.
3. **Module-level constants, not fixtures, for settings, keypairs and fixed inputs** in the HTTP-gateway and CPU flavors — anything with no setup or teardown (`test-principles`, *Fixture vs. builder*). Construct once at module scope.

### Coverage

4. **Every public method gets a happy-path test.** Drive the adapter; assert the observable side effect (the object exists in the backend, the request matches the upstream's contract, the return value equals a literal).
5. **Every row of the adapter's error mapping gets a dedicated test.** The bug class "translator handles error code X but not Y" only surfaces when each row is exercised. A row the backend never actually reports on a given call — an object store's delete of an absent key answers success — is exercised on a call where it does, and the call that cannot raise it pins its success instead.
6. **An adapter call's failure is asserted on the translated catalogue exception, and `assert exc_info.value.context["<key>"] == <value>` on every one.** Never the SDK's own class — translation at the boundary is `exception-catalog`'s, and asserting the SDK class passes an adapter that never translated. The context map is the load-bearing contract this test exists to pin. This is the capability-adapter analogue of the `context["constraint"]` rule in `hex-test-repository-contract`. The fake-based handler test cannot verify this — only this test can.
7. **A verification probe that reads the backend directly names the SDK's own error class**, as the narrowest class the probe can raise (`test-principles` *Assert strength* recipe 6), and asserts the error code that distinguishes "absent" from "unreachable" or "unauthorized". This is the one place an SDK exception is legitimate in a test — the probe is not going through the adapter, so there is nothing translated to assert on. Asserting on an adapter call still follows rule 6: the translated `MyappError` subclass, never the SDK's class.
8. **A failure that never reaches the upstream is covered too, and lands on the upstream error.** The HTTP-gateway flavor needs a connect-refused and a read-timeout case (`ConnectError` / `ReadTimeout` here) asserting `UpstreamError`; the containerized flavor needs a wrong-container or wrong-credential case asserting the fallback translation. Every row of the adapter's error mapping can pass while the transport arm is unexercised, which is the arm that fires in a real outage.

### Real, not mocked

9. **The real SDK client and the real adapter; only the socket or the backend is substituted** (`test-principles`, rungs 1–2), and no fake of the port here — fakes are `hex-test-application-handler`'s.

### Containerized flavor specifics

10. **Take the resource fixture, not raw settings.** Containerized adapters need a live client and settings naming the test's own namespace (the key-value add-on's `redis_client`, for one). Both come from the integration conftest — session scope for the container; function scope for the namespace, and for the client where its teardown is what empties it, as `redis_client`'s does. The one exception is rule 8's refused or unreachable client, which the test builds itself.
11. **Isolate by a per-test namespace with teardown; there is no rollback at this layer.** An object store, a cache or a queue has no nested transaction to discard. Which fixture owns the per-test namespace and which the session-scoped container and client → `hex-test-integration-setup` (its scope split).
12. **Don't bypass the adapter to drive setup.** For success assertions, you may inspect the backend directly — that is the observation. But for setup that exists to drive the test, go through the adapter (`adapter.upload(...)` then `adapter.delete(...)`).

### HTTP-gateway flavor specifics

13. **The interception follows `test-principles`, *Intercepting HTTP* rules 1–7**; this skill's rule 8 names the two transport cases this flavor needs.

### CPU flavor specifics

14. **Real parsing, real crypto.** Drive the actual library: feed a parser real inputs and assert literal outputs; for a verifier, generate a real key at module scope and sign with the real library. Never hand the adapter a pre-baked value the library never produced.
15. **One `test_*` per check the adapter makes** — each input condition that fails, whether it lands on its own library-exception arm, on a catch-all arm shared with others, or on a guard the adapter raises itself. Each test triggers exactly one and asserts the `context` value that case sets (Rule 6). Counting `raise` statements undercounts: one catch-all arm covering three checks is three cases.

## Inlined typing / import rules

- `pytest`, `httpx2` (its `MockTransport` is the HTTP flavor's stub), the real parsing / crypto library (CPU flavor), the real SDK, `myapp.domain.*`, `myapp.infrastructure.<adapter>` — imported from the package, not its inner module (`python-packaging`). No `myapp.application.*`, no `myapp.restapi.*`.
- Full annotations on every test signature, and a yielding fixture is annotated `AsyncIterator[T]` / `Iterator[T]`. Resource-fixture types (`Session`, `httpx2.AsyncClient`) come from the SDK / library, not the project.
- No `from __future__ import annotations`.

## Hard stops

- Nothing up-tree provides the live backend a containerized flavor drives (the key-value add-on's fixtures, for one) → stop, use `hex-test-integration-setup` to extend the fixtures first; what the flavor needs is the running backend, not a particular fixture name.
- Asked to mock the adapter itself → stop, use `hex-test-application-handler`.
- A test references FastAPI, `httpx2.AsyncClient` over `ASGITransport` or the DI container → stop, use `hex-test-restapi-endpoint`.
- Asked for a token-verifier test here → stop, use `hex-test-restapi-auth`; the flavor is this skill's, the example is not, and duplicating it teaches auth twice.

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.