Skip to content

Custom interactors

Once a few tests use the same form or component, give that workflow a name. Keep the definition with your app’s test support—or beside the component—so tests can import it.

tests/interactors/login.js
import { defineInteractor } from 'dyno';
export let LoginForm = defineInteractor({
actions: {
async enterCredentials({ email, password }) {
await this.find('Email').then.fill(email);
await this.find('Password').then.fill(password);
},
async signIn(user) {
await this.enterCredentials(user);
await this.click('"Log in" $(button)');
},
},
assertions: {
hasError: message => ({ root }) =>
root().querySelector('[role=alert]')?.innerText === message,
},
});

Bind it to the test’s I when you use it:

import { LoginForm } from './interactors/login.js';
let login = LoginForm(I, '$(form)');
await step('Sign in', () => login.signIn(user));
await login.assert.hasError('Check your password');

Share definitions, not bound instances. Each binding gets the test’s scope, assertion settings and cancellation signal. It follows the app after navigation without caching elements or changing other interactors.

A custom interactor can bind inside another one. With your own Post, Composer and selector:

let reply = Composer(Post(I, postSelector(words)), '$([aria-label="Reply"])');
await reply.write('I remember this storm.');

Use async actions and await each operation. Interactor’s generator actions and .then chains work too, and scope() keeps your custom methods.

Assertions receive $ for the current subject and root() for the component root. Return a boolean, including false when content is missing. Interactor treats undefined as success for callbacks that throw on failure. Custom assertions support .not and failure-message objects.

Keep navigation, fixture requests, screenshots and named steps in the surrounding workflow. TypeScript infers custom method arguments; use InteractorContext when annotating assertion callbacks.