diff --git a/.changeset/stabilize-create-remix-stub.md b/.changeset/stabilize-create-remix-stub.md new file mode 100644 index 00000000000..aa9f20c1454 --- /dev/null +++ b/.changeset/stabilize-create-remix-stub.md @@ -0,0 +1,7 @@ +--- +"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. +* Note: This involves 1 small breaking change. The `` prop has been renamed to `` 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 `` component and assert against it: + +```tsx +render(); +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

Message: {data.message}

; + } + + const RemixStub = createRemixStub([ + { + path: "/", + Component: MyComponent, + loader() { + return json({ message: "hello" }); + }, + }, + ]); + + render(); + + 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..1db4bb619f7 --- /dev/null +++ b/docs/utils/create-remix-stub.md @@ -0,0 +1,82 @@ +--- +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(); + + // 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 stubbed context via the second parameter to `createRemixStub`: + +```tsx +const RemixStub = createRemixStub( + [ + { + path: "/", + Component: MyComponent, + loader({ context }) { + return json({ message: context.key }); + }, + }, + ], + { key: "value" } +); +``` + +The `` 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( + +); + +// 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( + +); + +// Test the app rendered with given future flags enabled +render(); +``` 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..7ef983a7ab5 100644 --- a/packages/remix-testing/__tests__/stub-test.tsx +++ b/packages/remix-testing/__tests__/stub-test.tsx @@ -1,12 +1,20 @@ import * as React from "react"; -import { render, screen } from "@testing-library/react"; -import { unstable_createRemixStub } from "@remix-run/testing"; -import { Outlet, useLoaderData, useMatches } from "@remix-run/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import user from "@testing-library/user-event"; +import { createRemixStub } from "@remix-run/testing"; +import { + Form, + Outlet, + useActionData, + useFetcher, + 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 +27,7 @@ test("renders a route", () => { }); test("renders a nested route", () => { - let RemixStub = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { Component() { return ( @@ -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 = unstable_createRemixStub([ + 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", () => { @@ -73,7 +132,7 @@ test("can pass a predefined loader", () => { return json({ hi: "there" }); } - unstable_createRemixStub([ + createRemixStub([ { path: "/example", 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 = unstable_createRemixStub( + 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,18 +185,7 @@ 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 = unstable_createRemixStub([ + let RemixStub = createRemixStub([ { Component() { return ( @@ -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: "" }, 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";