Storybook interaction testing · accessibility first

Tests that read like a user's story.

kahraman gives Storybook and Vitest browser tests a small I.see(…) / I.click(…) actor and a fluent locator DSL built on roles, accessible names, and visible text. Every step runs against your story rendered in a real browser — not a jsdom stand-in — so the test sees what the user sees, and inaccessible UI fails where it should.

$ npm install --save-dev kahraman
ESM only Node ≥ 20.11 storybook ≥ 9 MIT
sign-in.stories.ts
import { createActor, button, heading, role } from 'kahraman'

const I = createActor()

export const Default = meta.story()
Default.test('signs the user in', async (context) => {
  I.init(context)
  await I.see(heading('Sign in'))
  await I.fill(role('textbox', 'Email'), 'ada@example.com')
  await I.click(button('Continue'))
  await I.waitExit(role('status'))
  await I.see(heading('Welcome, Ada'))
})
story canvas
step trace
I.see(heading "Sign in")
I.fill(role · textbox "Email")
I.click(button "Continue")
I.waitExit(role "status")
I.see(heading "Welcome, Ada")
Selectors describe markup · kahraman describes intent

Address the UI as a user would.

CSS selectors and test IDs describe implementation. kahraman locators describe what a person can perceive: a button named “Continue,” a textbox named “Email,” a heading named “Welcome, Ada.”

implementation-coupled
page.click('[data-testid="btn-4f2a"]') page.waitForSelector('.MuiBox-root > div:nth-child(3)') wrapper.find('.btn--primary').exists() Breaks on refactor. A screen reader can't read any of it.
perceivable, resilient
await I.see(heading('Sign in'))
await I.fill(role('textbox', 'Email'), 'ada@example.com')
await I.click(button('Continue'))

If you can't target an element by its role or name, neither can assistive tech.

The locator DSL

A fluent locator DSL. Small on purpose.

The public locator surface stays deliberately narrow. Refine a semantic query without abandoning its intent: wait for it, collect all matches, make a lookup nullable, scope it to a region, or pass Testing Library options.

locators.ts
heading('Dashboard')                    // getBy
button('Save').within(role('dialog'))   // getBy, scoped
role('status', 'Loading').wait()        // findBy
role('listitem').all()                  // getAllBy
role('alert').maybe()                   // queryBy
Fluent form → Testing Library mode
kahramanmoderesult
default getBy* one element; throws when missing
.wait() findBy* Promise<element>
.all() getAllBy* element[]
.maybe() queryBy* element | null
.within(scope) scoped query same variant, scoped
.options(...) query options same variant
The actor

Intent-level actions for real interaction tests.

One actor, initialized from a story context, expresses the whole journey — assertions, interactions, stabilization, and extraction — in causal order.

Assert

seedontSeeseeInFieldseeCheckedseeDisabledseeAttribute

Interact

clickfillclearselectOptionpress

Stabilize

waitExit.wait()retryTotryTo

Scope & extract

withingrabTextFromgrabTextFromAll

Report together

hopeThathopeThat.noErrors()
Diagnostics

Readable failures, not a wall of internals.

When a step fails, kahraman appends the complete ✔ / ✖ step trace and retargets the stack to your story or page actor. Add the optional kahraman/preview annotation to trim enormous Testing Library role listings down to the relevant near-misses and a capped DOM excerpt.

preview.ts
// .storybook/preview.ts
import kahraman from 'kahraman/preview'

export default {
  ...kahraman,
  // ...other preview config
}
actor error · step trace
I.see(heading "Sign in")
I.fill(role · textbox "Email")
I.click(button "Continue")
I.waitExit(role "status")
I.see(heading "Welcome, Ada")
Where it runs

Built for the Storybook browser-testing loop.

kahraman is a small intent-level layer over the storybook/test runtime — it reads only canvasElement and userEvent from story context, so it stays renderer-agnostic.

ReactVueSvelte PreactWeb ComponentsHTML

jsdom made sense when real browsers were slow and flaky in CI. That bill came down. Vitest browser mode runs your stories in an actual browser cheaply enough to do it by default. jsdom still fits pure logic — but for UI, there's little reason left to assert against a DOM that never lays out, focuses, or paints.

A fake DOM can't catch what a fake DOM can't render. kahraman tests where rendering, focus, and accessibility actually happen.

Extend it in your app's language

Keep mechanics reusable. Keep journeys readable.

kahraman ships the generic actor and locator DSL. Add narrowly scoped page vocabulary with I.extend(…)seeError(), selectCountry() — built from the same accessible primitives.

Readable for humans. Writable for agents. Declarative steps are a format coding agents can follow, and the repo ships the kahraman-storybook-testing skill.

$npx skills add apphane-dev/kahraman --skill kahraman-storybook-testing
pageActor.ts
const withPageError =
  (error) => (I) => ({
    seeError: async () => {
      await I.see(heading(error.title))
      await I.see(role('alert'))
      await I.see(button('Try again'))
    },
  })

const I = createActor().extend(
  withPageError({ title: 'Something went wrong' }),
)

await I.seeError()
Seen in production stories

See the style in working setups.

Examples, not mandatory templates — adopt only the parts your own Storybook setup supports.

Get started

Make the accessible path the easy path.

Install kahraman, initialize one actor in a story, and write the next test in the language of roles, names, actions, and visible outcomes.

$npm install --save-dev kahraman