dánwòdánwò

Fault matrix

Writing invariants

An invariant is the rule the fault matrix checks after every trial. Write one by comparing your app with the provider.

An invariant is a check that must be true however a test went: whether the payment succeeded, failed or was retried. You attach it to a test with .invariant(). The fault matrix runs it after every trial.

The shape of an invariant

.invariant() takes a function with the same fixtures as the test. It runs after the test's body, once background work like webhooks has finished:

tests/buy-credits.danwo.ts
import { type StripeFixtures, test as base } from "@danwo/sdk"

const test = base.extend<StripeFixtures>()

test("buys 500 credits", async ({ app, screen, expect }) => {
  await app.open("/")
  await screen.getByRole("button", { name: "Buy 500 credits" }).click()
  await screen.getByTestId("checkout-pay").click()
}).invariant(async ({ app, stripe, expect }) => {
  const res = await stripe.fetch(
    new Request("https://api.stripe.com/v1/checkout/sessions?payment_status=paid", {
      headers: { authorization: "Bearer sk_test_any" },
    }),
  )
  const { data } = await res.json()
  const { credits } = await (await fetch(new URL("/api/credits", app.baseUrl))).json()

  expect(credits).toBe(data.length * 500)
})

Compare two sides

A good invariant reads the same fact from two places and checks they agree:

Your app saysThe provider saysInvariant
credits grantedpayments Stripe capturedequal
orders confirmedconfirmation emails Resend acceptedone email per order
replies postedmessages in the Slack channelone reply per mention

Read your app's side through its own API or database. Read the provider's side through its emulator fixture.

To query your app's database from an invariant, see Your app's data in tests.

Reading the provider's side

Each emulator gives your test a fixture named after it. Ask it what your app did:

tests/order-email.danwo.ts
import { type ResendFixtures, resendInbox, test as base } from "@danwo/sdk"

const test = base.extend<ResendFixtures>()

test("confirms the order by email", async ({ app, screen }) => {
  await app.open("/checkout")
  await screen.getByRole("button", { name: "Place order" }).click()
}).invariant(async ({ resend, expect }) => {
  expect(resendInbox(resend).list("ada@example.com")).toHaveLength(1)
})

Every provider's page lists its fixture and helpers. See Providers.

Rules of thumb

  • Compare counts and totals, not the screen. The screen may never load in a trial where a call failed. The invariant still has to hold.
  • Allow a clean failure. If the payment failed, zero credits and zero charges is correct. Write the rule so both outcomes pass.
  • Never compare times or ids. They change on every trial.

A test without an invariant can still go through the matrix. Then the test itself must pass under every fault, which is a much stricter bar.

Next

On this page

Run it on every pull request

A GitHub check and one comment per pull request, with the flight records attached.

Set up CI