├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── BUG_REPORT.md │ └── FEATURE_REQUEST.md ├── docs │ └── images │ │ └── logo.png └── workflows │ ├── main.yml │ └── size.yml ├── .gitignore ├── LICENSE ├── README.md ├── extend-expect.ts ├── jest.config.js ├── package.json ├── setupTests.ts ├── src ├── @types │ └── jest.d.ts ├── __tests__ │ ├── appExample │ │ └── index.tsx │ ├── hocs │ │ └── renderInRouter.test.tsx │ └── matchers │ │ └── toHaveQueryParam.test.ts ├── hocs │ └── renderInRouter.tsx ├── index.ts ├── matchers │ ├── index.ts │ ├── message.ts │ └── toHaveQueryParam │ │ ├── helpers.ts │ │ └── index.ts └── utils │ └── serialize.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | # Lint all files except .eslintrc.js 2 | !.eslintrc.js 3 | 4 | # Dependencies 5 | node_modules/ 6 | 7 | # Build Output 8 | dist/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'plugin:react/recommended', 4 | 'standard', 5 | 'standard-react', 6 | 'standard-jsx', 7 | 'plugin:@typescript-eslint/recommended', 8 | 'plugin:import/errors', 9 | 'plugin:import/warnings', 10 | 'plugin:import/typescript' 11 | ], 12 | plugins: [ 13 | 'import', 'jest-dom' 14 | ], 15 | rules: { 16 | 'react/react-in-jsx-scope': 'off', 17 | '@typescript-eslint/explicit-module-boundary-types': 'off', 18 | '@typescript-eslint/explicit-function-return-type': 'off', 19 | 'react/prop-types': 'off', 20 | '@typescript-eslint/no-var-requires': 'off', 21 | 'no-console': ['error', { allow: ['warn', 'error'] }], 22 | 'import/order': ['error', { 23 | groups: ['builtin', 'external', 'internal'], 24 | 'newlines-between': 'always-and-inside-groups' 25 | }], 26 | 'no-use-before-define': 'off', 27 | '@typescript-eslint/no-use-before-define': 'warn' 28 | }, 29 | globals: { 30 | React: 'writable' 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🐞 Bug Report" 3 | about: Create a report to help contributors to solve a possible bug 4 | title: '' 5 | labels: 'type:Bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Issue Summary 11 | 12 | A summary of the issue. 13 | 14 | ### Steps to Reproduce 15 | 16 | Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead? 17 | 18 | ### Technical details 19 | 20 | 23 | 24 | * `Node` version 25 | * `React` version 26 | * `react-dom` version 27 | * `@testing-library/react` version 28 | * `react-router-dom` version -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature Request" 3 | about: Suggest an idea for react-router-testing-utils roadmap 4 | title: '' 5 | labels: Feature Request 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Is your proposal related to a problem? 11 | 12 | 16 | 17 | (Write your answer here.) 18 | 19 | ### Describe the solution you'd like 20 | 21 | 24 | 25 | (Describe your proposed solution here.) 26 | 27 | ### Describe alternatives you've considered 28 | 29 | 32 | 33 | (Write your answer here.) 34 | 35 | ### Additional context 36 | 37 | 41 | 42 | (Write your answer here.) 43 | -------------------------------------------------------------------------------- /.github/docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LauraBeatris/react-router-testing-utils/480777ebcccd5bf7b9b6847edc34391c74c9baca/.github/docs/images/logo.png -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['10.x', '12.x', '14.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Laura Beatris 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

react-router-testing-utils

3 | 4 | 5 | logo 11 | 12 | 13 |

A collection of utilities to test React Router with React Testing Library

