From f30265f66572c971bbcdeae0a335c020d0d7ef74 Mon Sep 17 00:00:00 2001 From: Arturs Vonda Date: Thu, 11 May 2023 18:26:16 +0300 Subject: [PATCH] Migrate to using @testing-library/user-event Removes redundant `act` calls as well. --- README.md | 2 +- package.json | 11 +- src/__tests__/select-event.test.tsx | 264 ++++++++++++++++++++++++---- src/act-compat.ts | 26 --- src/index.ts | 103 ++++++----- 5 files changed, 298 insertions(+), 108 deletions(-) delete mode 100644 src/act-compat.ts diff --git a/README.md b/README.md index e16e108..497da9a 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ const { getByRole, getByLabelText } = render( expect(getByRole("form")).toHaveFormValues({ food: "" }); // start typing to trigger the `loadOptions` -fireEvent.change(getByLabelText("Food"), { target: { value: "Choc" } }); +userEvent.type(getByLabelText("Food"), "Choc"); await selectEvent.select(getByLabelText("Food"), "Chocolate"); expect(getByRole("form")).toHaveFormValues({ food: ["chocolate"], diff --git a/package.json b/package.json index f6ff54a..1ed4662 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ }, "homepage": "https://github.com/romgain/react-select-event#readme", "dependencies": { - "@testing-library/dom": ">=7" + "@testing-library/dom": ">=7", + "@testing-library/user-event": ">=14" }, "devDependencies": { "@babel/core": "^7.4.5", @@ -46,14 +47,14 @@ "@babel/preset-react": "^7.0.0", "@babel/preset-typescript": "^7.3.3", "@testing-library/jest-dom": "^5.0.1", - "@testing-library/react": "^12.1.5", + "@testing-library/react": "^14.0.0", "@types/jest": "^29.1.2", - "@types/react": "^17.0.47", + "@types/react": "^18.2.6", "@types/react-select": "^5.0.1", "jest": "^27.0.4", "prettier": "^2.0.2", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", "react-select": "^5.0.0", "rimraf": "^3.0.0", "rollup": "^2.0.3", diff --git a/src/__tests__/select-event.test.tsx b/src/__tests__/select-event.test.tsx index bf9d78c..cc75fa3 100644 --- a/src/__tests__/select-event.test.tsx +++ b/src/__tests__/select-event.test.tsx @@ -1,6 +1,7 @@ import "@testing-library/jest-dom/extend-expect"; -import { fireEvent, render } from "@testing-library/react"; +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import React from "react"; import Select from "react-select"; @@ -56,20 +57,20 @@ const renderForm = (select: React.ReactNode) => { }; describe("The openMenu event helper", () => { - it("opens the menu", () => { + it("opens the menu", async () => { const { getByLabelText, queryByText } = renderForm( ); - selectEvent.openMenu(input); + await selectEvent.openMenu(input); expect(getByText("Chocolate")).toBeInTheDocument(); expect(getByText("Vanilla")).toBeInTheDocument(); expect(getByText("Strawberry")).toBeInTheDocument(); @@ -77,6 +78,34 @@ describe("The openMenu event helper", () => { await selectEvent.select(input, "Strawberry"); expect(form).toHaveFormValues({ food: "strawberry" }); }); + + it("allows passing custom userEvent option", async () => { + const user = userEvent.setup(); + jest.spyOn(user, "click"); + jest.spyOn(user, "type"); + + const { input } = renderForm(); + + await userSelectEvent.openMenu(input); + + expect(user.click).toHaveBeenCalledWith(input); + expect(user.type).toHaveBeenCalledWith(input, "{ArrowDown}"); + }); }); describe("The select event helpers", () => { @@ -240,6 +269,7 @@ describe("The select event helpers", () => { }); it("selects an option in an async input", async () => { + const user = userEvent.setup(); const loadOptions = (_: string, callback: Callback) => setTimeout(() => callback(OPTIONS), 100); const { form, input } = renderForm( @@ -248,7 +278,7 @@ describe("The select event helpers", () => { expect(form).toHaveFormValues({ food: "" }); // start typing to trigger the `loadOptions` - fireEvent.change(input, { target: { value: "Choc" } }); + await user.type(input, "Choc"); await selectEvent.select(input, "Chocolate"); expect(form).toHaveFormValues({ food: "chocolate" }); }); @@ -344,6 +374,42 @@ describe("The select event helpers", () => { expect(form).toHaveFormValues({ food: "vanilla" }); }); + it("allows passing custom userEvent option", async () => { + const user = userEvent.setup(); + jest.spyOn(user, "click"); + jest.spyOn(user, "type"); + + const { input } = renderForm(); + + await userSelectEvent.select(input, "Chocolate"); + + // Open the dropdown + expect(user.type).toHaveBeenCalledWith(input, "{ArrowDown}"); + expect(user.click).toHaveBeenNthCalledWith(1, input); + + // Difficult to get correct element here but it's fine as long as we get an element + expect(user.click).toHaveBeenNthCalledWith(2, expect.any(HTMLDivElement)); + }); + describe("when asynchronously generating the list of options", () => { // from https://github.com/JedWatson/react-select/blob/v3.0.0/docs/examples/CreatableAdvanced.js // mixed with Async Creatable Example from https://react-select.com/creatable @@ -416,6 +482,56 @@ describe("The select event helpers", () => { expect(form).toHaveFormValues({ food: "papaya" }); }); + + it("allows passing custom userEvent option", async () => { + const user = userEvent.setup(); + jest.spyOn(user, "click"); + jest.spyOn(user, "type"); + + const { input } = renderForm(); + + await selectEvent.create(input, "papaya", { user }); + + // Open the dropdown + expect(user.click).toHaveBeenNthCalledWith(1, input); + expect(user.type).toHaveBeenCalledWith(input, "{ArrowDown}"); + + // Create option + expect(user.type).toHaveBeenNthCalledWith(2, input, "papaya"); + + // Open dropdown again + expect(user.click).toHaveBeenNthCalledWith(2, input); + + // Select the new option + // Difficult to get correct element here but it's fine as long as we get an element + expect(user.click).toHaveBeenNthCalledWith(3, expect.any(HTMLDivElement)); + }); + + it("allows passing custom userEvent option with setup", async () => { + const user = userEvent.setup(); + jest.spyOn(user, "click"); + jest.spyOn(user, "type"); + + const userSelectEvent = selectEvent.setup(user); + + const { input } = renderForm(); + + await userSelectEvent.create(input, "papaya"); + + // Open the dropdown + expect(user.click).toHaveBeenNthCalledWith(1, input); + expect(user.type).toHaveBeenCalledWith(input, "{ArrowDown}"); + + // Create option + expect(user.type).toHaveBeenNthCalledWith(2, input, "papaya"); + + // Open dropdown again + expect(user.click).toHaveBeenNthCalledWith(2, input); + + // Select the new option + // Difficult to get correct element here but it's fine as long as we get an element + expect(user.click).toHaveBeenNthCalledWith(3, expect.any(HTMLDivElement)); + }); }); describe("when rendering the dropdown in a portal", () => { @@ -452,39 +568,119 @@ describe("The select event helpers", () => { await selectEvent.create(input, "papaya", { container: document.body }); expect(form).toHaveFormValues({ food: "papaya" }); }); + }); +}); - it("clears the first item in a multi-select dropdown", async () => { - const { form, input } = renderForm( - - ); - expect(form).toHaveFormValues({ - food: ["chocolate", "vanilla", "strawberry"], - }); - - await selectEvent.clearFirst(input); - expect(form).toHaveFormValues({ food: ["vanilla", "strawberry"] }); +describe("clearFirst", () => { + it("clears the first item in a multi-select dropdown", async () => { + const { form, input } = renderForm( + + ); + expect(form).toHaveFormValues({ + food: ["chocolate", "vanilla", "strawberry"], }); - it("clears all items", async () => { - const { form, input } = renderForm( - - ); - expect(form).toHaveFormValues({ - food: ["chocolate", "vanilla", "strawberry"], - }); + await selectEvent.clearFirst(input); + expect(form).toHaveFormValues({ food: ["vanilla", "strawberry"] }); + }); - await selectEvent.clearAll(input); - expect(form).toHaveFormValues({ food: "" }); + it("allows passing custom userEvent option", async () => { + const user = userEvent.setup(); + jest.spyOn(user, "click"); + + const { input } = renderForm( + + ); + + await selectEvent.clearFirst(input, { user }); + + expect(user.click).toHaveBeenCalledWith(expect.any(SVGSVGElement)); + }); + + it("allows passing custom userEvent option with setup", async () => { + const user = userEvent.setup(); + jest.spyOn(user, "click"); + + const userSelectEvent = selectEvent.setup(user); + + const { input } = renderForm( + + ); + + await userSelectEvent.clearFirst(input); + + expect(user.click).toHaveBeenCalledWith(expect.any(SVGSVGElement)); + }); +}); + +describe("clearAll", () => { + it("clears all items", async () => { + const { form, input } = renderForm( + + ); + expect(form).toHaveFormValues({ + food: ["chocolate", "vanilla", "strawberry"], }); + + await selectEvent.clearAll(input); + expect(form).toHaveFormValues({ food: "" }); + }); + + it("allows passing custom userEvent option", async () => { + const user = userEvent.setup(); + jest.spyOn(user, "click"); + + const { input } = renderForm( + + ); + + await selectEvent.clearAll(input, { user }); + + expect(user.click).toHaveBeenCalledWith(expect.any(SVGSVGElement)); + }); + + it("allows passing custom userEvent option with setup", async () => { + const user = userEvent.setup(); + jest.spyOn(user, "click"); + + const userSelectEvent = selectEvent.setup(user); + + const { input } = renderForm( + + ); + + await userSelectEvent.clearAll(input); + + expect(user.click).toHaveBeenCalledWith(expect.any(SVGSVGElement)); }); }); diff --git a/src/act-compat.ts b/src/act-compat.ts deleted file mode 100644 index 4c9e96a..0000000 --- a/src/act-compat.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * A simple compatibility method for react's "act". - * If a recent version of @testing-library/react is already installed, - * we just use their implementation - it's complete and has useful warnings. - * Otherwise, we just default to a noop. - * - * We need this because react-select-event doesn't actually pin a - * dependency version for @testing-library/react! - */ - -type Callback = () => Promise | void | undefined; -type AsyncAct = (callback: Callback) => Promise; -type SyncAct = (callback: Callback) => void; - -let act: AsyncAct | SyncAct; - -try { - act = require("@testing-library/react").act; -} catch (_) { - // istanbul ignore next - act = (callback: Function) => { - callback(); - }; -} - -export default act; diff --git a/src/index.ts b/src/index.ts index 5a91cca..903180b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,11 +4,9 @@ import { Matcher, findAllByText, findByText, - fireEvent, waitFor, } from "@testing-library/dom"; - -import act from "./act-compat"; +import userEvent from "@testing-library/user-event"; // find the react-select container from its input field 🤷 function getReactSelectContainerFromInput(input: HTMLElement): HTMLElement { @@ -16,36 +14,42 @@ function getReactSelectContainerFromInput(input: HTMLElement): HTMLElement { .parentNode as HTMLElement; } +type User = ReturnType | typeof userEvent; + +type UserEventOptions = { + user?: User; +}; + /** * Utility for opening the select's dropdown menu. * @param {HTMLElement} input The input field (eg. `getByLabelText('The label')`) */ -export const openMenu = (input: HTMLElement) => { - fireEvent.focus(input); - fireEvent.keyDown(input, { - key: "ArrowDown", - keyCode: 40, - code: 40, - }); +export const openMenu = async ( + input: HTMLElement, + { user = userEvent }: UserEventOptions = {} +) => { + await user.click(input); + await user.type(input, "{ArrowDown}"); }; // type text in the input field -const type = (input: HTMLElement, text: string) => { - fireEvent.change(input, { target: { value: text } }); +const type = async ( + input: HTMLElement, + text: string, + { user }: Required +) => { + await user.type(input, text); }; // press the "clear" button, and reset various states -const clear = async (input: HTMLElement, clearButton: Element) => { - await act(async () => { - fireEvent.mouseDown(clearButton); - fireEvent.click(clearButton); - // react-select will prevent the menu from opening, and asynchronously focus the select field... - await waitFor(() => {}); - input.blur(); - }); +const clear = async ( + clearButton: Element, + { user }: Required +) => { + await user.click(clearButton); }; -interface Config { +interface Config extends UserEventOptions { /** A container where the react-select dropdown gets rendered to. * Useful when rendering the dropdown in a portal using `menuPortalTarget`. */ @@ -64,7 +68,7 @@ interface Config { export const select = async ( input: HTMLElement, optionOrOptions: Matcher | Array, - config: Config = {} + { user = userEvent, ...config }: Config = {} ) => { const options = Array.isArray(optionOrOptions) ? optionOrOptions @@ -72,7 +76,7 @@ export const select = async ( // Select the items we care about for (const option of options) { - await openMenu(input); + await openMenu(input, { user }); let container; if (typeof config.container === "function") { @@ -92,17 +96,15 @@ export const select = async ( ignore: "[aria-live] *,[style*='visibility: hidden']", }); - act(() => { - // When the target option is already selected, the react-select display text - // will also match the selector. In this case, the actual dropdown element is - // positioned last in the DOM tree. - const optionElement = matchingElements[matchingElements.length - 1]; - fireEvent.click(optionElement); - }); + // When the target option is already selected, the react-select display text + // will also match the selector. In this case, the actual dropdown element is + // positioned last in the DOM tree. + const optionElement = matchingElements[matchingElements.length - 1]; + await user.click(optionElement); } }; -interface CreateConfig extends Config { +interface CreateConfig extends Config, UserEventOptions { createOptionText?: string | RegExp; waitForElement?: boolean; } @@ -120,15 +122,13 @@ interface CreateConfig extends Config { export const create = async ( input: HTMLElement, option: string, - { waitForElement = true, ...config }: CreateConfig = {} + { waitForElement = true, user = userEvent, ...config }: CreateConfig = {} ) => { const createOptionText = config.createOptionText || /^Create "/; - openMenu(input); - type(input, option); + await openMenu(input, { user }); + await type(input, option, { user }); - fireEvent.change(input, { target: { value: option } }); - - await select(input, createOptionText, config); + await select(input, createOptionText, { ...config, user }); if (waitForElement) { await findByText(getReactSelectContainerFromInput(input), option); @@ -139,25 +139,44 @@ export const create = async ( * Utility for clearing the first value of a `react-select` dropdown. * @param {HTMLElement} input The input field (eg. `getByLabelText('The label')`) */ -export const clearFirst = async (input: HTMLElement) => { +export const clearFirst = async ( + input: HTMLElement, + { user = userEvent }: UserEventOptions = {} +) => { const container = getReactSelectContainerFromInput(input); // The "clear" button is the first svg element that is hidden to screen readers const clearButton = container.querySelector('svg[aria-hidden="true"]')!; - await clear(input, clearButton); + await clear(clearButton, { user }); }; /** * Utility for clearing all values in a `react-select` dropdown. * @param {HTMLElement} input The input field (eg. `getByLabelText('The label')`) */ -export const clearAll = async (input: HTMLElement) => { +export const clearAll = async ( + input: HTMLElement, + { user = userEvent }: UserEventOptions = {} +) => { const container = getReactSelectContainerFromInput(input); // The "clear all" button is the penultimate svg element that is hidden to screen readers // (the last one is the dropdown arrow) const elements = container.querySelectorAll('svg[aria-hidden="true"]'); const clearAllButton = elements[elements.length - 2]; - await clear(input, clearAllButton); + await clear(clearAllButton, { user }); }; +const setup = (user: User): typeof selectEvent => ({ + select: (...params: Parameters) => + select(params[0], params[1], { user, ...params[2] }), + create: (...params: Parameters) => + create(params[0], params[1], { user, ...params[2] }), + clearFirst: (...params: Parameters) => + clearFirst(params[0], { user, ...params[1] }), + clearAll: (...params: Parameters) => + clearAll(params[0], { user, ...params[1] }), + openMenu: (...params: Parameters) => + openMenu(params[0], { user, ...params[1] }), +}); + const selectEvent = { select, create, clearFirst, clearAll, openMenu }; -export default selectEvent; +export default { ...selectEvent, setup };