Skip to content

Commit c5bb0a5

Browse files
committed
add Vitest Browser Mode guide
1 parent 1e21311 commit c5bb0a5

File tree

8 files changed

+3075
-600
lines changed

8 files changed

+3075
-600
lines changed

docs/package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
{
2-
"name": "docs",
32
"devDependencies": {
43
"@reduxjs/toolkit": "^2.0.1",
54
"@testing-library/react": "^14.1.2",
5+
"@vitest/browser-playwright": "^4.0.5",
66
"msw": "^2.0.0",
77
"react": "^18.2.0",
8-
"react-redux": "^9.1.0"
9-
}
8+
"react-redux": "^9.1.0",
9+
"vitest": "^4.0.5",
10+
"vitest-browser-react": "^2.0.2"
11+
},
12+
"name": "docs"
1013
}

docs/usage/WritingTests.mdx

Lines changed: 313 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,24 @@ See these resources for typical test runner configuration instructions:
5050

5151
- **Vitest**
5252
- [Vitest: Getting Started](https://vitest.dev/guide/)
53+
- [Vitest: Browser Mode](https://vitest.dev/guide/browser)
5354
- [Vitest: Configuration - Test Environment](https://vitest.dev/config/#environment)
5455
- **Jest**:
5556
- [Jest: Getting Started](https://jestjs.io/docs/getting-started)
5657
- [Jest: Configuration - Test Environment](https://jestjs.io/docs/configuration#testenvironment-string)
5758

5859
### UI and Network Testing Tools
5960

60-
**The Redux team recommends using [React Testing Library (RTL)](https://testing-library.com/docs/react-testing-library/intro) to test React components that connect to Redux without a browser**. React Testing Library is a simple and complete React DOM testing utility that encourages good testing practices. It uses ReactDOM's `render` function and `act` from react-dom/tests-utils. (The Testing Library family of tools also includes [adapters for many other popular frameworks as well](https://testing-library.com/docs/dom-testing-library/intro).)
61+
**The Redux team recommends using either [Vitest Browser Mode](https://vitest.dev/guide/browser/) or [React Testing Library (RTL)](https://testing-library.com/docs/react-testing-library/intro) to test React components that connect to Redux**.
62+
63+
React Testing Library is a simple and complete React DOM testing utility that encourages good testing practices. It uses ReactDOM's `render` function and `act` from react-dom/tests-utils. (The Testing Library family of tools also includes [adapters for many other popular frameworks as well](https://testing-library.com/docs/dom-testing-library/intro).)
64+
65+
Vitest Browser Mode runs integration tests in a real browser, removing the need for a "mock" DOM environment (and allowing for visual feedback and regression testing). When using React, you'll also need `vitest-browser-react`, which includes a `render` utility similar to RTL's.
6166

6267
We also **recommend using [Mock Service Worker (MSW)](https://mswjs.io/) to mock network requests**, as this means your application logic does not need to be changed or mocked when writing tests.
6368

69+
- **Vitest Browser Mode**
70+
- [Vitest: Browser Mode Setup](https://vitest.dev/guide/browser)
6471
- **DOM/React Testing Library**
6572
- [DOM Testing Library: Setup](https://testing-library.com/docs/dom-testing-library/setup)
6673
- [React Testing Library: Setup](https://testing-library.com/docs/react-testing-library/setup)
@@ -348,8 +355,9 @@ import type { AppStore, RootState, PreloadedState } from '../app/store'
348355
import { setupStore } from '../app/store'
349356

350357
// This type interface extends the default options for render from RTL, as well
351-
// as allows the user to specify other things such as initialState, store.
352-
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
358+
// as allows the user to specify other things such as preloadedState, store.
359+
interface ExtendedRenderOptions
360+
extends Omit<RenderOptions, 'queries' | 'wrapper'> {
353361
preloadedState?: PreloadedState
354362
store?: AppStore
355363
}
@@ -431,7 +439,8 @@ import { Provider } from 'react-redux'
431439
import { setupStore } from '../app/store'
432440
import type { AppStore, RootState, PreloadedState } from '../app/store'
433441

434-
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
442+
interface ExtendedRenderOptions
443+
extends Omit<RenderOptions, 'queries' | 'wrapper'> {
435444
preloadedState?: PreloadedState
436445
store?: AppStore
437446
}
@@ -563,6 +572,306 @@ test('Sets up initial state state with actions', () => {
563572

564573
You can also extract `store` from the object returned by the custom render function, and dispatch more actions later as part of the test.
565574

575+
### Vitest Browser Mode
576+
577+
#### Setting Up a Reusable Test Render Function
578+
579+
Similar to RTL, Vitest Browser Mode provides a `render` function that can be used to render a component in a real browser. However, since we're testing a React-Redux app, we need to ensure that the `<Provider>` is included in the rendered tree.
580+
581+
We can create a custom render function that wraps the component in a `<Provider>` and sets up a Redux store, similar to the RTL custom render function shown above.
582+
583+
```tsx title="utils/test-utils.tsx"
584+
// file: features/users/userSlice.ts noEmit
585+
import { createSlice } from '@reduxjs/toolkit'
586+
const userSlice = createSlice({
587+
name: 'user',
588+
initialState: {
589+
name: 'No user',
590+
status: 'idle'
591+
},
592+
reducers: {}
593+
})
594+
export default userSlice.reducer
595+
// file: app/store.ts noEmit
596+
import { combineReducers, configureStore } from '@reduxjs/toolkit'
597+
import userReducer from '../features/users/userSlice'
598+
const rootReducer = combineReducers({
599+
user: userReducer
600+
})
601+
export function setupStore(preloadedState?: PreloadedState) {
602+
return configureStore({
603+
reducer: rootReducer,
604+
preloadedState
605+
})
606+
}
607+
export type PreloadedState = Parameters<typeof rootReducer>[0]
608+
export type RootState = ReturnType<typeof rootReducer>
609+
export type AppStore = ReturnType<typeof setupStore>
610+
// file: utils/test-utils.tsx
611+
import React, { PropsWithChildren } from 'react'
612+
import { render } from 'vitest-browser-react'
613+
import type { RenderOptions } from 'vitest-browser-react'
614+
import { Provider } from 'react-redux'
615+
616+
import type { AppStore, RootState, PreloadedState } from '../app/store'
617+
import { setupStore } from '../app/store'
618+
619+
// This type interface extends the default options for render from vitest-browser-react, as well
620+
// as allows the user to specify other things such as preloadedState, store.
621+
interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
622+
preloadedState?: PreloadedState
623+
store?: AppStore
624+
}
625+
626+
export function renderWithProviders(
627+
ui: React.ReactElement,
628+
extendedRenderOptions: ExtendedRenderOptions = {}
629+
) {
630+
const {
631+
preloadedState = {},
632+
// Automatically create a store instance if no store was passed in
633+
store = setupStore(preloadedState),
634+
...renderOptions
635+
} = extendedRenderOptions
636+
637+
const Wrapper = ({ children }: PropsWithChildren) => (
638+
<Provider store={store}>{children}</Provider>
639+
)
640+
641+
// Return an object with the store, and the result of rendering
642+
return {
643+
store,
644+
...render(ui, { wrapper: Wrapper, ...renderOptions })
645+
}
646+
}
647+
```
648+
649+
For convenience, we can also attach this to `page` in our setup file:
650+
651+
```ts title="setup.ts"
652+
// file: vitest.config.ts noEmit
653+
import {} from "@vitest/browser-playwright"
654+
// file: features/users/userSlice.ts noEmit
655+
import { createSlice } from '@reduxjs/toolkit'
656+
const userSlice = createSlice({
657+
name: 'user',
658+
initialState: {
659+
name: 'No user',
660+
status: 'idle'
661+
},
662+
reducers: {}
663+
})
664+
export default userSlice.reducer
665+
// file: app/store.ts noEmit
666+
import { combineReducers, configureStore } from '@reduxjs/toolkit'
667+
import userReducer from '../features/users/userSlice'
668+
const rootReducer = combineReducers({
669+
user: userReducer
670+
})
671+
export function setupStore(preloadedState?: PreloadedState) {
672+
return configureStore({
673+
reducer: rootReducer,
674+
preloadedState
675+
})
676+
}
677+
export type PreloadedState = Parameters<typeof rootReducer>[0]
678+
export type RootState = ReturnType<typeof rootReducer>
679+
export type AppStore = ReturnType<typeof setupStore>
680+
// file: utils/test-utils.tsx noEmit
681+
import React, { PropsWithChildren } from 'react'
682+
import { render } from 'vitest-browser-react'
683+
import type { RenderOptions } from 'vitest-browser-react'
684+
import { Provider } from 'react-redux'
685+
686+
import type { AppStore, RootState, PreloadedState } from '../app/store'
687+
import { setupStore } from '../app/store'
688+
689+
// This type interface extends the default options for render from vitest-browser-react, as well
690+
// as allows the user to specify other things such as preloadedState, store.
691+
interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
692+
preloadedState?: PreloadedState
693+
store?: AppStore
694+
}
695+
696+
export async function renderWithProviders(
697+
ui: React.ReactElement,
698+
extendedRenderOptions: ExtendedRenderOptions = {}
699+
) {
700+
const {
701+
preloadedState = {},
702+
// Automatically create a store instance if no store was passed in
703+
store = setupStore(preloadedState),
704+
...renderOptions
705+
} = extendedRenderOptions
706+
707+
const Wrapper = ({ children }: PropsWithChildren) => (
708+
<Provider store={store}>{children}</Provider>
709+
)
710+
711+
// Return an object with the store, and the result of rendering
712+
return {
713+
store,
714+
...(await render(ui, { wrapper: Wrapper, ...renderOptions }))
715+
}
716+
}
717+
// file: setup.ts
718+
import { renderWithProviders } from './utils/test-utils'
719+
import { page } from 'vitest/browser'
720+
721+
page.extend({ renderWithProviders })
722+
723+
declare module 'vitest/browser' {
724+
interface BrowserPage {
725+
renderWithProviders: typeof renderWithProviders
726+
}
727+
}
728+
```
729+
730+
Then we can use it in our tests, similarly to RTL:
731+
732+
```tsx title="features/users/tests/UserDisplay.test.tsx"
733+
// file: vitest.config.ts noEmit
734+
import {} from '@vitest/browser-playwright'
735+
// file: setup.ts noEmit
736+
import { renderWithProviders } from './utils/test-utils'
737+
import { page } from 'vitest/browser'
738+
739+
page.extend({ renderWithProviders })
740+
741+
declare module 'vitest/browser' {
742+
interface BrowserPage {
743+
renderWithProviders: typeof renderWithProviders
744+
}
745+
}
746+
747+
// file: features/users/userSlice.ts noEmit
748+
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
749+
import type { RootState } from '../../app/store'
750+
export const fetchUser = createAsyncThunk('user/fetchUser', async () => {})
751+
const userSlice = createSlice({
752+
name: 'user',
753+
initialState: {
754+
name: 'No user',
755+
status: 'idle'
756+
},
757+
reducers: {}
758+
})
759+
export const selectUserName = (state: RootState) => state.user.name
760+
export const selectUserFetchStatus = (state: RootState) => state.user.status
761+
export default userSlice.reducer
762+
// file: app/store.ts noEmit
763+
import { combineReducers, configureStore } from '@reduxjs/toolkit'
764+
765+
import userReducer from '../features/users/userSlice'
766+
767+
const rootReducer = combineReducers({
768+
user: userReducer
769+
})
770+
771+
export const setupStore = (preloadedState?: PreloadedState) => {
772+
return configureStore({
773+
reducer: rootReducer,
774+
preloadedState
775+
})
776+
}
777+
778+
export type PreloadedState = Parameters<typeof rootReducer>[0]
779+
export type RootState = ReturnType<typeof rootReducer>
780+
export type AppStore = ReturnType<typeof setupStore>
781+
export type AppDispatch = AppStore['dispatch']
782+
// file: utils/test-utils.tsx noEmit
783+
import React, { PropsWithChildren } from 'react'
784+
import { render } from 'vitest-browser-react'
785+
import type { RenderOptions } from 'vitest-browser-react'
786+
import { Provider } from 'react-redux'
787+
788+
import type { AppStore, RootState, PreloadedState } from '../app/store'
789+
import { setupStore } from '../app/store'
790+
791+
// This type interface extends the default options for render from vitest-browser-react, as well
792+
// as allows the user to specify other things such as preloadedState, store.
793+
interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
794+
preloadedState?: PreloadedState
795+
store?: AppStore
796+
}
797+
798+
export async function renderWithProviders(
799+
ui: React.ReactElement,
800+
extendedRenderOptions: ExtendedRenderOptions = {}
801+
) {
802+
const {
803+
preloadedState = {},
804+
// Automatically create a store instance if no store was passed in
805+
store = setupStore(preloadedState),
806+
...renderOptions
807+
} = extendedRenderOptions
808+
809+
const Wrapper = ({ children }: PropsWithChildren) => (
810+
<Provider store={store}>{children}</Provider>
811+
)
812+
813+
// Return an object with the store, and the result of rendering
814+
return {
815+
store,
816+
...(await render(ui, { wrapper: Wrapper, ...renderOptions }))
817+
}
818+
}
819+
// file: app/hooks.tsx noEmit
820+
import { useDispatch, useSelector } from 'react-redux'
821+
import type { AppDispatch, RootState } from './store'
822+
// Use throughout your app instead of plain `useDispatch` and `useSelector`
823+
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
824+
export const useAppSelector = useSelector.withTypes<RootState>()
825+
// file: features/users/UserDisplay.tsx noEmit
826+
import React from 'react'
827+
import { useAppDispatch, useAppSelector } from '../../app/hooks'
828+
import { fetchUser, selectUserName, selectUserFetchStatus } from './userSlice'
829+
830+
export default function UserDisplay() {
831+
const dispatch = useAppDispatch()
832+
const userName = useAppSelector(selectUserName)
833+
const userFetchStatus = useAppSelector(selectUserFetchStatus)
834+
835+
return (
836+
<div>
837+
{/* Display the current user name */}
838+
<div>{userName}</div>
839+
{/* On button click, dispatch a thunk action to fetch a user */}
840+
<button onClick={() => dispatch(fetchUser())}>Fetch user</button>
841+
{/* At any point if we're fetching a user, display that on the UI */}
842+
{userFetchStatus === 'loading' && <div>Fetching user...</div>}
843+
</div>
844+
)
845+
}
846+
// file: features/users/tests/UserDisplay.test.tsx
847+
import React from 'react'
848+
import { test, expect } from 'vitest'
849+
import { page } from 'vitest/browser'
850+
import UserDisplay from '../UserDisplay'
851+
852+
test('fetches & receives a user after clicking the fetch user button', async () => {
853+
const { store, ...screen } = await page.renderWithProviders(<UserDisplay />)
854+
855+
const noUserText = screen.getByText(/no user/i)
856+
const fetchingUserText = screen.getByText(/Fetching user\.\.\./i)
857+
const userNameText = screen.getByText(/John Smith/i)
858+
859+
// should show no user initially, and not be fetching a user
860+
await expect.element(noUserText).toBeInTheDocument()
861+
await expect.element(fetchingUserText).not.toBeInTheDocument()
862+
863+
// after clicking the 'Fetch user' button, it should now show that it is fetching the user
864+
await screen.getByRole('button', { name: /fetch user/i }).click()
865+
await expect.element(noUserText).not.toBeInTheDocument()
866+
await expect.element(fetchingUserText).toBeInTheDocument()
867+
868+
// after some time, the user should be received
869+
await expect.element(userNameText).toBeInTheDocument()
870+
await expect.element(noUserText).not.toBeInTheDocument()
871+
await expect.element(fetchingUserText).not.toBeInTheDocument()
872+
})
873+
```
874+
566875
## Unit Testing Individual Functions
567876

568877
While we recommend using integration tests by default, since they exercise all the Redux logic working together, you may sometimes want to write unit tests for individual functions as well.

0 commit comments

Comments
 (0)