Skip to main content

Testing

Caution

This is the only frontend testing framework that sparks joy.

Why test your logics in the first place?#

While adding a new feature into a logic, such as "make it possible to duplicate dashboards" into a fictional dashboardsLogic.ts, you write a clear set of actions, listeners, reducers and so forth.

However, when you read dashboardsLogic.ts, its code is grouped by functionality: first all actions, then all reducers. The original intent behind each line of code is lost:

Redux Devtools

There is a lot of value in having a logic grouped by functionality. It ultimately makes it easy to add new features, as you have a clear overview of what's already there.

Yet if you're not careful, it's also easy to make mistakes. You could change an action without understanding its place in an existing feature, and break things.

That is why we write logic tests.

Redux Devtools

When building a feature, write the same story twice. Once in a test, grouping all new code together, and once more in the logic, code split up by functionality.

One side checking the other. Two factor authentication for your code.

Live-Replay testing#

To test a logic, you dispatch some actions, and make sure they in turn change the right values and/or dispatch other actions.

You literally just 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,
})
// also test the mocked api call separately
})

It doesn't matter if the actions you're matching have already been dispatched or if we need to wait for them. .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 matched action, no matter what they are now.

Kea-Test-Utils#

Installing#

Reset the context before each test#

Kea stores everything in a context. Call resetContext() before each test to reset Kea's brain.

/* 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 and unmount your logic#

Read the docs on Lifecycles and Mounting and Unmounting. Then make sure your logic is mounted before the tests run:

let unmount: () => void
let logic: ReturnType<typeof dashboardLogic.build>
beforeEach(() => {
logic = dashboardLogic({ id: 123 })
unmount = logic.mount()
})
afterEach(() => {
unmount()
})

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',
})

truth and partial#

Use the truth and partial helpers, or jest's expect matchers to make matching 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
})

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.

.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 */
],
})
})

How to test Kea and React together#

If you keep all your state in Kea, and only test your logics, you're mostly done with your frontend testing.

What's missing is making sure the right values are rendered in the right places, and the right actions get clicked when the right button is pressed.

I'm not yet ready to recommend a best practice here. However a year ago this approach got the job done.