├── .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 |
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 | [](#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 |
--------------------------------------------------------------------------------