-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: stablize createRemixStub (#7647)
Co-authored-by: Matt Brophy <[email protected]> Co-authored-by: Mehdi Achour <[email protected]>
- Loading branch information
1 parent
a71d5c9
commit 2a3ad8c
Showing
7 changed files
with
285 additions
and
63 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 `<RemixStub remixConfigFuture>` prop has been renamed to `<RemixStub future>` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(<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 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 `<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 }} />); | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <meta charset='utf-8' />", () => { | ||
let RemixStub = unstable_createRemixStub([ | ||
let RemixStub = createRemixStub([ | ||
{ | ||
path: "/", | ||
meta: () => [{ charSet: "utf-8" }], | ||
|
@@ -161,7 +161,7 @@ describe("meta", () => { | |
}); | ||
|
||
it("{ title } adds a <title />", () => { | ||
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: ["[email protected]", "[email protected]"], | ||
}; | ||
|
||
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() { | ||
|
Oops, something went wrong.