From 8fe1cb7ea0b0869dc31ff468f7347e9af2ff219e Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Wed, 11 Oct 2023 12:19:23 -0600 Subject: [PATCH 1/8] feat: stablize createRemixStub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This thing is ready to go 👍 --- packages/remix-testing/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/remix-testing/index.ts b/packages/remix-testing/index.ts index fe7a155819e..39eb89457bd 100644 --- a/packages/remix-testing/index.ts +++ b/packages/remix-testing/index.ts @@ -1,2 +1,2 @@ export type { RemixStubProps } from "./create-remix-stub"; -export { createRemixStub as unstable_createRemixStub } from "./create-remix-stub"; +export { createRemixStub } from "./create-remix-stub"; From b29c38b5b56202fe94fe7552a7151eb12135534c Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Wed, 11 Oct 2023 12:23:00 -0600 Subject: [PATCH 2/8] Create perfect-lamps-itch.md --- .changeset/perfect-lamps-itch.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/perfect-lamps-itch.md diff --git a/.changeset/perfect-lamps-itch.md b/.changeset/perfect-lamps-itch.md new file mode 100644 index 00000000000..3b53b68542a --- /dev/null +++ b/.changeset/perfect-lamps-itch.md @@ -0,0 +1,6 @@ +--- +"remix": patch +"@remix-run/testing": patch +--- + +Remove the `unstable_` prefix from `createRemixStub`. After real-world experience, we're confident in the API and ready to commit to it. From 1adb75a4d837adba0cf7b00d7aeccffbbd35a436 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Wed, 11 Oct 2023 12:28:47 -0600 Subject: [PATCH 3/8] remove unstable_ prefix from createRemixStub --- .../__tests__/integration/meta-test.tsx | 22 +++++++++---------- .../remix-testing/__tests__/stub-test.tsx | 14 ++++++------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/remix-react/__tests__/integration/meta-test.tsx b/packages/remix-react/__tests__/integration/meta-test.tsx index 71637720a79..644bfba530e 100644 --- a/packages/remix-react/__tests__/integration/meta-test.tsx +++ b/packages/remix-react/__tests__/integration/meta-test.tsx @@ -1,5 +1,5 @@ import { Meta, Outlet } from "@remix-run/react"; -import { unstable_createRemixStub } from "@remix-run/testing"; +import { createRemixStub } from "@remix-run/testing"; import { prettyDOM, render, screen } from "@testing-library/react"; import user from "@testing-library/user-event"; import * as React from "react"; @@ -9,7 +9,7 @@ const getHtml = (c: HTMLElement) => describe("meta", () => { it("no meta export renders meta from nearest route meta in the tree", () => { - let RemixStub = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { id: "root", path: "/", @@ -66,7 +66,7 @@ describe("meta", () => { }); it("empty meta array does not render a tag", () => { - let RemixStub = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { path: "/", meta: () => [], @@ -93,7 +93,7 @@ describe("meta", () => { }); it("meta from `matches` renders meta tags", () => { - let RemixStub = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { id: "root", path: "/", @@ -141,7 +141,7 @@ describe("meta", () => { }); it("{ charSet } adds a ", () => { - let RemixStub = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { path: "/", meta: () => [{ charSet: "utf-8" }], @@ -161,7 +161,7 @@ describe("meta", () => { }); it("{ title } adds a ", () => { - let RemixStub = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { path: "/", meta: () => [{ title: "Document Title" }], @@ -181,7 +181,7 @@ describe("meta", () => { }); it("{ property: 'og:*', content: '*' } adds a <meta property='og:*' />", () => { - let RemixStub = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { path: "/", meta: () => [ @@ -221,7 +221,7 @@ describe("meta", () => { email: ["sonnyday@fancymail.com", "surfergal@veryprofessional.org"], }; - let RemixStub = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { path: "/", meta: () => [ @@ -244,7 +244,7 @@ describe("meta", () => { }); it("{ tagName: 'link' } adds a <link />", () => { - let RemixStub = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { path: "/", meta: () => [ @@ -270,7 +270,7 @@ describe("meta", () => { }); it("does not mutate meta when using tagName", async () => { - let RemixStub = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { path: "/", meta: ({ data }) => data?.meta, @@ -329,7 +329,7 @@ describe("meta", () => { }); it("loader errors are passed to meta", () => { - let RemixStub = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { path: "/", Component() { diff --git a/packages/remix-testing/__tests__/stub-test.tsx b/packages/remix-testing/__tests__/stub-test.tsx index bdc584839e5..ea44bff0939 100644 --- a/packages/remix-testing/__tests__/stub-test.tsx +++ b/packages/remix-testing/__tests__/stub-test.tsx @@ -1,12 +1,12 @@ import * as React from "react"; import { render, screen } from "@testing-library/react"; -import { unstable_createRemixStub } from "@remix-run/testing"; +import { createRemixStub } from "@remix-run/testing"; import { Outlet, useLoaderData, useMatches } from "@remix-run/react"; import type { DataFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; test("renders a route", () => { - let RemixStub = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { path: "/", Component: () => <div>HOME</div>, @@ -19,7 +19,7 @@ test("renders a route", () => { }); test("renders a nested route", () => { - let RemixStub = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { Component() { return ( @@ -50,7 +50,7 @@ test("loaders work", async () => { return <pre data-testid="data">Message: {data.message}</pre>; } - let RemixStub = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { path: "/", index: true, @@ -73,7 +73,7 @@ test("can pass a predefined loader", () => { return json({ hi: "there" }); } - unstable_createRemixStub([ + createRemixStub([ { path: "/example", loader, @@ -97,7 +97,7 @@ test("can pass context values", async () => { return <pre data-testid="hello">Context: {data.context}</pre>; } - let RemixStub = unstable_createRemixStub( + let RemixStub = createRemixStub( [ { path: "/", @@ -141,7 +141,7 @@ test("all routes have ids", () => { ); } - let RemixStub = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { Component() { return ( From 6c5ee016835236fb0ab997ec1b6bd8f8a9007068 Mon Sep 17 00:00:00 2001 From: Matt Brophy <matt@brophy.org> Date: Wed, 11 Oct 2023 16:39:49 -0400 Subject: [PATCH 4/8] Add docs and rename remixConfigFuture -> future --- ...itch.md => stabilize-create-remix-stub.md} | 1 + docs/other-api/testing.md | 78 ++++++++++ docs/utils/create-remix-stub.md | 67 +++++++++ .../remix-testing/__tests__/stub-test.tsx | 137 ++++++++++++------ packages/remix-testing/create-remix-stub.tsx | 6 +- 5 files changed, 245 insertions(+), 44 deletions(-) rename .changeset/{perfect-lamps-itch.md => stabilize-create-remix-stub.md} (59%) create mode 100644 docs/other-api/testing.md create mode 100644 docs/utils/create-remix-stub.md diff --git a/.changeset/perfect-lamps-itch.md b/.changeset/stabilize-create-remix-stub.md similarity index 59% rename from .changeset/perfect-lamps-itch.md rename to .changeset/stabilize-create-remix-stub.md index 3b53b68542a..21c2612632d 100644 --- a/.changeset/perfect-lamps-itch.md +++ b/.changeset/stabilize-create-remix-stub.md @@ -4,3 +4,4 @@ --- Remove the `unstable_` prefix from `createRemixStub`. After real-world experience, we're confident in the API and ready to commit to it. +* Note: This involves 1 small breaking change. The `<RemixStub remixConfigFuture>` prop has been renamed to `<RemixStub future>` diff --git a/docs/other-api/testing.md b/docs/other-api/testing.md new file mode 100644 index 00000000000..8f72c5a357f --- /dev/null +++ b/docs/other-api/testing.md @@ -0,0 +1,78 @@ +--- +title: "@remix-run/testing" +--- + +# `@remix-run/testing` + +This package contains utilities to assist in unit testing portions of your Remix application. This is accomplished by mocking the Remix route modules/assets manifest output by the compiler and generating an in-memory React Router app via [createMemoryRouter][memory-router]. + +The general usage of this is to test components/hooks that rely on Remix hooks/components which you do not have the ability to cleanly mock (`useLoaderData`, `useFetcher`, etc.). While it can also be used for more advanced testing such as clicking links and navigating to pages, those are better suited for End to End tests via something like [Cypress][cypress] or [Playwright][playwright]. + +## Usage + +To use `createRemixStub`, define your routes using React Router-like route objects, where you specify the `path`, `Component`, `loader`, etc. These are essentially mocking the nesting and exports of the route files in your Remix app: + +```tsx +const RemixStub = createRemixStub([ + { + path: "/", + Component: MyComponent, + loader() { + return json({ message: "hello" }); + }, + }, +]); +``` + +Then you can render the `<RemixStub />` component and assert against it: + +```tsx +render(<RemixStub />); +await waitFor(() => + screen.findByText("Some rendered text") +); +``` + +## Example + +Here's a full working example testing using [`jest`][jest] and [React Testing Library][rtl]: + +```tsx +import { json } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; +import { createRemixStub } from "@remix-run/testing"; +import { + render, + screen, + waitFor, +} from "@testing-library/react"; +import * as React from "react"; + +test("renders loader data", async () => { + // ⚠️ This would usually be a component you import from your app code + function MyComponent() { + const data = useLoaderData() as { message: string }; + return <p>Message: {data.message}</p>; + } + + const RemixStub = createRemixStub([ + { + path: "/", + Component: MyComponent, + loader() { + return json({ message: "hello" }); + }, + }, + ]); + + render(<RemixStub />); + + await waitFor(() => screen.findByText("Message: hello")); +}); +``` + +[memory-router]: https://reactrouter.com/en/main/routers/create-memory-router +[cypress]: https://www.cypress.io/ +[playwright]: https://playwright.dev/ +[rtl]: https://testing-library.com/docs/react-testing-library/intro/ +[jest]: https://jestjs.io/ diff --git a/docs/utils/create-remix-stub.md b/docs/utils/create-remix-stub.md new file mode 100644 index 00000000000..6fd6353a790 --- /dev/null +++ b/docs/utils/create-remix-stub.md @@ -0,0 +1,67 @@ +--- +title: createRemixStub +--- + +# `createRemixStub` + +This utility allows you to unit-test your own components that rely on Remix hooks/components by setting up a mocked set of routes: + +```tsx +test("renders loader data", async () => { + const RemixStub = createRemixStub([ + { + path: "/", + meta() { ... }, + links() { ... }, + Component: MyComponent, + ErrorBoundary: MyErrorBoundary, + action() {...}, + loader() {...}, + }, + ]); + + render(<RemixStub />); + + // Assert initial render + await waitFor(() => screen.findByText("...")); + + // Click a button and assert a UI change + user.click(screen.getByText("button text")); + await waitFor(() => screen.findByText("...")); +}); +``` + +If your loaders rely on the `getLoadContext` method, you can provide a stuibbed context via the second parameter to `createRemixStub`: + +```tsx +const RemixStub = createRemixStub( + [ + { + path: "/", + Component: MyComponent, + loader({ context }) { + return json({ message: context.key }); + }, + }, + ], + { key: "value" } +); +``` + +The `<RemixStub>` component itself takes properties similar to React Router if you need to control the initial URL, history stack, hydration data, or future flags: + +```tsx +// Test the app rendered at "/2" with 2 prior history stack entries +render(<RemixStub initialEntries={['/', '/1', '/2']} initialIndex={2}) /> + +// Test the app rendered with initial loader data for the root route. When using +// this, it's best to give your routes their own unique IDs in your route definitions +render(<RemixStub hydrationData={{ + loaderData: { + root: { message: 'hello' } + } +}} /> + +// Test the app rendered with given future flags enabled +render(<RemixStub future={{ v3_coolFeature: true }} /> +``` diff --git a/packages/remix-testing/__tests__/stub-test.tsx b/packages/remix-testing/__tests__/stub-test.tsx index ea44bff0939..09586e281c0 100644 --- a/packages/remix-testing/__tests__/stub-test.tsx +++ b/packages/remix-testing/__tests__/stub-test.tsx @@ -1,7 +1,15 @@ import * as React from "react"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import user from "@testing-library/user-event"; import { createRemixStub } from "@remix-run/testing"; -import { Outlet, useLoaderData, useMatches } from "@remix-run/react"; +import { + Form, + Outlet, + useActionData, + useFetcher, + useLoaderData, + useMatches, +} from "@remix-run/react"; import type { DataFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; @@ -45,16 +53,13 @@ test("renders a nested route", () => { }); test("loaders work", async () => { - function App() { - let data = useLoaderData(); - return <pre data-testid="data">Message: {data.message}</pre>; - } - let RemixStub = createRemixStub([ { path: "/", - index: true, - Component: App, + Component() { + let data = useLoaderData(); + return <pre data-testid="data">Message: {data.message}</pre>; + }, loader() { return json({ message: "hello" }); }, @@ -63,9 +68,63 @@ test("loaders work", async () => { render(<RemixStub />); - expect(await screen.findByTestId("data")).toHaveTextContent( - /message: hello/i - ); + await waitFor(() => screen.findByText("Message: hello")); +}); + +test("actions work", async () => { + let RemixStub = createRemixStub([ + { + path: "/", + Component() { + let data = useActionData() as { message: string } | undefined; + return ( + <Form method="post"> + <button type="submit">Submit</button> + {data ? <pre>Message: {data.message}</pre> : null} + </Form> + ); + }, + action() { + return json({ message: "hello" }); + }, + }, + ]); + + render(<RemixStub />); + + user.click(screen.getByText("Submit")); + await waitFor(() => screen.findByText("Message: hello")); +}); + +test("fetchers work", async () => { + let count = 0; + let RemixStub = createRemixStub([ + { + path: "/", + Component() { + let fetcher = useFetcher<{ count: number }>(); + return ( + <button onClick={() => fetcher.load("/api")}> + {fetcher.state + " " + (fetcher.data?.count || 0)} + </button> + ); + }, + }, + { + path: "/api", + loader() { + return json({ count: ++count }); + }, + }, + ]); + + render(<RemixStub />); + + user.click(screen.getByText("idle 0")); + await waitFor(() => screen.findByText("idle 1")); + + user.click(screen.getByText("idle 1")); + await waitFor(() => screen.findByText("idle 2")); }); test("can pass a predefined loader", () => { @@ -82,33 +141,29 @@ test("can pass a predefined loader", () => { }); test("can pass context values", async () => { - function App() { - let data = useLoaderData(); - return ( - <div> - <pre data-testid="root">Context: {data.context}</pre>; - <Outlet /> - </div> - ); - } - - function Hello() { - let data = useLoaderData(); - return <pre data-testid="hello">Context: {data.context}</pre>; - } - let RemixStub = createRemixStub( [ { path: "/", - Component: App, + Component() { + let data = useLoaderData() as { context: string }; + return ( + <div> + <pre data-testid="root">Context: {data.context}</pre>; + <Outlet /> + </div> + ); + }, loader({ context }) { return json(context); }, children: [ { path: "hello", - Component: Hello, + Component() { + let data = useLoaderData() as { context: string }; + return <pre data-testid="hello">Context: {data.context}</pre>; + }, loader({ context }) { return json(context); }, @@ -130,17 +185,6 @@ test("can pass context values", async () => { }); test("all routes have ids", () => { - function Home() { - let matches = useMatches(); - - return ( - <div> - <h1>HOME</h1> - <pre data-testid="matches">{JSON.stringify(matches, null, 2)}</pre> - </div> - ); - } - let RemixStub = createRemixStub([ { Component() { @@ -154,7 +198,18 @@ test("all routes have ids", () => { children: [ { path: "/", - Component: Home, + Component() { + let matches = useMatches(); + + return ( + <div> + <h1>HOME</h1> + <pre data-testid="matches"> + {JSON.stringify(matches, null, 2)} + </pre> + </div> + ); + }, }, ], }, diff --git a/packages/remix-testing/create-remix-stub.tsx b/packages/remix-testing/create-remix-stub.tsx index 34257bfb8f7..ec40ba11131 100644 --- a/packages/remix-testing/create-remix-stub.tsx +++ b/packages/remix-testing/create-remix-stub.tsx @@ -85,7 +85,7 @@ export interface RemixStubProps { /** * Future flags mimicking the settings in remix.config.js */ - remixConfigFuture?: Partial<FutureConfig>; + future?: Partial<FutureConfig>; } export function createRemixStub( @@ -96,14 +96,14 @@ export function createRemixStub( initialEntries, initialIndex, hydrationData, - remixConfigFuture, + future, }: RemixStubProps) { let routerRef = React.useRef<Router>(); let remixContextRef = React.useRef<RemixContextObject>(); if (routerRef.current == null) { remixContextRef.current = { - future: { ...remixConfigFuture }, + future: { ...future }, manifest: { routes: {}, entry: { imports: [], module: "" }, From 516f4194c1d084671be4030671c4074c8f4aad12 Mon Sep 17 00:00:00 2001 From: Matt Brophy <matt@brophy.org> Date: Wed, 11 Oct 2023 16:41:43 -0400 Subject: [PATCH 5/8] Change to minor changeset due to 'breaking' change --- .changeset/stabilize-create-remix-stub.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/stabilize-create-remix-stub.md b/.changeset/stabilize-create-remix-stub.md index 21c2612632d..aa9f20c1454 100644 --- a/.changeset/stabilize-create-remix-stub.md +++ b/.changeset/stabilize-create-remix-stub.md @@ -1,6 +1,6 @@ --- -"remix": patch -"@remix-run/testing": patch +"remix": minor +"@remix-run/testing": minor --- Remove the `unstable_` prefix from `createRemixStub`. After real-world experience, we're confident in the API and ready to commit to it. From 4f6956fd1672be461a0cae41504fd7f709ecdc56 Mon Sep 17 00:00:00 2001 From: Matt Brophy <matt@brophy.org> Date: Wed, 11 Oct 2023 17:00:17 -0400 Subject: [PATCH 6/8] Fix docs lint error --- docs/utils/create-remix-stub.md | 37 +++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/docs/utils/create-remix-stub.md b/docs/utils/create-remix-stub.md index 6fd6353a790..076213ef44c 100644 --- a/docs/utils/create-remix-stub.md +++ b/docs/utils/create-remix-stub.md @@ -11,12 +11,20 @@ test("renders loader data", async () => { const RemixStub = createRemixStub([ { path: "/", - meta() { ... }, - links() { ... }, + meta() { + /* ... */ + }, + links() { + /* ... */ + }, Component: MyComponent, ErrorBoundary: MyErrorBoundary, - action() {...}, - loader() {...}, + action() { + /* ... */ + }, + loader() { + /* ... */ + }, }, ]); @@ -52,16 +60,23 @@ The `<RemixStub>` component itself takes properties similar to React Router if y ```tsx // Test the app rendered at "/2" with 2 prior history stack entries -render(<RemixStub initialEntries={['/', '/1', '/2']} initialIndex={2}) /> +render( + <RemixStub + initialEntries={["/", "/1", "/2"]} + initialIndex={2} + /> +); // Test the app rendered with initial loader data for the root route. When using // this, it's best to give your routes their own unique IDs in your route definitions -render(<RemixStub hydrationData={{ - loaderData: { - root: { message: 'hello' } - } -}} /> +render( + <RemixStub + hydrationData={{ + loaderData: { root: { message: "hello" } }, + }} + /> +); // Test the app rendered with given future flags enabled -render(<RemixStub future={{ v3_coolFeature: true }} /> +render(<RemixStub future={{ v3_coolFeature: true }} />); ``` From e2a1e7769bf01ca095ba0487954a312d3d368f3b Mon Sep 17 00:00:00 2001 From: Matt Brophy <matt@brophy.org> Date: Thu, 12 Oct 2023 09:27:24 -0400 Subject: [PATCH 7/8] Update docs/utils/create-remix-stub.md Co-authored-by: Mehdi Achour <machour@gmail.com> --- docs/utils/create-remix-stub.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/utils/create-remix-stub.md b/docs/utils/create-remix-stub.md index 076213ef44c..1db4bb619f7 100644 --- a/docs/utils/create-remix-stub.md +++ b/docs/utils/create-remix-stub.md @@ -39,7 +39,7 @@ test("renders loader data", async () => { }); ``` -If your loaders rely on the `getLoadContext` method, you can provide a stuibbed context via the second parameter to `createRemixStub`: +If your loaders rely on the `getLoadContext` method, you can provide a stubbed context via the second parameter to `createRemixStub`: ```tsx const RemixStub = createRemixStub( From e06ca026174b4ccc0168db130c292e981c5e4732 Mon Sep 17 00:00:00 2001 From: Matt Brophy <matt@brophy.org> Date: Thu, 12 Oct 2023 09:27:30 -0400 Subject: [PATCH 8/8] Update packages/remix-testing/__tests__/stub-test.tsx Co-authored-by: Mehdi Achour <machour@gmail.com> --- packages/remix-testing/__tests__/stub-test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/remix-testing/__tests__/stub-test.tsx b/packages/remix-testing/__tests__/stub-test.tsx index 09586e281c0..7ef983a7ab5 100644 --- a/packages/remix-testing/__tests__/stub-test.tsx +++ b/packages/remix-testing/__tests__/stub-test.tsx @@ -149,7 +149,7 @@ test("can pass context values", async () => { let data = useLoaderData() as { context: string }; return ( <div> - <pre data-testid="root">Context: {data.context}</pre>; + <pre data-testid="root">Context: {data.context}</pre> <Outlet /> </div> );