Test paths
@xstate/test
generates test paths that it walks through to execute your tests. Knowing how these paths are generated will make your tests more predictable.
Coverage
The following example models a checkbox:
ts
import { createTestMachine } from "@xstate/test";const machine = createTestMachine({initial: "notChecked",states: {notChecked: {on: {CLICK: "checked",},},checked: {on: {CLICK: "notChecked",},},},});
ts
import { createTestMachine } from "@xstate/test";const machine = createTestMachine({initial: "notChecked",states: {notChecked: {on: {CLICK: "checked",},},checked: {on: {CLICK: "notChecked",},},},});
You could take a few different approaches to ensure everything in this machine works:
State coverage
The first approach is to ensure full coverage of states, where you would want to test:
- When
checked
is reached, the checkbox is displaying a checkbox. - When
notChecked
is reached, the checkbox is NOT displaying a checkbox.
Event coverage
State coverage is a good start, but we also want to ensure all the events are working. To do this, we can add a new test:
- Ensure that
CLICK
changes the checkbox.
Putting these tests together, you’d end up with a single test path:
- Assert we’re in the
notChecked
state. - Run the
CLICK
event. - Assert we’re in the
checked
state.
Transition coverage
The test path above feels complete, but it’s not quite there. We now know that clicking the checkbox can change it from notChecked
to checked
. But we don’t know that the same will happen when we go the other way! That means our full test should be:
- Assert we’re in the
notChecked
state. - Run the
CLICK
event. - Assert we’re in the
checked
state. - Run the
CLICK
event. - Assert we’re in the
notChecked
state.
In @xstate/test
, we achieve the test path above by checking all transitions are covered, which means you get full coverage out of the box.
Multiple paths
Test setup can be expensive, whether you’re loading up a browser or just setting up a database. @xstate/test will speed up your tests by attempting to walk through your test model in as few paths as possible.
The following example models a login form:
ts
import { createTestMachine } from "@xstate/test";const loginMachine = createTestMachine({initial: "showingLoginForm",states: {showingLoginForm: {on: {SUBMIT_VALID_FORM: 'loggedIn',SUBMIT_INVALID_FORM: 'passwordInvalid',}},loggedIn: {}passwordInvalid: {},},});
ts
import { createTestMachine } from "@xstate/test";const loginMachine = createTestMachine({initial: "showingLoginForm",states: {showingLoginForm: {on: {SUBMIT_VALID_FORM: 'loggedIn',SUBMIT_INVALID_FORM: 'passwordInvalid',}},loggedIn: {}passwordInvalid: {},},});
This example would generate two test paths:
txt
showingLoginForm -> SUBMIT_VALID_FORM -> loggedIn showingLoginForm -> SUBMIT_INVALID_FORM -> passwordInvalid
txt
showingLoginForm -> SUBMIT_VALID_FORM -> loggedIn showingLoginForm -> SUBMIT_INVALID_FORM -> passwordInvalid
Two test paths are generated because the test model can’t transition away from the loggedIn
state or the passwordInvalid
state.
Condensing to a single path
If we were to model the machine slightly differently, the test model would generate a single path:
ts
import { createTestMachine } from "@xstate/test";const loginMachine = createTestMachine({initial: "showingLoginForm",states: {showingLoginForm: {on: {SUBMIT_VALID_FORM: 'loggedIn',SUBMIT_INVALID_FORM: 'passwordInvalid',}},loggedIn: {on: {LOG_OUT: 'showingLoginForm'}}passwordInvalid: {},},});
ts
import { createTestMachine } from "@xstate/test";const loginMachine = createTestMachine({initial: "showingLoginForm",states: {showingLoginForm: {on: {SUBMIT_VALID_FORM: 'loggedIn',SUBMIT_INVALID_FORM: 'passwordInvalid',}},loggedIn: {on: {LOG_OUT: 'showingLoginForm'}}passwordInvalid: {},},});
In the example above, we’ve added a LOG_OUT
transition to loggedIn
, which means the test model can navigate away from the loggedIn
state. Now, the test model will run a single path:
txt
showingLoginForm -> SUBMIT_VALID_FORM -> loggedIn -> LOG_OUT -> showingLoginForm -> SUBMIT_INVALID_FORM -> passwordInvalid
txt
showingLoginForm -> SUBMIT_VALID_FORM -> loggedIn -> LOG_OUT -> showingLoginForm -> SUBMIT_INVALID_FORM -> passwordInvalid
The test above requires less setup while also testing more behavior.
Note: we don’t necessarily recommend running fewer test paths, but understanding this behavior is useful when using @xstate/test.