In this blog post I will share my experiences writing unit tests for ReasonReact components.
My test setup consists of bs-jest, bs-react-testing-library, bs-dom-testing-library, and future
bs-react-testing-library and bs-dom-testing-library are wrappers for React Testing Library and Dom Testing Library, which have recently become a popular method for testing React components. We will use them from Bucklescript to test our components! One advantage of this method is that your tests can leverage the same type system as your components.
Writing tests
Our component
We will be testing a simple note taking application. Users can enter text into an input element, and when they click the add icon, the note will be appended to note list below the input. Although the component is simple, it contains enough logic to make writing tests an interesting exercise.
module RD = ReactDOMRe;
module RR = ReasonReact;
let icon =
<svg width="24" height="24" viewBox="0 0 24 24">
<path
d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 14h-3v3h-2v-3H8v-2h3v-3h2v3h3v2zm-3-7V3.5L18.5 9H13z"
/>
</svg>;
type state = {
notes: list(string),
newVal: string,
};
type actions =
| AddNote
| UpdateText(string);
let addTestId = (testId, re) =>
RR.cloneElement(re, ~props={"data-testid": testId}, [||]);
let component = ReasonReact.reducerComponent("Notes");
let make = (~initialNotes, _children) => {
...component,
initialState: () => {notes: initialNotes, newVal: ""},
reducer: (action, state) =>
switch (action) {
| AddNote =>
ReasonReact.Update({
notes: List.append(state.notes, [state.newVal]),
newVal: "",
})
| UpdateText(txt) => RR.Update({...state, newVal: txt})
},
render: self =>
<div style={RD.Style.make(~display="flex", ~flexDirection="column", ())}>
<div
style={RD.Style.make(
~display="flex",
~alignItems="space-between",
(),
)}>
{<input
type_="text"
placeholder="Add new note here"
value={self.state.newVal}
onChange={e => {
self.send(UpdateText(ReactEvent.Form.target(e)##value));
}}
/>
|> addTestId("input-annotation")}
{<div
onClick={_ => {
self.send(AddNote);
}}>
icon
</div>
|> addTestId("add-note-button")}
</div>
{self.state.notes
|> List.mapi((idx, v) =>
<span key={string_of_int(idx)}> {RR.string(v)} </span>
|> addTestId("note-" ++ string_of_int(idx))
)
|> Array.of_list
|> RR.array}
</div>,
};
Snapshot testing
Snapshot testing with bs-jest and react-dom-testing is simple using the bindings provided by bs-jest and bs-react-testing-library.
module DT = DomTestingLibrary;
module RT = ReactTestingLibrary;
open Jest;
open Expect;
/* test must be wrapped by describe from bs-jest */
test("Notes component renders successfully", () =>
RT.render(<Notes initialNotes=["First note"] />)
|> RT.container
|> expect /* the last two functions are provided from bs-jest */
|> toMatchSnapshot
);
Testing component functionality
First we will define a simple error handler, some dom bindings to help our tests,
and a custom binding to Dom-testing-library that is needed right now due to an issue with
the FireEvent.change
bindings in bs-dom-testing-library.
Here we bind to the fireEvent method from the dom-testing-library module, and create a convienience method to call it. In addition, we create two bindings to access raw dom elements.
let errorHandler = err => Js.log(err);
[@bs.get] external innerText: Dom.element => string = "";
[@bs.get] external innerHTML: Dom.element => string = "";
/* Workaround for broken binding in dom-testing-libray.
should be fixed upstream soon */
[@bs.module "dom-testing-library"] [@bs.scope "fireEvent"]
external change: (Dom.element, Js.t({..})) => unit = "";
let changeValue = (value, domElement) =>
change(domElement, {
"target": {
"value": value,
},
});
Testing a component’s stateful functionality is more complicated. These tests have to be asynchronous because we will be interacting with the component and waiting for changes. We will use Future to convert the javascript promises into monadic “Futures” so that we can write our tests more idiomatically.
The pattern for our tests is as follows:
- Render component to jsdom
- Fire event at element
- Wait for element to change (covert this promise to a Future)
- If futher dom actions are needed, flatMapOk* the future and start at step 2 again.
- Finally, get the Future’s value, and evaluate if the value matches our expectations.
- Call the finish callback to indicate the async test completed.
flatMapOk is a convienience method provided by Future that works on Future’s containing a Belt.Result, which is a type provided by Bucklescript for distinguishing success cases (Ok) and errors (Error)
describe("Note Functionality Test", () => {
/* Previous snapshot test goes here */
testAsync(
"When user adds a note, the new note contains the user's input", finish => {
let tree = RT.render(<Notes initialNotes=["First note"] />);
let container = tree |> RT.container;
let waitForNote = () => tree |> RT.getByTestId("note-1");
let waitForInputChange = () => tree |> RT.getByValue("New Note");
let options =
DT.WaitForElement.makeOptions(~container, ~timeout=2000, ());
tree
|> RT.getByPlaceholderText("Add new note here")
|> changeValue("New Note");
DT.waitForElement(~options, ~callback=waitForInputChange, ())
->FutureJs.fromPromise(errorHandler)
->Future.flatMapOk(_ => {
tree |> RT.getByTestId("add-note-button") |> DT.FireEvent.click;
DT.waitForElement(~callback=waitForNote, ~options, ())
->FutureJs.fromPromise(errorHandler);
})
->Future.get(
fun
| Ok(node) =>
node |> innerHTML |> expect |> toEqual("New Note") |> finish
| Error(err) => {
Js.log(err);
fail("Note not found") |> finish;
},
);
});
});
I hope this article was helpful to you as you start writing tests for your ReasonReact components. The full source code is available at https://github.com/briangorman/reason-react-testing-example.