Skip to main content

Testing

Kea-Test-Utils

Test the boundary

A logic communicates with the rest of the world through actions and values. Thus to assure that a logic does what it's supposed to do, it's usually enough to dispatch some actions, and make sure they in turn change the right values and/or dispatch other actions.

You literally write down what should happen in a chain of dispatched actions and matched values:

import { expectLogic, partial } from 'kea-test-utils'

it('setting search query loads remote items', async () => {
await expectLogic(logic, () => {
logic.actions.setSearchQuery('event')
})
.toDispatchActions(['setSearchQuery', 'loadRemoteItems'])
.toMatchValues({
searchQuery: 'event',
remoteItems: partial({
count: 0,
results: [],
}),
remoteItemsLoading: true,
})
.toDispatchActions(['loadRemoteItemsSuccess'])
.toMatchValues({
searchQuery: 'event',
remoteItems: partial({
count: 3, // got new results
results: partial([partial({ name: 'event1' })]),
}),
remoteItemsLoading: false,
})
})

It doesn't matter if the actions you're matching have already been dispatched or if we need to wait for them. Kea-Test-Utils' .toDispatchActions can both query a recorded history of actions, and wait for new ones to arrive.

In turn, .toMatchValues matches values as they were after the last matched action, no matter what they are now.

Installing

# With yarn
yarn add --dev kea-test-utils

# With npm
npm install --save-dev kea-test-utils

Reset the context before each test

Kea stores everything in a context. Call resetContext() before each test to reset Kea's brain. Pass it the testUtilsPlugin to enable the action and state recording necessary for expectLogic to work.

/* global test, expect, beforeEach */
import { kea, resetContext } from 'kea'
import { testUtilsPlugin } from 'kea-test-utils'

beforeEach(() => {
resetContext({
plugins: [testUtilsPlugin /* other plugins */],
})
})

test('runs before and after mount events', async () => {
// your test here
})

Mount your logic

Then make sure your logic is mounted before the tests run:

describe('dashboardLogic', () => {
let logic: ReturnType<typeof dashboardLogic.build>

beforeEach(() => {
logic = dashboardLogic({ id: 123 })
logic.mount()
})

test('runs before and after mount events', async () => {
await expectLogic(logic).toMatchValues({ id: 123 })
})
})

If you run resetContext between tests, and use enough breakpoints in your listeners, you shouldn't need to worry about unmounting logic.

expectLogic()

The entrypoint to the Live-Replay logic testing. All of these options work:

// option 1
await expectLogic(logic, () => logic.actions.doSomething()).toDispatchActions(['doSomething'])

// option 2
await expectLogic(() => logic.actions.doSomething()).toDispatchActions(logic, ['doSomething'])

// option 3
logic.actions.doSomething()
await expectLogic(logic).toDispatchActions(['doSomething'])

// option 4
logic.actions.doSomething()
await expectLogic().toDispatchActions(logic, ['doSomething'])

.toDispatchActions()

Match dispatched actions in order. Waits up to 3s and requires await if any of the actions haven't already happened.

await expectLogic(logic, () => {
logic.actions.setSearchQuery('hello')
}).toDispatchActions([
// array of actions
// short form
'setSearchQuery',
// redux type
logic.actionTypes.setSearchQuery,
// full redux action
logic.actionCreators.setSearchQuery('hello'),
// custom matcher
(action) =>
action.type === logic.actionTypes.setSearchQuery && action.payload.searchQuery === 'hello',
])

.toDispatchActionsInAnyOrder()

Match dispatched actions in any order. Waits up to 3s and requires await if any of the actions haven't already happened.

await expectLogic(logic, () => {
logic.actions.setSearchQuery('hello')
}).toDispatchActionsInAnyOrder([
// array of actions
// short form
'setSearchQuery',
// redux type
logic.actionTypes.setSearchQuery,
// full redux action
logic.actionCreators.setSearchQuery('hello'),
// custom matcher
(action) =>
action.type === logic.actionTypes.setSearchQuery && action.payload.searchQuery === 'hello',
])

.toNotHaveDispatchedActions()

Make sure none of the given actions have been dispatched. Use with delay or toFinishListeners.

await expectLogic(logic, () => {
logic.actions.setSearchQuery('hello')
}).toNotHaveDispatchedActions([
// array of actions
// short form
'setSearchQuery',
// redux type
logic.actionTypes.setSearchQuery,
// full redux action
logic.actionCreators.setSearchQuery('hello'),
// custom matcher
(action) =>
action.type === logic.actionTypes.setSearchQuery && action.payload.searchQuery === 'hello',
])

.toFinishListeners()

Wait for all listeners on a logic to finish.

await expectLogic(logic, () => logic.actions.doSomething()).toFinishListeners()

.toFinishAllListeners()

Wait for all running listeners to finish.

await expectLogic().toFinishAllListeners()

.toMatchValues()

Match the store's state as it was after a matched action.

await expectLogic(logic, () => logic.actions.doSomething())
.toDispatchActions(['doSomething'])
.toMatchValues({
something: 'done',
somethingLoading: 'true',
})
.toDispatchActions(['doSomethingElse'])
.toMatchValues(otherLogic, {
something: 'else',
})

Match values at the end of history

If you use toDispatchActions([]), we lock the history index that toMatchValues uses to the last matched action.

This allows you to effortlessly query history, but sometimes you might want to see what are the current values. Here you have two options.

  1. Actually match a better action with .toDispatchActions(['doSomethingElse']) if applicable
  2. Use .clearHistory() to reset all matched actions. See below for details.

truth and partial

Use the truth and partial helpers, or jest's expect matchers to make matching values easier:

import { expectLogic, partial, truth } from 'kea-test-utils'

await expectLogic(logic, () => logic.actions.loadResults())
.toHaveDispatchedActions(['loadResultsSuccess'])
.toMatchValues({
results: partial([partial({ id: 33 })]), // has a result with id: 33
})
.toMatchValues({
results: truth((results) => results.length === 42), // has 42 results
})
.toMatchValues({
results: expect.arrayContaining([expect.objectContaining({ id: 33 })]), // jest matchers work too
})

.toMount()

Expect specific logics to be mounted

await expectLogic(logic).toMount([userLogic, otherLogic({ id: insight.id })])

.printActions()

Show what actions have been printed now, and where the current pointer for value matching is.

await expectLogic(logic, () => logic.actions.setSearchQuery())
.toMatchActions(['setSeachQuery'])
.printActions()
.printActions({ compact: true })

.delay()

Wait the time in ms.

await expectLogic(logic, () => logic.actions.setSearchQuery())
.wait(100)
.printActions()

.clearHistory()

Forget anything ever happened. This can be useful if you want to reset the index used for toMatchValues after matching with toDispatchActions.

await expectLogic(logic, () => logic.actions.setSearchQuery())
.clearHistory()
.printActions() // nothing to print
.toMatchValues({ results: [] }) // checks the current state of values

Common issues

Adapt kea-router to run in nodejs

To run kea-router in a jest test, you need to pass it a mocked history object. Otherwise and especially when using jsdom, the URL might persist between tests.

Install the memory package, and adapt as needed:

import { createMemoryHistory } from 'history'

beforeEach(() => {
const history = createMemoryHistory()
;(history as any).pushState = history.push
;(history as any).replaceState = history.replace

resetContext({
plugins: [
testUtilsPlugin,
routerPlugin({ history: history, location: history.location }),
/* other plugins */
],
})
})

Questions & Answers

Ask questions about this page here.