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.
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'))
}) A fake DOM can't catch what a fake DOM can't render.
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.”
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.
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.
heading('Dashboard') // getBy
button('Save').within(role('dialog')) // getBy, scoped
role('status', 'Loading').wait() // findBy
role('listitem').all() // getAllBy
role('alert').maybe() // queryBy | kahraman | mode | result |
|---|---|---|
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 |
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
Interact
Stabilize
Scope & extract
Report together
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.
// .storybook/preview.ts
import kahraman from 'kahraman/preview'
export default {
...kahraman,
// ...other preview config
} 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.
- 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.
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.
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() See the style in working setups.
Examples, not mandatory templates. Adopt only the parts your own Storybook setup supports.
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.
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.