14 | 15 |
16 | 17 | --- 18 | 19 | 20 | [![Build Status][build-badge]][build] 21 | [![version][version-badge]][package] 22 | [![MIT License][license-badge]][license] 23 | [![All Contributors](https://img.shields.io/badge/all_contributors-28-orange.svg?style=flat-square)](#contributors-) 24 | 25 | [![Watch on GitHub][github-watch-badge]][github-watch] 26 | [![Star on GitHub][github-star-badge]][github-star] 27 | [![Tweet][twitter-badge]][twitter] 28 | 29 | 30 | 31 | [build-badge]: https://img.shields.io/github/workflow/status/LauraBeatris/react-router-testing-utils/CI?style=flat-square&logo=github 32 | [build]: https://github.com/LauraBeatris/react-router-testing-utils/actions?query=workflow%3ACI 33 | [version-badge]: 34 | https://img.shields.io/npm/v/react-router-testing-utils.svg?style=flat-square 35 | [package]: https://www.npmjs.com/package/react-router-testing-utils 36 | [license-badge]: 37 | https://img.shields.io/npm/l/react-router-testing-utils.svg?style=flat-square 38 | [license]: https://github.com/LauraBeatris/react-router-testing-utils/blob/main/LICENSE 39 | [github-watch-badge]: 40 | https://img.shields.io/github/watchers/LauraBeatris/react-router-testing-utils.svg?style=social 41 | [github-watch]: https://github.com/LauraBeatris/react-router-testing-utils/watchers 42 | [github-star-badge]: 43 | https://img.shields.io/github/stars/LauraBeatris/react-router-testing-utils.svg?style=social 44 | [github-star]: https://github.com/LauraBeatris/react-router-testing-utils/stargazers 45 | [twitter]: 46 | https://twitter.com/intent/tweet?text=Check%20out%20react-router-testing-utils%20by%20%40lauradotjs%20https%3A%2F%2Fgithub.com%2FLauraBeatris%2Freact-router-testing-utils%20%F0%9F%91%8D 47 | [twitter-badge]: 48 | https://img.shields.io/twitter/url/https/github.com/LauraBeatris/react-router-testing-utils.svg?style=social 49 | 50 | 51 | ## Table of Contents 52 | - [Installation](#installation) 53 | - [Usage](#usage) 54 | - [With TypeScript](#with-typescript) 55 | - [Render in Router](#render-in-router) 56 | - [Custom matchers](#custom-matchers) 57 | - [`toHaveQueryParam`](#tohavequeryparam) 58 | - [LICENSE](#license) 59 | 60 | ## Installation 61 | 62 | This module should be installed as one of your project's `devDependencies`: 63 | 64 | With NPM 65 | ``` 66 | npm install --save-dev react-router-testing-utils 67 | ``` 68 | 69 | or 70 | 71 | With [yarn](https://yarnpkg.com/) package manager 72 | ``` 73 | yarn add --dev react-router-testing-utils 74 | ``` 75 | 76 | ## Setup 77 | 78 | Import `react-router-testing-utils/extend-expect` in your [tests setup 79 | file][], in order to [extend Jest expectations][]: 80 | 81 | [tests setup file]: 82 | https://jestjs.io/docs/en/configuration.html#setupfilesafterenv-array 83 | [extend Jest expectations]: https://jestjs.io/docs/expect#expectextendmatchers 84 | 85 | ```js 86 | // In your own jest-setup.js (or any other name) 87 | import 'react-router-testing-utils/extend-expect' 88 | 89 | // In jest.config.js add (if you haven't already) 90 | setupFilesAfterEnv: ['/jest-setup.js'] 91 | ``` 92 | 93 | ### With TypeScript 94 | 95 | If you're using TypeScript, make sure your setup file is a `.ts` and not a `.js` 96 | to include the necessary types. 97 | 98 | You will also need to include your setup file in your `tsconfig.json` if you 99 | haven't already: 100 | 101 | ```json 102 | // In tsconfig.json 103 | "include": [ 104 | ... 105 | "./jest-setup.ts" 106 | ], 107 | ``` 108 | ## Render in Router 109 | 110 | This allows you to render a given component in a Router for un-browser environments 111 | 112 | ```js 113 | renderInRouter(ExampleAppRoutes, { 114 | initialEntries: ['/about'] 115 | }) 116 | 117 | expect(screen.getByTestId('example-about-page')).toBeVisible() 118 | ``` 119 | 120 | ## Custom matchers 121 | 122 | ### `toHaveQueryParam` 123 | 124 | This allows you to check if a location search has a certain query param value. 125 | 126 | A query param is contained in a location search if **all** the following conditions are met: 127 | * It's name is contained in the location search 128 | * It's value is contained in the location search 129 | * It's given type corresponds to it's decoded value 130 | 131 | In order to encode/decode query params, it's necessary to provide the param type from `ParamTypes` exported module 132 | 133 | #### Examples 134 | 135 | ```javascript 136 | import { ParamTypes } from 'react-router-testing-utils' 137 | 138 | const { history } = renderInRouter(ExampleAppRoutes, { 139 | shouldCheckHistory: true, 140 | }) 141 | 142 | expect(history?.location.search).toHaveQueryParam({ 143 | name: 'filter-object', 144 | type: ParamTypes.ObjectParam, 145 | value: { foo: 'foo' } 146 | }) 147 | ``` 148 | 149 | ## LICENSE 150 | 151 | MIT -------------------------------------------------------------------------------- /extend-expect.ts: -------------------------------------------------------------------------------- 1 | import * as extensions from './src/matchers' 2 | 3 | expect.extend(extensions) 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | setupFilesAfterEnv: ['./setupTests.ts'] 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.4.0", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src", 9 | "extend-expect.ts" 10 | ], 11 | "engines": { 12 | "node": ">=10" 13 | }, 14 | "keywords": [ 15 | "testing", 16 | "react", 17 | "react-router", 18 | "react-router-testing" 19 | ], 20 | "scripts": { 21 | "start": "tsdx watch", 22 | "build": "tsdx build", 23 | "test": "tsdx test", 24 | "lint": "tsdx lint", 25 | "prepare": "tsdx build", 26 | "size": "size-limit", 27 | "analyze": "size-limit --why" 28 | }, 29 | "husky": { 30 | "hooks": { 31 | "pre-commit": "tsdx lint" 32 | } 33 | }, 34 | "name": "react-router-testing-utils", 35 | "author": "Laura Beatris (https://laurabeatris.com)", 36 | "module": "dist/react-router-testing-utils.esm.js", 37 | "size-limit": [ 38 | { 39 | "path": "dist/react-router-testing-utils.cjs.production.min.js", 40 | "limit": "70 KB" 41 | }, 42 | { 43 | "path": "dist/react-router-testing-utils.esm.js", 44 | "limit": "70 KB" 45 | } 46 | ], 47 | "dependencies": { 48 | "@testing-library/react": "^11.2.6", 49 | "history": "^5.0.0", 50 | "lodash.isequal": "^4.5.0", 51 | "query-string": "^7.0.0", 52 | "react-router-dom": "^5.2.0", 53 | "serialize-query-params": "^1.3.3", 54 | "@types/lodash.isequal": "^4.5.5", 55 | "@types/react-router-dom": "^5.1.7" 56 | }, 57 | "devDependencies": { 58 | "react": "^17.0.2", 59 | "react-dom": "^17.0.2", 60 | "@types/react": "^17.0.3", 61 | "@size-limit/preset-small-lib": "^4.10.2", 62 | "@testing-library/jest-dom": "^5.11.10", 63 | "@typescript-eslint/eslint-plugin": "^4.22.0", 64 | "@typescript-eslint/parser": "^4.22.0", 65 | "babel-eslint": "^10.1.0", 66 | "eslint": "^7.5.0", 67 | "eslint-config-standard": "^16.0.2", 68 | "eslint-config-standard-jsx": "^10.0.0", 69 | "eslint-config-standard-react": "^11.0.1", 70 | "eslint-import-resolver-typescript": "^2.3.0", 71 | "eslint-plugin-import": "^2.22.1", 72 | "eslint-plugin-jest-dom": "^3.8.0", 73 | "eslint-plugin-jsx-a11y": "^6.3.1", 74 | "eslint-plugin-node": "^11.1.0", 75 | "eslint-plugin-promise": "^4.2.1", 76 | "eslint-plugin-react": "^7.20.3", 77 | "eslint-plugin-react-hooks": "^4.0.8", 78 | "husky": "^6.0.0", 79 | "size-limit": "^4.10.2", 80 | "ts-jest": "^26.5.5", 81 | "tsdx": "^0.14.1", 82 | "tslib": "^2.2.0", 83 | "typescript": "^4.2.4", 84 | "use-query-params": "^1.2.2" 85 | }, 86 | "peerDependencies": { 87 | "react": "*", 88 | "react-dom": "*" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | import './extend-expect' 3 | -------------------------------------------------------------------------------- /src/@types/jest.d.ts: -------------------------------------------------------------------------------- 1 | namespace jest { 2 | interface Matchers { 3 | toHaveQueryParam( 4 | expectedQueryParam: { 5 | name: string; 6 | value: any; 7 | type: import('serialize-query-params').QueryParamConfig 8 | } 9 | ): CustomMatcherResult; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/__tests__/appExample/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route, Link } from 'react-router-dom' 3 | import { ArrayParam, NumberParam, ObjectParam, QueryParamProvider, useQueryParam } from 'use-query-params' 4 | 5 | export const EXAMPLE_QUERY_PARAMS_PAGE_ROUTE_NAME = '/query-params' 6 | export const EXAMPLE_ABOUT_PAGE_ROUTE_NAME = '/about' 7 | export const EXAMPLE_HOME_PAGE_ROUTE_NAME = '/' 8 | 9 | export const ExampleQueryParamsPage = () => { 10 | const [, setFilterNumber] = useQueryParam('filter-number', NumberParam) 11 | const [, setFilterObject] = useQueryParam('filter-object', ObjectParam) 12 | const [, setFilterArray] = useQueryParam('filter-array', ArrayParam) 13 | 14 | return ( 15 |
16 |

Select filter

17 | 18 | 19 | 20 |
21 | ) 22 | } 23 | 24 | export const ExampleAboutPage = () => ( 25 |
26 |

27 | This is a random text for a paragraph 28 |

29 |
30 | ) 31 | 32 | export const ExampleHomePage = () => ( 33 |
34 | 39 |

40 | Example App Title 41 |

42 |
43 | ) 44 | 45 | export const EXAMPLE_APP_ROUTES = [ 46 | { 47 | path: EXAMPLE_QUERY_PARAMS_PAGE_ROUTE_NAME, 48 | component: ExampleQueryParamsPage 49 | }, 50 | { 51 | path: EXAMPLE_ABOUT_PAGE_ROUTE_NAME, 52 | component: ExampleAboutPage 53 | }, 54 | { 55 | path: EXAMPLE_HOME_PAGE_ROUTE_NAME, 56 | component: ExampleHomePage 57 | } 58 | ] 59 | 60 | export const ExampleAppRoutes = () => ( 61 | 62 | {EXAMPLE_APP_ROUTES.map(({ path, component: Component }) => ( 63 | 64 | 65 | 66 | ))} 67 | 68 | ) 69 | -------------------------------------------------------------------------------- /src/__tests__/hocs/renderInRouter.test.tsx: -------------------------------------------------------------------------------- 1 | import { screen, fireEvent } from '@testing-library/react' 2 | 3 | import { renderInRouter } from '../../index' 4 | import { EXAMPLE_HOME_PAGE_ROUTE_NAME, ExampleAppRoutes } from '../appExample' 5 | 6 | describe('renderInRouter HOC', () => { 7 | it('Renders component', () => { 8 | renderInRouter(ExampleAppRoutes) 9 | 10 | expect(screen.getByTestId('example-home-page')).toBeVisible() 11 | }) 12 | 13 | it('Renders component with initial entries', () => { 14 | renderInRouter(ExampleAppRoutes, { 15 | initialEntries: ['/about'] 16 | }) 17 | 18 | expect(screen.getByTestId('example-about-page')).toBeVisible() 19 | }) 20 | 21 | it('Enables navigation', () => { 22 | renderInRouter(ExampleAppRoutes) 23 | 24 | screen.getByTestId('example-home-page') 25 | 26 | fireEvent( 27 | screen.getByRole('link'), 28 | new MouseEvent('click', { 29 | bubbles: true, 30 | cancelable: true 31 | }) 32 | ) 33 | 34 | expect(screen.getByTestId('example-about-page')).toBeVisible() 35 | }) 36 | 37 | it('Accesses initial history', () => { 38 | const { history } = renderInRouter(ExampleAppRoutes, { 39 | shouldCheckHistory: true 40 | }) 41 | 42 | expect(history?.location.pathname).toBe(EXAMPLE_HOME_PAGE_ROUTE_NAME) 43 | }) 44 | 45 | it('Updates history according to navigation', () => { 46 | const { history } = renderInRouter(ExampleAppRoutes, { 47 | shouldCheckHistory: true 48 | }) 49 | 50 | expect(history?.location.pathname).toBe('/') 51 | 52 | fireEvent( 53 | screen.getByRole('link'), 54 | new MouseEvent('click', { 55 | bubbles: true, 56 | cancelable: true 57 | }) 58 | ) 59 | 60 | expect(history?.location.pathname).toBe('/about') 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /src/__tests__/matchers/toHaveQueryParam.test.ts: -------------------------------------------------------------------------------- 1 | import { screen, fireEvent } from '@testing-library/dom' 2 | import { ArrayParam, NumberParam, ObjectParam, StringParam } from 'serialize-query-params' 3 | 4 | import { renderInRouter } from '../..' 5 | import { ExampleAppRoutes, EXAMPLE_QUERY_PARAMS_PAGE_ROUTE_NAME } from '../appExample' 6 | 7 | describe('toHaveQueryParam matcher', () => { 8 | it("doesn't pass if query param don't exists", () => { 9 | const { history } = renderInRouter(ExampleAppRoutes, { 10 | shouldCheckHistory: true, 11 | initialEntries: [EXAMPLE_QUERY_PARAMS_PAGE_ROUTE_NAME] 12 | }) 13 | 14 | expect(history?.location.search).not.toHaveQueryParam({ 15 | name: 'foo', 16 | value: 'foo', 17 | type: StringParam 18 | }) 19 | }) 20 | 21 | it("doesn't pass if query param isn't equal to expected value", () => { 22 | const { history } = renderInRouter(ExampleAppRoutes, { 23 | shouldCheckHistory: true, 24 | initialEntries: [EXAMPLE_QUERY_PARAMS_PAGE_ROUTE_NAME] 25 | }) 26 | 27 | fireEvent( 28 | screen.getByRole('button', { name: 'Number' }), 29 | new MouseEvent('click', { 30 | bubbles: true, 31 | cancelable: true 32 | }) 33 | ) 34 | 35 | expect(history?.location.search).not.toHaveQueryParam({ 36 | name: 'filter-number', 37 | type: NumberParam, 38 | value: 2 39 | }) 40 | }) 41 | 42 | it('passes if query param exists & is equal to expected value', () => { 43 | const { history } = renderInRouter(ExampleAppRoutes, { 44 | shouldCheckHistory: true, 45 | initialEntries: [EXAMPLE_QUERY_PARAMS_PAGE_ROUTE_NAME] 46 | }) 47 | 48 | fireEvent( 49 | screen.getByRole('button', { name: 'Number' }), 50 | new MouseEvent('click', { 51 | bubbles: true, 52 | cancelable: true 53 | }) 54 | ) 55 | 56 | expect(history?.location.search).toHaveQueryParam({ 57 | name: 'filter-number', 58 | type: NumberParam, 59 | value: 1 60 | }) 61 | }) 62 | 63 | it('handle query param encoding of different value types', () => { 64 | const { history } = renderInRouter(ExampleAppRoutes, { 65 | shouldCheckHistory: true, 66 | initialEntries: [EXAMPLE_QUERY_PARAMS_PAGE_ROUTE_NAME] 67 | }) 68 | 69 | fireEvent( 70 | screen.getByRole('button', { name: 'Object' }), 71 | new MouseEvent('click', { 72 | bubbles: true, 73 | cancelable: true 74 | }) 75 | ) 76 | 77 | fireEvent( 78 | screen.getByRole('button', { name: 'Array' }), 79 | new MouseEvent('click', { 80 | bubbles: true, 81 | cancelable: true 82 | }) 83 | ) 84 | 85 | expect(history?.location.search).toHaveQueryParam({ 86 | name: 'filter-object', 87 | type: ObjectParam, 88 | value: { foo: 'foo' } 89 | }) 90 | expect(history?.location.search).toHaveQueryParam({ 91 | name: 'filter-array', 92 | type: ArrayParam, 93 | value: ['1'] 94 | }) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /src/hocs/renderInRouter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { History } from 'history' 3 | import { render, RenderResult, RenderOptions } from '@testing-library/react' 4 | import { Route, MemoryRouter } from 'react-router-dom' 5 | 6 | export type RenderInRouterArguments = { 7 | renderOptions?: RenderOptions, 8 | initialEntries?: Array, 9 | shouldCheckHistory?: boolean; 10 | } 11 | 12 | export type RenderInRouterReturnType = RenderResult & { 13 | history?: History 14 | } 15 | 16 | /** 17 | * @description 18 | * Allows you to render a given component in a Router for un-browser environments 19 | */ 20 | export const renderInRouter = (Component: React.FC, { 21 | initialEntries = ['/'], 22 | renderOptions, 23 | shouldCheckHistory = false 24 | }: RenderInRouterArguments = {}): RenderInRouterReturnType => { 25 | let history 26 | 27 | const renderPayload = render( 28 | 29 | 30 | 31 | {shouldCheckHistory && ( 32 | { 35 | history = renderedHistory 36 | 37 | return null 38 | }} 39 | /> 40 | )} 41 | , renderOptions) 42 | 43 | return { 44 | ...renderPayload, 45 | history 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { renderInRouter } from './hocs/renderInRouter' 2 | import { ParamTypes } from './utils/serialize' 3 | 4 | export { 5 | ParamTypes, 6 | renderInRouter 7 | } 8 | -------------------------------------------------------------------------------- /src/matchers/index.ts: -------------------------------------------------------------------------------- 1 | import { toHaveQueryParam } from './toHaveQueryParam' 2 | 3 | export { toHaveQueryParam } 4 | -------------------------------------------------------------------------------- /src/matchers/message.ts: -------------------------------------------------------------------------------- 1 | export interface CustomMatcherMessagePayload { 2 | pass: boolean; 3 | message: () => string 4 | } 5 | 6 | export class CustomMatcherMessage { 7 | public readonly pass: CustomMatcherMessagePayload['pass']; 8 | 9 | public readonly message: CustomMatcherMessagePayload['message']; 10 | 11 | constructor ({ pass, message }: CustomMatcherMessagePayload) { 12 | this.message = message 13 | this.pass = pass 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/matchers/toHaveQueryParam/helpers.ts: -------------------------------------------------------------------------------- 1 | import { decodeQueryParams, encodeQueryParams, QueryParamConfig } from 'serialize-query-params' 2 | import { parse } from 'query-string' 3 | import isEqual from 'lodash.isequal' 4 | 5 | import { CustomMatcherMessage } from '../message' 6 | 7 | export interface ExpectedQueryParam { 8 | name: string; 9 | type: QueryParamConfig 10 | value: any; 11 | } 12 | 13 | type EncodedValue = string | string[] | null 14 | 15 | export const validateArguments = ({ 16 | name, 17 | type, 18 | locationSearch, 19 | encodedReceivedValue 20 | }: Pick & { 21 | locationSearch: Location['search'], 22 | encodedReceivedValue: EncodedValue, 23 | }) => { 24 | if (!type) { 25 | throw new CustomMatcherMessage({ 26 | pass: false, 27 | message: () => 'Please, make sure to provide a type argument in order to serialize a query param value' 28 | }) 29 | } 30 | 31 | if (!encodedReceivedValue) { 32 | throw new CustomMatcherMessage({ 33 | pass: false, 34 | message: () => `${name} wasn't found in ${locationSearch}` 35 | }) 36 | } 37 | } 38 | 39 | export const encodeValues = ({ 40 | type, 41 | name, 42 | value, 43 | locationSearch 44 | }: ExpectedQueryParam & { 45 | locationSearch: Location['search'], 46 | }) => { 47 | const encodedReceivedValue = parse(locationSearch)[name] 48 | 49 | return { 50 | encodedExpectedValue: encodeQueryParams( 51 | { [name]: type }, 52 | { [name]: value } 53 | )[name], 54 | encodedReceivedValue, 55 | decodedReceivedValue: decodeQueryParams( 56 | { [name]: type }, 57 | { [name]: encodedReceivedValue } 58 | )[name] 59 | } 60 | } 61 | 62 | export const validateValues = ({ 63 | name, 64 | value, 65 | encodedExpectedValue, 66 | encodedReceivedValue, 67 | decodedReceivedValue 68 | }: Pick & ReturnType) => { 69 | throw new CustomMatcherMessage({ 70 | pass: isEqual(decodedReceivedValue, value), 71 | message: () => `${name} query param is expected to be ${encodedExpectedValue} but the received value is ${encodedReceivedValue}` 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /src/matchers/toHaveQueryParam/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | encodeValues, 3 | validateValues, 4 | validateArguments, 5 | ExpectedQueryParam 6 | } from './helpers' 7 | 8 | /** 9 | * @description 10 | * This allows you to check if a location search has a certain query param value. 11 | * 12 | * @example 13 | * "https://mywebsite.com/home?page=1" 14 | * 15 | * expect(location.search).toHaveQueryParam({ name: page, value: 1, type: NumberParam }) 16 | * 17 | * @see 18 | * [react-router-testing-utils#tohavequeryparam](https://github.com/LauraBeatris/react-router-testing-utils#tohavequeryparam) 19 | */ 20 | export const toHaveQueryParam = ( 21 | locationSearch: Location['search'], 22 | { 23 | name, 24 | type, 25 | value 26 | }: ExpectedQueryParam 27 | ) => { 28 | try { 29 | const { 30 | encodedExpectedValue, 31 | encodedReceivedValue, 32 | decodedReceivedValue 33 | } = encodeValues({ 34 | name, 35 | value, 36 | type, 37 | locationSearch 38 | }) 39 | 40 | validateArguments({ 41 | type, 42 | name, 43 | locationSearch, 44 | encodedReceivedValue 45 | }) 46 | 47 | validateValues({ 48 | name, 49 | value, 50 | encodedExpectedValue, 51 | encodedReceivedValue, 52 | decodedReceivedValue 53 | }) 54 | } catch (message) { 55 | return message 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/serialize.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DateParam, 3 | JsonParam, 4 | ArrayParam, 5 | ObjectParam, 6 | NumberParam, 7 | StringParam, 8 | BooleanParam, 9 | DateTimeParam, 10 | NumericObjectParam, 11 | DelimitedArrayParam, 12 | DelimitedNumericArrayParam 13 | } from 'serialize-query-params' 14 | 15 | export const ParamTypes = { 16 | DateParam, 17 | JsonParam, 18 | ArrayParam, 19 | ObjectParam, 20 | NumberParam, 21 | StringParam, 22 | BooleanParam, 23 | DateTimeParam, 24 | NumericObjectParam, 25 | DelimitedArrayParam, 26 | DelimitedNumericArrayParam 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types", "dist"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "moduleResolution": "node", 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "skipLibCheck": true, 20 | "forceConsistentCasingInFileNames": true, 21 | "noEmit": true, 22 | } 23 | } 24 | --------------------------------------------------------------------------------