如何使用新的反应路由器挂钩测试组件?

到目前为止,在单元测试中,反应路由器匹配参数被作为组件的道具检索。 因此,测试一个组件,考虑一些特定的匹配,具有特定的 URL 参数,是很容易的: 我们只需要精确的路由器匹配的道具,因为我们想在测试中呈现的组件(我正在使用酶库为此目的)。

我真的很喜欢检索路由的新钩子,但是我没有找到关于如何在单元测试中模拟一个反应路由器匹配的例子,使用新的反应路由器钩子?

71438 次浏览

Edit: The proper way of doing this the way described in Catalina Astengo's answer as it uses the real router functionality with just the history/routing state mocked rather than mocking the entire hook.

The way I ended up solving it was by mocking the hooks in my tests using jest.mock:

// TeamPage.test.js
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
useParams: () => ({
companyId: 'company-id1',
teamId: 'team-id1',
}),
useRouteMatch: () => ({ url: '/company/company-id1/team/team-id1' }),
}));

I use jest.requireActual to use the real parts of react-router-dom for everything except the hooks I'm interested in mocking.

I looked at the tests for hooks in the react-router repo and it looks like you have to wrap your component inside a MemoryRouter and Route. I ended up doing something like this to make my tests work:

import {Route, MemoryRouter} from 'react-router-dom';


...


const renderWithRouter = ({children}) => (
render(
<MemoryRouter initialEntries={['blogs/1']}>
<Route path='blogs/:blogId'>
{children}
</Route>
</MemoryRouter>
)
)

Hope that helps!

I am trying to get if the push function in useHistory is called by doing that but I can't get the mocked function calls...

const mockHistoryPush = jest.fn();


jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: () => ({
push: mockHistoryPush,
}),
}));


fireEvent.click(getByRole('button'));
expect(mockHistoryPush).toHaveBeenCalledWith('/help');

It says that mockHistoryPush is not called when the button has onClick={() => history.push('/help')}

In your component use hooks as below

import {useLocation} from 'react-router';


const location = useLocation()

In your test spy on reactRouter Object as below

import routeData from 'react-router';


const mockLocation = {
pathname: '/welcome',
hash: '',
search: '',
state: ''
}
beforeEach(() => {
jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation)
});

If using the enzyme library, I found a much less verbose way to solve the problem (using this section from the react-router-dom docs):

import React from 'react'
import { shallow } from 'enzyme'
import { MemoryRouter } from 'react-router-dom'
import Navbar from './Navbar'


it('renders Navbar component', () => {
expect(
shallow(
<MemoryRouter>
<Navbar />
</MemoryRouter>
)
).toMatchSnapshot()
})

If you're using react-testing-library for testing, you can get this mock to work like so.

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({ state: { email: 'school@edu.ng' } }),
}));


export const withReduxNRouter = (
ui,
{ store = createStore(rootReducer, {}) } = {},
{
route = '/',
history = createMemoryHistory({ initialEntries: [ route ] }),
} = {}
) => {
return {
...render(
<Provider store={store}>
<Router history={history}>{ui}</Router>
</Provider>
),
history,
store,
};
};

You should have mocked react-router-dom before it has been used to render your component. I'm exploring ways to make this reusable

My use case was unit testing a custom hook using using useLocation(). I had to override the inner properties of useLocation which was read-only.


\\ foo.ts


export const useFoo = () => {


const {pathname} = useLocation();




\\ other logic


return ({
\\ returns whatever thing here
});
}


/*----------------------------------*/


\\ foo.test.ts


\\ other imports here


import * as ReactRouter from 'react-router';




Object.defineProperty(ReactRouter, 'useLocation', {
value: jest.fn(),
configurable: true,
writable: true,
});


describe("useFoo", () => {




it(' should do stgh that involves calling useLocation', () => {


const mockLocation = {
pathname: '/path',
state: {},
key: '',
search: '',
hash: ''
};




const useLocationSpy =  jest.spyOn(ReactRouter, 'useLocation').mockReturnValue(mockLocation)






const {result} = renderHook(() => useFoo());
         

expect(useLocationSpy).toHaveBeenCalled();




});
});


A slight variation of the above solutions which includes several params and query strings for a more complex scenario. This is easy to abstract into a utility function similar to a few above which can be reused by other tests.

short version

      <MemoryRouter
initialEntries={[
'/operations/integrations/trello?business=freelance&businessId=1&pageId=1&pageName=Trello',
]}
>
<Route path="/operations/:operation/:location">
<OperationPage />
</Route>
</MemoryRouter>

Longer version:

The example snippets below include a full example of the test file, component and logs to help leave little room for interpretation.

includes:

  • react 16
  • redux 7
  • react-router-dom 5
  • typescript
  • thunk
  • sagas
  • @testing-library/react 11

operations.spec.tsx

import React from 'react'
import { MemoryRouter, Route } from 'react-router-dom'
import { render, screen } from '@testing-library/react'
import { Provider } from 'react-redux'
import { createStore, applyMiddleware, compose } from 'redux'
import createDebounce from 'redux-debounced'
import thunk from 'redux-thunk'
import createSagaMiddleware from 'redux-saga'
import rootReducer from 'redux/reducers/rootReducer'
import OperationPage from '../operation'
import { initialState } from '../mock'
import '@testing-library/jest-dom' // can be moved to a single setup file


const sagaMiddleware = createSagaMiddleware()
const middlewares = [thunk, sagaMiddleware, createDebounce()]
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose


const store = createStore(
rootReducer,
// any type only until all reducers are given a type
initialState as any,
composeEnhancers(applyMiddleware(...middlewares))
)


const Wrapper: React.FC = ({ children }) => <Provider store={store}>{children}</Provider>


describe('Operation Page - Route', () => {
it('should load', async () => {


const Element = () => (
<MemoryRouter
initialEntries={[
'/operations/integrations/trello?business=freelance&businessId=1&pageId=1&pageName=Trello',
]}
>
<Route path="/operations/:operation/:location">
<OperationPage />
</Route>
</MemoryRouter>
)
render(<Element />, { wrapper: Wrapper })
// logs out the DOM for further testing
screen.debug()
})
})


logs and the component via operations.tsx. Got lazy including the all types (via typescript) for this component but outside of scope :)

import React from 'react'
import { useParams, useLocation } from 'react-router-dom'
import { connect } from 'react-redux'
import queryString from 'query-string'


const OperationPage = (): JSX.Element => {
const { search } = useLocation()
const queryStringsObject = queryString.parse(search)
const { operation, location } = useParams<{ operation: string; location: string }>()


console.log(
'>>>>>queryStringsObject',
queryStringsObject,
'\n search:',
search,
'\n operation:',
operation,
'\n location:',
location
)
return <div>component</div>
}


const mapStateToProps = (state) => {
return {
test: state.test,
}
}


export default connect(mapStateToProps, {})(OperationPage)


terminal where the tests are running

>>>>>queryStringsObject [Object: null prototype] {
business: 'freelance',
businessId: '1',
pageId: '1',
pageName: 'Trello'
}
search: ?business=freelance&businessId=1&pageId=1&pageName=Trello
operation: integrations
location: trello




PASS  src/__tests__/operations.spec.tsx
Operation Page - Route
✓ should load (48 ms)


Test Suites: 1 passed, 1 total
Tests:       0 skipped, 1 passed, 1 total
Snapshots:   0 total
Time:        2.365 s
Ran all test suites related to changed files.

Mock useSearchParams

const searchParams = { "name": "test"};
jest.mock('react-router-dom', () => ({
...(jest.requireActual('react-router-dom') as object),
useSearchParams: () => [searchParams]
}));