Skip to content

Testing outlets

Outlets let one controller reference another via a CSS selector. Testing them is a matter of registering both controllers, mounting both DOM nodes, and asserting through the outlet interface.

ts
import { render, attr.controller, attr.action, attr.combine } from '@tito10047/stimulus-test-utils'
import ModalController from '../src/modal_controller.js'
import DialogController from '../src/dialog_controller.js'

const { controller, user, element, getByRole } = await render(ModalController, {
  controllers: { dialog: DialogController },
  html: `
    <div>
      <div ${attr.combine(attr.controller('modal', {}, {}, { dialog: "[data-controller~='dialog']" }))}>
        <button ${attr.action('modal', 'open', 'click')}>Open</button>
        <button ${attr.action('modal', 'close', 'click')}>Close</button>
      </div>
      <section ${attr.controller('dialog')} hidden>Dialog body</section>
    </div>
  `,
})

const dialogEl = element.parentElement!.querySelector('section')!
expect(dialogEl.hasAttribute('hidden')).toBe(true)

await user.click(getByRole('button', { name: 'Open' }))
expect(dialogEl.hasAttribute('hidden')).toBe(false)
expect(controller.dialogOutlet.isOpen).toBe(true)

await user.click(getByRole('button', { name: 'Close' }))
expect(dialogEl.hasAttribute('hidden')).toBe(true)

Multiple outlets of the same type

Declare static outlets = ['item'] on the controller and use itemOutlets in tests:

ts
expect(controller.itemOutlets).toHaveLength(3)
controller.itemOutlets.forEach(o => expect(o.isSelected).toBe(false))

Asserting connect / disconnect callbacks

Stimulus fires fooOutletConnected(outlet, element) and fooOutletDisconnected(...). Spy on them via the controller instance:

ts
const connected = vi.spyOn(controller, 'dialogOutletConnected')
// ... do something that matches the selector
expect(connected).toHaveBeenCalled()

See also the guide page on Multiple controllers & outlets.

Released under the MIT License.