Testable XState-Machines: Combining XState with Dependency Inversion
I really like to use XState for architecting web applications. In the past year, I have written and deployed over twenty state machines of various sizes to production. Over time, my personal go-to pattern for defining new state machines has emerged, which simplifies both testing and integration of machines. As the title suggests, the pattern uses dependency inversion to decouple hard-to-test dependencies.
Disclaimer: This article is targeted at an audience that is familiar with XState. If you want to get started with XState, the official documentation contains great resources.
Motivation
In most applications, actors that are spawned from machines have to execute side effects, such as invoking network calls or sending messages to other actors, to fulfill the applications requirements. In XState, these side effects are executed as actions and services. The actual APIs that implement these side effects, such as fetch
, localStorage
, or custom wrappers around them, are either accessed from global scope or imported into the module.
fetch
,localStorage
, and various other APIs are dependencies of machines.
Once you fully embrace XState, the focus in an application shifts from components to actors. Machines tend to pull most application logic out of components, which results in lean components that only select state from actors and send events to actors. Therefore, actors should be tested thoroughly to verify that the logic for a feature is implemented correctly. However, executing side effects in unit tests is often impossible or undesired, e.g. a test should not execute network calls. This motivates the following question: "How can we test an actor without executing its dependencies?"
Toggle - An Example Machine
Throughout the article, the following state machine definition is used as an example to simplify the discussion around different approaches.
Let us envision a toggle that can be either active or inactive. Further, the toggle must keep track about the total amount it has been toggled, and send an update to a server each time it is toggled. The result of the network call can be ignored, so sending the update can be implemented as a "fire and forget" effect, i.e. an action.
The following code snippet implements the requirements for our toggle. In this example, sendUpdate
is a function that uses fetch
to send the update to a server.
import { createMachine, assign } from "xstate";
// Dependency that creates a network call
import { sendUpdate } from "./api-layer";
export const toggleMachine = createMachine(
{
schema: {
context: {} as { toggled: number },
events: {} as { type: "toggle" },
},
id: "Toggle",
initial: "Inactive",
context: { toggled: 0 },
states: {
Inactive: {
entry: "notifyInactive",
on: {
toggle: { target: "Active", actions: "storeToggle" },
},
},
Active: {
entry: "notifyActive",
on: {
toggle: { target: "Inactive", actions: "storeToggle" },
},
},
},
},
{
actions: {
notifyInactive: () => sendUpdate("inactive"),
notifyActive: () => sendUpdate("active"),
storeToggle: assign({ toggled: (ctx) => ctx.toggled + 1 }),
},
}
);
The Suggested Approaches
The XState documentation contains two possible approaches to avoid the execution of side effects during test runs.
Testing Pure Transitions
The first approach tests the pure transition logic by not spawning actors from a machine. A benefit of this approach is the ability to easily validate the correctness of complex machine definitions, i.e. "does a state transition to the expected next state". This can be desirable if the definition's correctness is the main concern.
import { toggleMachine } from "./toggle-machine";
it("should switch between Active and Inactive states when a toggle event occurs", () => {
const event = { type: "toggle" } as const;
const activeState = toggleMachine.transition("Inactive", event);
expect(activeState.matches("Active")).toBe(true);
const inActiveState = toggleMachine.transition("Active", event);
expect(inActiveState.matches("Inactive")).toBe(true);
});
While this approach can verify that certain actions are queued for execution after a toggle
event is received, it cannot verify that actions are correctly implemented. For example, the above test could verify that a storeToggle
action is queued but it cannot ensure that the context value toggled
is actually incremented by one without executing storeToggle
.
Therefore, I like to spawn actors from a machine during test runs to test the whole machine. This way, test code better reflects the actual usage of machines and provides more confidence, which might sound familiar to anyone who has used Testing Library for UI testing.
Mocking Actions and Services
By working with spawned actors in tests, actions and services are executed, which makes it possible to verify their implementation. This has the downside that undesired dependencies, such as network call invocations, are executed as well. The second approach in the XState documentation suggests to mock specific actions and services. Mocking is achieved by overriding them with a partial machine config object, which can prevent the execution of dependencies. In the following example, notifyActive
is replaced with a dummy implementation, thus the dependency sendUpdate
is not executed.
import { interpret } from "xstate";
import { toggleMachine } from "./toggle-machine";
it("should increment the toggled count and notify the server when a toggle event occurs", () => {
let notified = false;
const actor = interpret(
toggleMachine.withConfig({
actions: { notifyActive: () => (notified = true) },
})
).start();
actor.send({ type: "toggle" });
expect(notified).toBe(true);
expect(actor.state.context.toggled).toBe(1);
});
While this approach makes it possible to fully test the implementation of actions and services that do not have undesired dependencies, thus do not have to be mocked, it has problems similar to the first approach as well. Mocking actions prevents tests from verifying important, observable behavior. Will the dependency sendUpdate
actually be called by actors spawned from toggleMachine
? Will it be called with the correct arguments? Without answering these questions, tests cannot verify that an actor will behave as expected when it is executed in the application.
Creating Machines with Factories and Dependency Inversion
To solve the problem of not being able to verify all observable behavior that is defined by a machine, I like to mock only dependencies and not the whole action. Furthermore, not every dependency can be easily imported and used in a machine. Some may only be available at runtime, e.g. references to other actors that are spawned at a higher level in a React component tree.
In an attempt to provide these "runtime dependencies", the previous approach of defining actions and services with withConfig
is sometimes used when a machine is integrated in applications as well. In my experience, this escalates quickly and is not very enjoyable to maintain. Especially, more complex actions and services become a lot harder to implement, maintain, and test.
Therefore, three requirements emerge that should be satisfied by a pattern for defining machines:
- Decouples a machine from its dependencies, such as API-calls or other outside behavior.
- Enables an easy solution for mocking dependencies during testing.
- Enables a robust solution for providing any dependency to a machine.
This can be achieved with the dependency inversion principle. The goal of the principle is to decouple software components, e.g. in our case state machines, from their dependencies by reversing their relationships. Instead of creating machines that depend on concrete implementations of functionality, they should only depend on interfaces of the functionality. The concrete implementation will be provided when a machine is used in an application or test.
However, this principle cannot be applied to the toggleMachine
machine example directly. The machine must be wrapped with a factory, i.e. a function that creates and returns the machine. By creating machines through factory functions, it is possible to provide arguments that can be used in the machine definition, actions, and services. Therefore, all dependencies of a machine can be declared as parameters and passed as arguments to the factory, thus inverting the dependencies. The application provides concrete implementations of the required dependencies wherever actors are spawned from the machine. During testing, mocked dependencies can be provided.
Factories and dependency inversion keep each machine free of dependencies, which helps keeping all parts of a machine testable.
The following code snippet highlights the complete pattern I like to use for every new machine. Further, I prefer to define all dependencies in one interface and require them as a single parameter. This makes it easier to mock all dependencies during testing. Alternatively, each dependency can be required as an additional parameter.
import { createMachine, assign } from "xstate";
export interface ToggleMachineDependencies {
sendUpdate(state: "active" | "inactive"): void;
}
export function createToggleMachine(deps: ToggleMachineDependencies) {
return createMachine(
{
// Unchanged machine definition...
},
{
actions: {
// Notify relies on the dependency
notifyInactive: () => deps.sendUpdate("inactive"),
notifyActive: () => deps.sendUpdate("active"),
storeToggle: assign({ toggled: (ctx) => ctx.toggled + 1 }),
},
}
);
}
// ----------------------------------------------------
// Spawning an actor from the machine
import { interpret } from "xstate";
import { sendUpdate } from "./api-layer";
import { createToggleMachine } from "./toggle-machine";
// Works the same with useInterpret, useMachine, ...
const actor = interpret(createToggleMachine({ sendUpdate })).start();
As a side-note: Not every dependency has to be a function. They can be primitive values or complex data types as well, e.g. an ApiService
interface that bundles multiple API calls in a service. However, in a world of reactive frontend applications where everything "magically" stays in sync, be aware that these dependencies behave like any other function argument and will not automatically update the machine when changed.
Usage During Testing
Testing a machine that is created with the above pattern is straightforward. Personally, I use jest-mock-extended
all the time to elegantly mock the dependencies interface.
import { DeepMockProxy, mockDeep } from "jest-mock-extended";
import { interpret } from "xstate";
import { ToggleMachineDependencies, createToggleMachine } from "./toggle-machine";
describe("Toggle Actor", () => {
let deps: DeepMockProxy<ToggleMachineDependencies>;
beforeEach(() => {
deps = mockDeep<ToggleMachineDependencies>();
});
it("should notify the server with the new state when a toggle event occurs", () => {
const actor = interpret(createToggleMachine(deps)).start();
actor.send({ type: "toggle" });
expect(deps.sendUpdate).toBeCalledTimes(1);
expect(deps.sendUpdate).toBeCalledWith("active");
});
});
The API of jest-mock-extended
is nearly identical to Jest's mock API. If required, the return value of different dependencies can be changed with .mockReturnValue
or .mockResolvedValue
and .mockRejectedValue
. This can be especially useful for testing actors that process values returned from dependencies, e.g. fetching data from a server and transition to different states based on the result.
With this approach, it is finally possible to test all behavior that is defined by a machine. A test can verify that a dependency is called when expected, and that it is called with the expected arguments.
Conclusion
By using factories and dependency inversion, I have been able to fully test machines without mocking actions and services themself. Therefore, both state transitions and effect execution can be tested, which gives me the confidence that a machine will behave as expected when used in my application.
The steps to successfully apply this pattern can be recapped as:
- Wrap the machine creation (
createMachine
) in a factory function which returns the machine. - Provide all dependencies as a dependencies-object that is required as a parameter by the factory.
- During testing, mock dependencies-objects with
jest-mock-extended
.
Let me know what you think about this approach. Do you have any ideas on improving the pattern further?
Subscribe to my newsletter
Read articles from Christoph Fricke directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by