StatifierPersistence.Testing.StorageConformance (StatifierPersistence v0.12.0)

Copy Markdown View Source

The conformance suite every StatifierPersistence.Storage.Adapter must pass. Ships in lib/ (ADR-0003 decision 5, st-ADR-0053's shape) so an adapter in another package runs the identical suite:

defmodule MyApp.EctoAdapterConformanceTest do
  use StatifierPersistence.Testing.StorageConformance,
    adapter: MyApp.EctoAdapter,
    opts: [repo: MyApp.Repo]
end

Every test generated here goes through either the adapter directly (the callbacks in StatifierPersistence.Storage.Adapter) or through StatifierPersistence.Storage, the guarded facade every adapter sits behind. Nothing here reaches into test/ - the fixtures come from StatifierPersistence.Testing.Charts, the sibling module this one is named alongside, so the one-way StatifierPersistence.Testing.* rule (ADR-0003 decision 5) holds for both.

init/1 is called once per test, in setup, so every generated test starts from a fresh handle. When the adapter under test exports the optional StatifierPersistence.Storage.Adapter.isolate/1 callback, setup calls it right after init/1 - the hook an adapter backed by a shared resource (a database connection, a sandbox checkout) uses to wrap the test that follows in its own isolated unit. An adapter that exports no such callback, like StatifierPersistence.Storage.InMemory, is unaffected: the check is a function_exported?/3 guard, not a requirement.

That setup is the only callback this module registers, and it writes nothing: it opens a handle and, when the adapter exports isolate/1, isolates it. Every row a generated case needs it inserts inside the case body, so the first write against the adapter is always the running test's own.

That matters because ExUnit runs setup callbacks in the order they are defined, and the ones this template registers are defined where you write use. A host whose adapter needs a per-test binding established before any write - a session parameter, a connection-scoped setting, a sandbox checkout - must define that setup above the use:

defmodule MyApp.EctoAdapterConformanceTest do
  setup do
    MyApp.Tenant.bind!(...)
    :ok
  end

  use StatifierPersistence.Testing.StorageConformance,
    adapter: MyApp.EctoAdapter,
    opts: [repo: MyApp.Repo]
end

A setup written below the use runs after every callback this template registers. What the template guarantees such a callback is that no row has been written yet - not that nothing has run: the handle in context.store is already open, and already isolated, by the time it is called.

The optional execution metadata map (ADR-0006) is treated differently again: its cases are generated for every adapter and assert the answer this adapter gives - a round trip when it declares support through StatifierPersistence.Storage.Adapter.supports_metadata?/1, a {:error, :metadata_unsupported} refusal at open when it does not. Refusing is conformance; silently dropping the map is not, and that is the failure these cases exist to catch.

The optional StatifierPersistence.Storage.Adapter.lock_execution/3 gets the same treatment at generation time: when the adapter under test exports it, the suite generates the per-execution lock tests (mutual exclusion of two concurrent bodies, release after a raising fun); when it does not, they are not generated at all - exporting the callback is what opts an adapter into its contract.

Opting out by not exporting is the whole story for an adapter written from scratch. It is not the whole story for StatifierPersistence.Storage.Ecto, which exports lock_execution/3, list_executions_by_metadata/2 and list_execution_states_by_metadata/2 for every Ecto backend but implements all three in Postgres-only SQL (pg_advisory_xact_lock plus FOR UPDATE; jsonb containment). Point that adapter at a backend that is not Postgres and the four cases those three callbacks generate are generated and fail: the lock pair on SQL the backend does not parse, the two listings on the refusal they answer with instead. list_executions_by_metadata/2 and list_execution_states_by_metadata/2 consult supports_metadata?/1 before they issue anything, so off Postgres they return {:error, :metadata_unsupported} rather than raising (sp-4eo) - a cleaner answer, but not the list these two cases assert over, so their tag stays where the raise put it.

So those four carry @tag :postgres, and such a host excludes them by tag rather than forking the suite:

mix test --exclude postgres

Nothing else in the suite is tagged: every remaining case executions, and a green execution with four excluded is the honest report of what that backend supports. It is honest only alongside actually declining what the tag excludes - serialization: pointed at the host's own strategy rather than the adapter's lock_execution/3, and no reliance on the child listings. Excluding the tag while still routing serialization through a lock the backend cannot honor hides a failure instead of opting out of a contract. docs/non-postgres-backends.md in this package is the guide: what declining costs, and how to verify.