Assertions

If you’ve done testing before, you might be familiar with the setup, then assert pattern for writing tests:

  1. Setup to get the app into the desired state.
  2. Assert by checking the app is in the correct state.

The following example shows a whole test for a function that adds a user to a database:

ts
// PSEUDOCODE
describe("addUserToDb", () => {
it("Should add a user to a database", async () => {
// SETUP
// Clear the database before each test
await testUtils.clearDatabase();
// Run the function we’re testing
await addUserToDb({
name: "Matt",
});
// ASSERT
// Ensure that the user exists
await testUtils.ensureUserExistsInDb({
name: "Matt",
});
});
});
ts
// PSEUDOCODE
describe("addUserToDb", () => {
it("Should add a user to a database", async () => {
// SETUP
// Clear the database before each test
await testUtils.clearDatabase();
// Run the function we’re testing
await addUserToDb({
name: "Matt",
});
// ASSERT
// Ensure that the user exists
await testUtils.ensureUserExistsInDb({
name: "Matt",
});
});
});

The test above follows the setup then assert steps:

  1. Setup: Clear the database, then call the addUserToDb function we’re testing.
  2. Assert: Check the app is in the correct state with ensureUserExistsInDb.

Setup

You have two main options to run test setup in @xstate/test.

Setup before each test path

If your setup needs to run before each test path, run the setup just before calling path.test():

ts
const paths = model.getPaths();
describe("My model", () => {
paths.forEach((path) => {
it(path.description, () => {
/**
* Run any setup that needs to happen
* before each test
*/
// Run the test
path.testSync({
states: {},
events: {},
});
/**
* Run any teardown that needs to
* happen after each test
*/
});
});
});
ts
const paths = model.getPaths();
describe("My model", () => {
paths.forEach((path) => {
it(path.description, () => {
/**
* Run any setup that needs to happen
* before each test
*/
// Run the test
path.testSync({
states: {},
events: {},
});
/**
* Run any teardown that needs to
* happen after each test
*/
});
});
});

Setup during a test

If your setup needs to happen during a test, you pass an implementation to an event. The following example tests a button. The button’s text begins as ‘pending,’ but the text turns into ‘complete’ after the button is clicked.

ts
const machine = createTestMachine({
initial: "buttonIsPending",
states: {
buttonIsPending: {
on: {
CLICK: "buttonIsComplete",
},
},
buttonIsComplete: {},
},
});
createTestModel(machine)
.getPaths()
.forEach((path) => {
it(path.description, () => {
path.testSync({
events: {
CLICK: () => {
cy.findByRole("button").click();
},
},
});
});
});
ts
const machine = createTestMachine({
initial: "buttonIsPending",
states: {
buttonIsPending: {
on: {
CLICK: "buttonIsComplete",
},
},
buttonIsComplete: {},
},
});
createTestModel(machine)
.getPaths()
.forEach((path) => {
it(path.description, () => {
path.testSync({
events: {
CLICK: () => {
cy.findByRole("button").click();
},
},
});
});
});

When the testModel wants to know how to implement the CLICK event, it looks inside events and runs the function. The result is that the model knows how to setup the app in each state:

  • The test model knows how to setup the button in the buttonIsPending state as it’s the machine’s initial state.
  • The test model knows how to setup the buttonIsComplete state by running the CLICK event.

Assert

Once your model can set up your app in each state, you should assert that your app is actually in that state. You can do this by passing states to path.test:

ts
createTestModel(machine)
.getPaths()
.forEach((path) => {
it(path.description, () => {
path.testSync({
events: {
CLICK: () => {
cy.findByRole("button").click();
},
},
states: {
buttonIsPending: () => {
cy.findByRole("button").should(
"have.text",
"pending",
);
},
buttonIsComplete: () => {
cy.findByRole("button").should(
"have.text",
"complete",
);
},
},
});
});
});
ts
createTestModel(machine)
.getPaths()
.forEach((path) => {
it(path.description, () => {
path.testSync({
events: {
CLICK: () => {
cy.findByRole("button").click();
},
},
states: {
buttonIsPending: () => {
cy.findByRole("button").should(
"have.text",
"pending",
);
},
buttonIsComplete: () => {
cy.findByRole("button").should(
"have.text",
"complete",
);
},
},
});
});
});

The test model can now ensure that the app is in the state as described by the machine.

This approach is a good general guide for testing with @xstate/test:

  • Run assertions in states
  • Run setup in events, or before/after path.test.