Skip to content

Commit

Permalink
docs(test): add some simple jest testing docs
Browse files Browse the repository at this point in the history
  • Loading branch information
mlaursen committed Nov 11, 2024
1 parent 3fa3869 commit 42b0ef6
Show file tree
Hide file tree
Showing 4 changed files with 422 additions and 14 deletions.
390 changes: 390 additions & 0 deletions apps/docs/src/app/(main)/(markdown)/testing/jest/page.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,390 @@
# Testing with Jest

ReactMD provides built-in support for testing with [jest](https://jestjs.io/) with `@jest/globals`.

# Quickstart

## Installing Jest Dependencies

To get started, install the required `jest` dependencies:

```sh
npm install --save-dev \
jest \
@jest/globals \
@jest/types \
```

It is also recommended to install the following React Testing Library packages to improve the testing experience:

```sh
npm install --save-dev \
@testing-library/dom \
@testing-library/react \
@testing-library/jest-dom \
@testing-library/user-event
```

For more information, see:

- [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/)
- [Jest DOM](https://testing-library.com/docs/ecosystem-jest-dom/)
- [User Event Testing](https://testing-library.com/docs/user-event/v13/)

## Setup Polyfills and Additional Jest Matchers

Once the jest dependencies have been installed, create or modify a
`jest.config.ts` file that includes a
[setupFilesAfterEnv](https://jestjs.io/docs/configuration#setupfilesafterenv-array):

```ts fileName="jest.config.ts"
import { type Config } from "jest";

const config: Config = {
testEnvironment: "jsdom",
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
};

export default config;
```

Next, create or modify the `jest.setup.ts` file to include the test util polyfills and the optional `jest-dom` matchers:

```ts fileName="jest.setup.ts"
import "@react-md/core/test-utils/polyfills";
import "@react-md/core/test-utils/jest-setup";

// Optional: allow data-testid to be a valid DOM property for typescript
import "@react-md/core/test-utils/data-testid";

// Optional: include the jest-dom matchers
import "@testing-library/jest-dom/jest-globals";
```

# Create a custom Test Renderer

My preferred method of making all the global context providers, data stores,
`react-md` configuration, etc available for each test is to create a utility file
that re-exports everything from `@react-md/core/test-utils`,
`@testing-library/react`, and `@testing-library/user-event`. The example below
shows a possible setup.

> See [Custom Renderer](https://testing-library.com/docs/react-testing-library/setup#custom-render) for additional context.
```diff fileName="src/__tests__/MyComponent.tsx"
-import { render, userEvent } from "@testing-library/react";
+import { render, userEvent } from "../test-utils";
```

```tsx fileName="src/rmdConfig.tsx"
import { type ReactMDCoreConfiguration } from "@react-md/core/CoreProviders";
import { configureIcons } from "@react-md/core/icon/iconConfig";

// any icon overrides. Using material icons as an example
configureIcons({
back: <KeyboardArrowLeftIcon />,
clear: <ClearIcon />,
close: <CloseIcon />,
checkbox: <CheckBoxOutlineBlankIcon />,
checkboxChecked: <CheckBoxIcon />,
checkboxIndeterminate: <IndeterminateCheckBoxIcon />,
dropdown: <ArrowDropDownIcon />,
error: <ErrorOutlineIcon />,
expander: <KeyboardArrowDownIcon />,
forward: <KeyboardArrowRightIcon />,
menu: <MenuIcon />,
notification: <NotificationsIcon />,
password: <RemoveRedEyeIcon />,
radio: <RadioButtonUncheckedIcon />,
radioChecked: <RadioButtonCheckedIcon />,
remove: <CancelIcon />,
selected: <CheckIcon />,
sort: <ArrowUpwardIcon />,
upload: <FileUploadIcon />,
});

export const rmdConfig: ReactMDCoreConfiguration = {
// any other global changes
// ssr: true,
// colorSchemeMode: "system",
};
```

```tsx fileName="src/test-utils.tsx"
import { type ReactElement } from "react";
import {
rmdRender,
type ReactMDRenderOptions,
type RenderOptions,
} from "@react-md/test-utils";

import { rmdConfig } from "./rmdConfig.jsx";
import { MyCustomProviders } from "./MyCustomProviders.jsx";

export * from "@react-md/core/test-utils";

export const render = (
ui: ReactElement,
options?: ReactMDRenderOptions
): RenderResult =>
rmdRender(ui, {
...options,
rmdConfig: {
...rmdConfig,
...options?.rmdConfig,
},
wrapper: ({ children }) => (
<MyCustomProviders>{children}</MyCustomProviders>
),
});
```

# What's Included

## Polyfills

By importing the `@react-md/core/test-utils/polyfills`, all the following
polyfills will be applied. You can also manually import the specific polyfills
instead.

### matchMedia

```ts
import "@react-md/core/test-utils/polyfills/matchMedia";
```

This updates `window.matchMedia` to default to desktop when `typeof window.matchMedia === "undefined"`.

### ResizeObserver

```ts
import "@react-md/core/test-utils/polyfills/ResizeObserver";
```

This sets the `window.ResizeObserver` to the `ResizeObserverMock`
when it is `undefined`. See the
[ResizeObserverMock](#resize-observer-mock) for more details.

### IntersectionObserver

```ts
import "@react-md/core/test-utils/polyfills/IntersectionObserver";
```

This sets the `window.IntersectionObserver` to the `IntersectionObserverMock`
when it is `undefined`. See the
[IntersectionObserverMock](#intersection-observer-mock) for more details.

### TextEncoder

```ts
import "@react-md/core/test-utils/polyfills/TextEncoder";
```

This sets the `global.TextEncoder` to `node:util`
[TextEncoder](https://nodejs.org/docs/latest-v22.x/api/util.html#class-utiltextencoder)
when it is `undefined`.

### TextDecoder

```ts
import "@react-md/core/test-utils/polyfills/TextDecoder";
```

This sets the `global.TextDecoder` to `node:util`
[TextDecoder](https://nodejs.org/docs/latest-v22.x/api/util.html#class-utiltextdecoder)
when it is `undefined`.

### offsetParent

```ts
import "@react-md/core/test-utils/polyfills/offsetParent";
```

This polyfill was added for the tree keyboard movement behavior since `offsetParent` might return the incorrect value in jsdom.

### scrollIntoView

```ts
import "@react-md/core/test-utils/polyfills/scrollIntoView";
```

This polyfill adds no-op behavior to the `HTMLElement.prototype.scrollIntoView` if it is `undefined`.

## jest-setup

The `@react-md/core/test-utils/jest-setup` import just adds the following behavior for all tests:

```ts
import { beforeEach } from "@jest/globals";
import { INTERACTION_CONFIG } from "@react-md/core/interaction/config";
import { TRANSITION_CONFIG } from "@react-md/core/transition/config";

beforeEach(() => {
// set the mode to `none` in tests since ripples require
// `getBoundingClientRect()` to create correct CSS. You'll either see warnings
// in the console around invalid css values or `NaN`.
INTERACTION_CONFIG.mode = "none";

// disable transitions in tests since it just makes it more difficult
TRANSITION_CONFIG.disabled = true;
});
```

## Match Media Helpers

Since it can be useful to test different viewport sizes in `jest`, a few `window.matchMedia` helpers have been included.

### spyOnMatchMedia

The `spyOnMatchMedia` can be used to change the viewport size for tests and
expect different results. The default behavior is to match the desktop min
width.

```tsx
import { matchPhone, render, spyOnMatchMedia } from "@react-md/core/test-utils";

const matchMedia = spyOnMatchMedia();
render(<Test />);

// expect desktop results

matchMedia.changeViewport(matchPhone);
// expect phone results
```

A default matcher can be provided as well:

```tsx
import { matchPhone, render, spyOnMatchMedia } from "@react-md/core/test-utils";

const matchMedia = spyOnMatchMedia(matchPhone);
render(<Test />);

// expect phone results
```

#### Matchers

The following matchers have been provided out of the box which use the default media query breakpoints:

- `matchPhone`
- `matchTablet`
- `matchDesktop`
- `matchLargeDesktop`
- `matchAnyDesktop`

You can also create a custom matcher:

```ts
const customMatcher: MatchMediaMatcher = (query) => query.includes("20rem");

function Example() {
// the `customMatcher` would be called with `"screen and (min-width: 20rem)"`
const matches = useMediaQuery("screen and (min-width: 20rem)");

// implementation
}
```

## Resize Observer Mock

The `ResizeObserverMock` can be used to write tests that include the `useResizeObserver` hook. Here is a small example:

```tsx fileName="src/ExampleComponent.tsx"
import { useResizeObserver } from "@react-md/core/useResizeObserver";
import { useCallback, useState } from "react";

export function ExampleComponent() {
const [size, setSize] = useState({ height: 0, width: 0 });
const ref = useResizeObserver({
onUpdate: useCallback((entry) => {
setSize({
height: entry.contentRect.height,
width: entry.contentRect.width,
});
}, []),
});

return (
<>
<div data-testid="size">{JSON.stringify(size)}</div>
<div data-testid="resize-target" ref={ref} />
</>
);
}
```

```tsx fileName="src/__tests__/ExampleComponent.tsx"
import {
cleanupResizeObserverAfterEach,
render,
screen,
setupResizeObserverMock,
} from "@react-md/core/test-utils";
import { ExampleComponent } from "../ExampleComponent.jsx";

cleanupResizeObserverAfterEach();

describe("ExampleComponent", () => {
it("should do stuff", () => {
const observer = setupResizeObserverMock();
render(<ExampleComponent />);

const size = screen.getByTestId("size");
const resizeTarget = screen.getByTestId("resize-target");

// jsdom sets all element sizes to 0 by default
expect(size).toHaveTextContent(JSON.stringify({ height: 0, width: 0 }));

// you can trigger with a custom change
act(() => {
observer.resizeElement(resizeTarget, { height: 100, width: 100 });
});
expect(size).toHaveTextContent(JSON.stringify({ height: 100, width: 100 }));

// or you can mock the `getBoundingClientRect` result
jest.spyOn(resizeTarget, "getBoundingClientRect").mockReturnValue({
...document.body.getBoundingClientRect(),
height: 200,
width: 200,
});

act(() => {
observer.resizeElement(resizeTarget);
});
expect(size).toHaveTextContent(JSON.stringify({ height: 200, width: 200 }));
});
});
```

## Timer Helpers

### testImmediateRaf

Use this util to make `window.requestAnimationFrame` happen immediately.

```ts
import { testImmediateRaf } from "@react-md/core/test-utils";

afterEach(() => {
jest.restoreAllMocks();
});

describe("some test suite", () => {
it("should test something with requestAnimationFrame", () => {
const raf = testImmediateRaf();

// do some testing with requestAnimationFrame

// reset to original at the end of the test if not using `jest.restoreAllMocks()`
raf.mockRestore();
});
});
```

## Intersection Observer Mock

This just adds a simple mock that ensures your tests won't crash if the
`useIntersectionObserver` hook is used. This might be updated in the future to
help verify the intersection observer behavior.
Loading

0 comments on commit 42b0ef6

Please sign in to comment.