Smoke Testing a TinyMCE React Component
(with Jest & React Testing Library)
Preface
Let me start off by saying, thank you for reading this post. I’ve finally rounded up the courage to join the learn in public movement and I plan to write about specific situations that I've found to have a dearth of coverage online.
If you find place for improvement in the solutions laid out below, I encourage you to share your feedback so that I and anyone else that reads this post may benefit from it.
Context
This post will cover how to use Jest and React Testing Library to write a simple smoke test for a TinyMCE React component being used as controlled component in a Formik form within a NextJS project.
Prior to incorporating a rich text editor in my project, my form and corresponding smoke test looked similar to:
// NewQuizForm.js
import { Formik, Field, Form, ErrorMessage, FieldArray } from "formik";
import * as yup from "yup";
const validationSchema = yup.object().shape({
query: yup.string().required("Query is required."),
});
export function NewQuizForm() {
return (
<Formik
initialValues={{
query: "",
}}
validationSchema={validationSchema}
onSubmit={(values, { setSubmitting }) => {
setSubmitting(false);
alert(JSON.stringify(values, null, 2));
}}
>
{({ values }) => {
return (
<Form>
<h1>Questions</h1>
<label htmlFor="query">Query</label>
<Field id="query" name="query" type="text" />
<ErrorMessage name="query" />
</Form>
);
}}
</Formik>
);
}
// NewQuizForm.spec.js
import { screen, render } from "@testing-library/react";
import NewQuizForm from "../pages/new-quiz";
it("renders expected input fields and no error messages", () => {
render(<NewQuizForm />);
const queryInput = screen.getByRole("textbox", { name: /query/i });
const queryError = screen.queryByText(/query is required/i);
expect(queryInput).toBeInTheDocument();
expect(queryError).toBe(null);
});
Initial Failed Test
When I incorporated TinyMCE's rich text editor component into the form, the component and corresponding test output now looked something like so:
// NewQuizForm.js
//...
<label htmlFor="query">Query</label>
<Field id="query" name="query">
{(props) => {
return (
<RichTextEditor
{/* ... */}
/>
);
}}
</Field>
<ErrorMessage name="query" />
# output after running `yarn test __tests__/NewQuizForm.spec.js`
TestingLibraryElementError: Unable to find an accessible element with the role "textbox" and name `/query/i`
# RTL's log of elements rendered to the DOM
<h1>
Questions
</h1>
<div>
<label
for="query"
>
Query
</label>
<textarea
id="query"
style="visibility: hidden;"
/>
Note that in the output, we see a hidden <textarea> element. This is because when the TinyMCE editor is in classic (iframe) mode it inserts a textarea element that then gets replaced with an iframe and other UI elements (required for the toolbar, menu bar, etc..) when the rich text editor component is loaded.
The challenge at this point became how to load the rich text editor in my testing environment.
Loading TinyMCE in Testing Environment
Thanks to a post on stackoverflow, I realized that because I had chosen the TinyMCE self-hosted option using tinymceScriptScr (attribute added to the rich text editor component which specifies the path to the TinyMCE script, which was located in my project's public folder) I needed to configure my jsdom testing environment to load sub-resources.
As the stackoverflow post suggested, I started by modifying the testEnvironmentOptions in my Jest config file:
// jest.config.js
module.exports = {
// ...
testEnvironment: "jsdom",
testEnvironmentOptions: { resources: "usable" },
};
However, that still didn't do the trick, as the rich text editor was still not being loaded. Luckily, I found another article on stackoverflow that referenced an adiitional option needed to specifically load subresources that are scripts. I went on to verify the information by examining jsdom's loading subresources section, and discovered that there was one last hitch. I would be unable to load the rich text editor component because in my project I was using a relative URL for it's script path value. For a third time, users of stackoverflow came to the rescue by posting about how to configure a testURL in jest.
The final configuration that got everything to work was:
// jest.config.js
module.exports = {
// ...
// note that 'runscripts' property was added to testEnvironmentOptions' object
testEnvironmentOptions: { resources: "usable", runScripts: "dangerously" },
// AND a absolute testURL was provided
testURL: "http://localhost:3000",
};
Note that because I was loading the TinyMCE script from my project's public folder, the testURL matched the address of my local development server (which has to be running while conducting the tests for the resource to be fetched and loaded properly during testing).
Updating the Test Case
Next, I had to update the test case to reflect that I was querying for something that would not be available right away:
// NewQuizForm.spec.js
// ...
it("renders expected input fields and no error messages", async () => {
render(<NewQuizForm />);
// notice we not only use async/await but we switch our query from `getByRole` to `findByRole`
const queryInput = await screen.findByRole("textbox", { name: /query/i });
// ...
});
If you're interested in learning more about why findByRole was needed, checkout this great article on React Testing Library best practices.
Running yarn test __tests__NewQuizForm.spec.js at this point would unfornately yield another error:
# ...
Error: Uncaught [TypeError: window.matchMedia is not a function]
# ...
Fortunately for us, Jest's docs had a ready made solution for this issue:
// NewQuizForm.spec.js
// ...
// obtained from Jest docs on mocking methods which are not implemented in JSDOM
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
it("should initially render expected number of tinyMCE instances and no error messages", async () => {
// ...
});
Running the test after adding the above manual mock resulted in the following failed result:
# FAIL status output..
renders expected input fields and no error messages
# RTL's log of elements rendered to the DOM
<label
for="query"
>
Query
</label>
<textarea
aria-hidden="true"
id="query"
style="display: none;"
/>
<div
aria-disabled="false"
class="tox tox-tinymce"
role="application"
style="visibility: hidden; height: 200px;"
>
# the total rendered output of the rich text editor is omitted for the sake of brevity
Although the test failed, we finally arrived at the point were the TinyMCE rich text editor component is being loaded! My final step was to adjust my query to reflect the appropriate role we are now querying for:
// NewQuizForm.spec.js
// ...
const queryInput = await screen.findByRole("application", {
hidden: true,
});
// ...
That's all I have for today, I hope this helped!