Statifier.Testing.HandlerCase (Statifier v2.1.1)

Copy Markdown View Source

A reusable conformance case for Statifier.Invoke.Handler implementations

  • the second member of the Statifier.Testing family ADR-0053 opened, and the mechanical pin for the behaviour contract ADR-0051 decision 4 and docs/extending.md state in prose. A host application uses this module in a test alongside use ExUnit.Case and gets the contract checks as generated tests against its own handler:

    defmodule MyApp.EnrichHandlerConformanceTest do

    use ExUnit.Case, async: false
    use Statifier.Testing.HandlerCase,
      handler: MyApp.EnrichHandler,
      type: "myapp:enrich"

    end

What the generated tests verify, each a clause of the documented contract:

  1. start/2, cancel/2, and forward/3 are pure planning callbacks - deterministic for fixed inputs, observably effect-free, returning the documented shapes (Statifier.Invoke.Handler's moduledoc, point 1).
  2. perform/2 is idempotent on invoke_id: performing the handler's own planned instructions twice must not duplicate observable effects (point 2 there, and docs/extending.md's "At-least-once" section). Idempotency needs an observation point only the implementor can name - see "The observation point" below.
  3. cancel/2 after start/2 plans without raising, and cancel/2 for an invoke_id the handler never saw is a no-op, never a raise - the library plans cancels off the live invocation table, but a crash-recovering host may replay a cancel whose start it never durably recorded.
  4. {:error, _} from start/2 surfaces as error.execution in the driving chart (ADR-0051 decision 1's classification table), pinned by driving a minimal chart fixture with a case-internal always-failing probe handler registered under the implementor's own type string.
  5. Handler exceptions propagate - the library never rescues them into an event (docs/extending.md, "Where the library will not help") - pinned with a case-internal raising probe the same way.

Checks 4 and 5 pin the library's half of the contract with probe handlers rather than the implementation under test, on purpose: a conforming handler has no reason to expose a start that fails or raises on demand, and the point of running the pins downstream is that the contract an implementor builds against stays enforced by tests where the implementations live, not by this repository's suite alone. A handler whose start/2 has a real failure path asserts its own half with assert_erroring_start/3.

Every check is also a public function on this module, so a suite that wants different fixtures - or only one of the checks - calls them directly instead of use-ing the whole case.

Options

  • :handler (required) - the Statifier.Invoke.Handler implementation module under test.
  • :type (required) - the <invoke type> string the handler serves.

The fixtures, and overriding them

The generated tests plan against a synthetic %Statifier.Effect.Invoke{}, a plan context, and an autoforwarded event, built by build_invoke/2, build_ctx/3, and build_event/1. Each is reachable through an overridable function in the use-ing module, so a handler that reads params, src, or content overrides the one fixture it cares about:

  • conformance_invoke/0 - the %Statifier.Effect.Invoke{} handed to start/2.
  • conformance_ctx/0 - the plan context handed to every callback.
  • conformance_event/0 - the %Statifier.Event{} handed to forward/3.
  • observed_effects/1 - the observation point, below.

The observation point

The case cannot see your job queue, your database, or your test inbox, so idempotency (and planning purity) are judged against a function you declare: override observed_effects/1 to return the observable effects attributable to the given invoke_id at the moment of the call - a list of enqueued jobs, a set of rows, whatever your perform/2 writes. The default returns :unobserved, which skips the purity comparisons; the idempotency test flunks under that default whenever the handler actually routes instructions to perform/2, naming the override to write - a handler with real performed effects never gets a silent pass (ADR-0053's fail-not-skip discipline). A handler that routes nothing to perform/2 needs no observation point, and its idempotency test asserts exactly that instead.

Summary

Types

The observation point: returns the observable effects attributable to an invoke_id, or :unobserved when the use-ing module has not declared one - see the moduledoc's "The observation point" section.

Functions

Generates the conformance tests into the use-ing module, which must use ExUnit.Case first (the generated tests are ordinary test blocks). Options and the overridable fixture functions are described in the moduledoc.

Contract check 3 for cancel/2: after start/2 has planned, cancel/2 for the same invoke_id deterministically plans {:ok, instructions}; for an invoke_id the handler never saw it is a no-op that returns {:ok, instructions} rather than raising; and neither call moves observe's reading.

Contract check 4, the library's half: an {:error, _} from start/2 is planned as error.execution (ADR-0051 decision 1 - a pure planning failure, no communication ever attempted). Drives a minimal chart whose initial state invokes type, registered to a case-internal probe handler whose start/2 always returns {:error, _}, and asserts the chart transitions on error.execution - the observation a chart author actually has.

The implementor's half of contract check 4, for a handler whose start/2 has a real failure path: asserts start/2 returns {:error, _} for invoke, deterministically. Not generated by use - a conforming handler need not expose a failing start at all - so a suite whose handler has one calls this itself with the failing fixture.

Contract check 1 for forward/3: called twice with fixed inputs it returns the same {:ok, instructions}, and leaves observe's reading unchanged.

Contract check 5: an exception raised from a handler callback propagates and crashes the session - the library never rescues it into an event (docs/extending.md, "Where the library will not help"). Drives the same minimal chart with a case-internal probe whose start/2 raises, and asserts the session process exits with that very exception.

Contract check 2: performing the handler's own planned {:handler, module, payload} instructions twice - the crash-and-replay shape docs/extending.md's "At-least-once" section describes - must not duplicate the effects observe reads for the invoke's id, and each perform/2 call must return :ok or {:error, term}.

Contract check 1 for start/2: called twice with fixed inputs it returns the same value, of the documented shape ({:ok, instructions} or {:error, term}), and leaves observe's reading for the invoke's own id unchanged - planning is deciding, never doing.

A plan context (Statifier.Invoke.Handler.t:ctx/0) declaring type registered and dispatching it to handler - the same three keys Statifier.Session.Effects.plan/2 hands every planning callback. overrides merges over the defaults, since ctx is a plain map by contract.

A synthetic external event for forward/3 (spec 6.4.2's autoforward): name "conformance.ping", no data. overrides replaces any field.

A synthetic %Statifier.Effect.Invoke{} for planning against: type as given, invoke_id "inv_1" (the shape a generated platformid takes, ADR-0008 as amended), empty params, and zeroed counters. overrides replaces any field.

Types

observe()

@type observe() :: (invoke_id :: String.t() -> term())

The observation point: returns the observable effects attributable to an invoke_id, or :unobserved when the use-ing module has not declared one - see the moduledoc's "The observation point" section.

Functions

__using__(opts)

(macro)
@spec __using__(opts :: Macro.t()) :: Macro.t()

Generates the conformance tests into the use-ing module, which must use ExUnit.Case first (the generated tests are ordinary test blocks). Options and the overridable fixture functions are described in the moduledoc.

assert_cancel_contract(handler, invoke, ctx, observe)

@spec assert_cancel_contract(
  handler :: module(),
  invoke :: Statifier.Effect.Invoke.t(),
  ctx :: Statifier.Invoke.Handler.ctx(),
  observe :: observe()
) :: :ok

Contract check 3 for cancel/2: after start/2 has planned, cancel/2 for the same invoke_id deterministically plans {:ok, instructions}; for an invoke_id the handler never saw it is a no-op that returns {:ok, instructions} rather than raising; and neither call moves observe's reading.

assert_error_start_raises_error_execution(type)

@spec assert_error_start_raises_error_execution(type :: String.t()) :: :ok

Contract check 4, the library's half: an {:error, _} from start/2 is planned as error.execution (ADR-0051 decision 1 - a pure planning failure, no communication ever attempted). Drives a minimal chart whose initial state invokes type, registered to a case-internal probe handler whose start/2 always returns {:error, _}, and asserts the chart transitions on error.execution - the observation a chart author actually has.

assert_erroring_start(handler, invoke, ctx)

@spec assert_erroring_start(
  handler :: module(),
  invoke :: Statifier.Effect.Invoke.t(),
  ctx :: Statifier.Invoke.Handler.ctx()
) :: :ok

The implementor's half of contract check 4, for a handler whose start/2 has a real failure path: asserts start/2 returns {:error, _} for invoke, deterministically. Not generated by use - a conforming handler need not expose a failing start at all - so a suite whose handler has one calls this itself with the failing fixture.

assert_forward_contract(handler, invoke, ctx, event, observe)

@spec assert_forward_contract(
  handler :: module(),
  invoke :: Statifier.Effect.Invoke.t(),
  ctx :: Statifier.Invoke.Handler.ctx(),
  event :: Statifier.Event.t(),
  observe :: observe()
) :: :ok

Contract check 1 for forward/3: called twice with fixed inputs it returns the same {:ok, instructions}, and leaves observe's reading unchanged.

assert_handler_exceptions_propagate(type)

@spec assert_handler_exceptions_propagate(type :: String.t()) :: :ok

Contract check 5: an exception raised from a handler callback propagates and crashes the session - the library never rescues it into an event (docs/extending.md, "Where the library will not help"). Drives the same minimal chart with a case-internal probe whose start/2 raises, and asserts the session process exits with that very exception.

assert_perform_idempotent(handler, invoke, ctx, event, observe)

@spec assert_perform_idempotent(
  handler :: module(),
  invoke :: Statifier.Effect.Invoke.t(),
  ctx :: Statifier.Invoke.Handler.ctx(),
  event :: Statifier.Event.t(),
  observe :: observe()
) :: :ok

Contract check 2: performing the handler's own planned {:handler, module, payload} instructions twice - the crash-and-replay shape docs/extending.md's "At-least-once" section describes - must not duplicate the effects observe reads for the invoke's id, and each perform/2 call must return :ok or {:error, term}.

When the handler routes instructions to perform/2 and observe still answers :unobserved, this flunks naming the observed_effects/1 override to write - never a silent pass. When the handler routes nothing to perform/2 (the built-in scxml handler's own shape), it asserts exactly that and no observation point is needed.

assert_start_contract(handler, invoke, ctx, observe)

@spec assert_start_contract(
  handler :: module(),
  invoke :: Statifier.Effect.Invoke.t(),
  ctx :: Statifier.Invoke.Handler.ctx(),
  observe :: observe()
) :: :ok

Contract check 1 for start/2: called twice with fixed inputs it returns the same value, of the documented shape ({:ok, instructions} or {:error, term}), and leaves observe's reading for the invoke's own id unchanged - planning is deciding, never doing.

build_ctx(type, handler, overrides \\ [])

@spec build_ctx(type :: String.t(), handler :: module(), overrides :: keyword()) ::
  Statifier.Invoke.Handler.ctx()

A plan context (Statifier.Invoke.Handler.t:ctx/0) declaring type registered and dispatching it to handler - the same three keys Statifier.Session.Effects.plan/2 hands every planning callback. overrides merges over the defaults, since ctx is a plain map by contract.

build_event(overrides \\ [])

@spec build_event(overrides :: keyword()) :: Statifier.Event.t()

A synthetic external event for forward/3 (spec 6.4.2's autoforward): name "conformance.ping", no data. overrides replaces any field.

build_invoke(type, overrides \\ [])

@spec build_invoke(type :: String.t(), overrides :: keyword()) ::
  Statifier.Effect.Invoke.t()

A synthetic %Statifier.Effect.Invoke{} for planning against: type as given, invoke_id "inv_1" (the shape a generated platformid takes, ADR-0008 as amended), empty params, and zeroed counters. overrides replaces any field.