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 imitation, 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")

A fake DOM can't catch what a fake DOM can't render.

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.”

coupled to implementation
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
  • Storybook story play functions and story tests.
  • Portable stories with Vitest browser mode.
  • ESM only, with Storybook 9+ as the peer dependency.
  • Installs from npm or vendors through jsrepo.
  • Doesn't supply Storybook, MSW, routing, or a browser provider. It uses yours.

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.

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()
Questions

Frequently asked.

Short answers to what people ask before installing.

Does kahraman replace Playwright or Cypress?

No. kahraman is an intent level actor and locator layer over the storybook/test runtime. It runs inside the browser your Storybook or Vitest setup already provides, so it composes with your runner instead of competing with it.

Does it work with Vitest?

Yes. Portable stories run in Vitest browser mode with the same actor API you use in story play functions.

Which renderers are supported?

React, Vue, Svelte, Preact, web components, and plain HTML. kahraman reads only canvasElement and userEvent from story context, so any renderer that works with Storybook works here.

Why a real browser instead of jsdom?

A real browser lays out, focuses, and paints. Accessibility behavior only means something where rendering actually happens, and that is where your tests should run.

Does kahraman supply Storybook or a browser?

No. Storybook 9 or newer is the peer dependency, and kahraman uses the browser provider your setup already has.

How do I install it?

Run npm install --save-dev kahraman. It is ESM only and needs Node 20.11 or newer.

Can I add my own steps?

Yes. I.extend(…) adds narrowly scoped page vocabulary such as seeError(), built from the same accessible primitives.

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