├── .gitignore ├── README.md ├── jest.config.js ├── package.json ├── src ├── __tests__ │ ├── by-selector-queries.test.js │ ├── enhance-queries.test.js │ └── matchers.test.js ├── by-selector-queries.js ├── enhance-queries.js ├── index.js ├── matchers.js ├── screen.js └── within.js └── test ├── setup.js └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /package-lock.json 3 | /coverage/ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # query-extensions 2 | 3 | Extensions for core @testing-library queries 4 | 5 | [Kent & Travis briefly discuss](https://youtu.be/FdO2cphSH9Y?t=772) during one 6 | of Kent's helpful office hours sessions 7 | 8 | # Install 9 | 10 | ```sh 11 | npm install query-extensions 12 | 13 | # or 14 | yarn add query-extensions 15 | ``` 16 | 17 | # Motivation 18 | 19 | Here's the reason this package exists: 20 | 21 | ```js 22 | import { screen } from "query-extensions"; 23 | import { fireEvent } from "@testing-library/react"; 24 | // ... more imports 25 | 26 | test("the standard screen queries work nicely for the majority of cases", async () => { 27 | render(); 28 | 29 | // standard queries are available 30 | // component starts in loading state 31 | const loadingEl = screen.getByText("Loading..."); 32 | expect(loadingEl).toBeInTheDocument(); 33 | 34 | // loads up an email input, loading disappears 35 | const emailInput = await screen.findByLabelText("Your email"); 36 | expect(screen.queryByText("Loading...")).toBeNull(); 37 | 38 | // fill out email and click to sign up 39 | fireEvent.change(emailInput, { target: { value: "email@example.com" } }); 40 | fireEvent.click(screen.getByRole("button", { name: /sign up/i })); 41 | 42 | // success modal pops up and takes over component (hiding other content) 43 | await screen.findByRole("img", { name: "celebration" }); 44 | expect(screen.queryByLabelText("Your email")).toBeNull(); 45 | expect(screen.queryByRole("button", { name: /sign up/i })).toBeNull(); 46 | }); 47 | 48 | test("the query extensions API can help us write something more readable and maintainable", async () => { 49 | const ui = { 50 | successIcon: { filter: "role", params: ["img", { name: "celebration" }] }, 51 | signUpBtn: { filter: "role", params: ["button", { name: /sign up/i }] }, 52 | emailInput: { filter: "labelText", params: ["Your email"] }, 53 | loading: { filter: "text", params: ["Loading..."] }, 54 | }; 55 | 56 | render(); 57 | 58 | // component starts in loading state 59 | expect(screen.get(ui.loading)).toBeInTheDocument(); 60 | 61 | // loads up an email input, loading disappears 62 | const emailInput = await screen.find(ui.emailInput); 63 | expect(screen.query(ui.loading)).toBeNull(); 64 | 65 | // fill out email and click to sign up 66 | fireEvent.change(emailInput, { target: { value: "email@example.com" } }); 67 | fireEvent.click(screen.get(ui.signUpBtn)); 68 | 69 | // success modal pops up and takes over component (hiding other content) 70 | await screen.find(ui.successIcon); 71 | expect(screen.query(ui.emailInput)).toBeNull(); 72 | expect(screen.query(ui.signUpBtn)).toBeNull(); 73 | }); 74 | ``` 75 | 76 | TL;DR wouldn't it be nice to reuse your querying configs without coupling to a 77 | particular flavor of get/query/find? 78 | 79 | If that (contrived) example doesn't sell you outright, consider a couple of 80 | "maintenance" scenarios. What happens to each test (or a _much_ bigger, more 81 | hypothetical test suite) if: 82 | 83 | 1. A UI element goes from rendering sync to async (or vice versa) 84 | 2. A UI element has a text/markup/label change which requires a different query 85 | 86 | # Usage 87 | 88 | ## `screen` 89 | 90 | There's a handy, pre-built `screen` object available for direct use. This is 91 | probably the most common way you'll interact with `query-extensions` 92 | 93 | ```js 94 | import { screen } from 'query-extensions'; 95 | import { render } from '@testing-library/react'; 96 | // ... more imports 97 | 98 | test('your actual test', () => { 99 | render(); 100 | 101 | // standard screen query 102 | expect(screen.queryByText('Expected text')).toBeTruthy(); 103 | 104 | // equivalent _enhanced_ query! 105 | expect(screen.query({ filter: 'text', params: ['Expected text'] }).toBeTruthy(); 106 | }) 107 | ``` 108 | 109 | ## `within` 110 | 111 | Similarly, `query-extensions` provides its own version of the `within` API which 112 | makes the extended queries available on the resulting query object. 113 | 114 | ```js 115 | import { within, screen } from "query-extensions"; 116 | import { render } from "@testing-library/react"; 117 | // ... more imports 118 | 119 | test("your actual test", () => { 120 | render(); 121 | 122 | // standard within-scoped query 123 | expect( 124 | within(screen.getByTestId("container-id")).queryByText("Expected text") 125 | ).toBeTruthy(); 126 | 127 | // equivalent _enhanced_ query! OK it's actually _longer_ but you'll have to 128 | // make your own conclusions about tradeoffs ;) 129 | const containerConfig = { filter: "testId", params: ["container-id"] }; 130 | const targetConfig = { filter: "text", params: ["Expected text"] }; 131 | expect(within(screen.get(containerConfig)).query(targetConfig)).toBeTruthy(); 132 | }); 133 | ``` 134 | 135 | Scoping with `within` is also possible via the `within` property of the query 136 | descriptor object (this can nest/compose with itself as well as the top-level 137 | `within` API) 138 | 139 | ```js 140 | import { screen } from "query-extensions"; 141 | import { render } from "@testing-library/react"; 142 | // ... more imports 143 | 144 | test("your actual test", () => { 145 | render(); 146 | 147 | // standard within-scoped query 148 | expect( 149 | within(screen.getByTestId("container-id")).queryByText("Expected text") 150 | ).toBeTruthy(); 151 | 152 | // equivalent _enhanced_ query! 153 | const containerConfig = { filter: "testId", params: ["container-id"] }; 154 | expect( 155 | query({ 156 | filter: "text", 157 | params: ["Expected text"], 158 | within: containerConfig, 159 | }) 160 | ).toBeTruthy(); 161 | }); 162 | ``` 163 | 164 | ## `enhanceQueries` 165 | 166 | You can also enhance any query objects you like using `enhanceQueries` 167 | 168 | ```js 169 | import { render } from '@testing-library/react'; 170 | import { enhanceQueries } from 'query-extensions'; 171 | // ... more imports 172 | 173 | test('your actual test', () => { 174 | const queries = render(); 175 | 176 | // standard query 177 | expect(queries.queryByText('Expected text')).toBeTruthy(); 178 | 179 | // equivalent _enhanced_ query! 180 | const enhanced = enhanceQueries(queries); 181 | expect(enhanced.query({ filter: 'text', params: ['Expected text'] }).toBeTruthy(); 182 | }) 183 | ``` 184 | 185 | ## `queryBySelector` (and the whole \*BySelector family) 186 | 187 | OK, you _really_ should do everything in your power to keep your tests following 188 | the [guiding principles](https://testing-library.com/docs/guiding-principles) of 189 | @testing-library 190 | 191 | _BUT_ sometimes your application code is just a bit of a mess and your tests 192 | really need to drop down and do a standard `querySelector`-style interaction. 193 | 194 | This has always been possible with a bit of manual intervention, but 195 | `query-extensions` offers a simple wrapper for API consistency. 196 | 197 | ```js 198 | import { render } from "@testing-library/react"; 199 | import { screen } from "query-extensions"; 200 | // ... more imports 201 | 202 | test("sometimes you just have to use a selector", async () => { 203 | const { unmount } = render(); 204 | 205 | // maybe your logo is just a styled div with a background-image, I dunno 206 | const logoData = { filter: "selector", params: [".company-logo"] }; 207 | 208 | const logo = screen.get(logoData); 209 | expect(logo).toHaveStyle({ backgroundImage: "/some/image.png" }); // maybe!? 210 | 211 | // the long-form query API is available as well, of course! 212 | const logo2 = screen.getBySelector(".company-logo"); 213 | expect(logo2).toHaveStyle({ backgroundImage: "/some/image.png" }); 214 | 215 | unmount(); 216 | 217 | expect(screen.query(logoData)).toBeNull(); 218 | expect(screen.queryBySelector(".company-logo")).toBeNull(); 219 | }); 220 | ``` 221 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // The directory where Jest should store its cached dependency information 12 | // cacheDirectory: "/private/var/folders/t0/r14q7d0s51vbmt63j6g3gv3c0000gn/T/jest_dx", 13 | 14 | // Automatically clear mock calls and instances between every test 15 | clearMocks: true, 16 | 17 | // Indicates whether the coverage information should be collected while executing the test 18 | // collectCoverage: false, 19 | 20 | // An array of glob patterns indicating a set of files for which coverage information should be collected 21 | // collectCoverageFrom: undefined, 22 | 23 | // The directory where Jest should output its coverage files 24 | coverageDirectory: "coverage", 25 | 26 | // An array of regexp pattern strings used to skip coverage collection 27 | // coveragePathIgnorePatterns: [ 28 | // "/node_modules/" 29 | // ], 30 | 31 | // Indicates which provider should be used to instrument code for coverage 32 | coverageProvider: "v8", 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: undefined, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: undefined, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: undefined, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: undefined, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 64 | // maxWorkers: "50%", 65 | 66 | // An array of directory names to be searched recursively up from the requiring module's location 67 | // moduleDirectories: [ 68 | // "node_modules" 69 | // ], 70 | 71 | // An array of file extensions your modules use 72 | // moduleFileExtensions: [ 73 | // "js", 74 | // "json", 75 | // "jsx", 76 | // "ts", 77 | // "tsx", 78 | // "node" 79 | // ], 80 | 81 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 82 | // moduleNameMapper: {}, 83 | 84 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 85 | // modulePathIgnorePatterns: [], 86 | 87 | // Activates notifications for test results 88 | // notify: false, 89 | 90 | // An enum that specifies notification mode. Requires { notify: true } 91 | // notifyMode: "failure-change", 92 | 93 | // A preset that is used as a base for Jest's configuration 94 | // preset: undefined, 95 | 96 | // Run tests from one or more projects 97 | // projects: undefined, 98 | 99 | // Use this configuration option to add custom reporters to Jest 100 | // reporters: undefined, 101 | 102 | // Automatically reset mock state between every test 103 | // resetMocks: false, 104 | 105 | // Reset the module registry before running each individual test 106 | // resetModules: false, 107 | 108 | // A path to a custom resolver 109 | // resolver: undefined, 110 | 111 | // Automatically restore mock state between every test 112 | // restoreMocks: false, 113 | 114 | // The root directory that Jest should scan for tests and modules within 115 | // rootDir: undefined, 116 | 117 | // A list of paths to directories that Jest should use to search for files in 118 | // roots: [ 119 | // "" 120 | // ], 121 | 122 | // Allows you to use a custom runner instead of Jest's default test runner 123 | // runner: "jest-runner", 124 | 125 | // The paths to modules that run some code to configure or set up the testing environment before each test 126 | // setupFiles: [], 127 | 128 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 129 | setupFilesAfterEnv: ['/test/setup.js'], 130 | 131 | // The number of seconds after which a test is considered as slow and reported as such in the results. 132 | // slowTestThreshold: 5, 133 | 134 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 135 | // snapshotSerializers: [], 136 | 137 | // The test environment that will be used for testing 138 | // testEnvironment: "jest-environment-jsdom", 139 | 140 | // Options that will be passed to the testEnvironment 141 | // testEnvironmentOptions: {}, 142 | 143 | // Adds a location field to test results 144 | // testLocationInResults: false, 145 | 146 | // The glob patterns Jest uses to detect test files 147 | // testMatch: [ 148 | // "**/__tests__/**/*.[jt]s?(x)", 149 | // "**/?(*.)+(spec|test).[tj]s?(x)" 150 | // ], 151 | 152 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 153 | // testPathIgnorePatterns: [ 154 | // "/node_modules/" 155 | // ], 156 | 157 | // The regexp pattern or array of patterns that Jest uses to detect test files 158 | // testRegex: [], 159 | 160 | // This option allows the use of a custom results processor 161 | // testResultsProcessor: undefined, 162 | 163 | // This option allows use of a custom test runner 164 | // testRunner: "jasmine2", 165 | 166 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 167 | // testURL: "http://localhost", 168 | 169 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 170 | // timers: "real", 171 | 172 | // A map from regular expressions to paths to transformers 173 | // transform: undefined, 174 | 175 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 176 | // transformIgnorePatterns: [ 177 | // "/node_modules/", 178 | // "\\.pnp\\.[^\\/]+$" 179 | // ], 180 | 181 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 182 | // unmockedModulePathPatterns: undefined, 183 | 184 | // Indicates whether each individual test should be reported during the run 185 | // verbose: undefined, 186 | 187 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 188 | // watchPathIgnorePatterns: [], 189 | 190 | // Whether to use watchman for file crawling 191 | // watchman: true, 192 | }; 193 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "query-extensions", 3 | "version": "0.0.4", 4 | "description": "Extensions to the core @testing-library query API", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/tjefferson08/query-extensions.git" 12 | }, 13 | "keywords": [ 14 | "testing-library", 15 | "queries", 16 | "extensions", 17 | "get", 18 | "find", 19 | "query" 20 | ], 21 | "author": "Travis Jefferson", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/tjefferson08/query-extensions/issues" 25 | }, 26 | "homepage": "https://github.com/tjefferson08/query-extensions#readme", 27 | "dependencies": {}, 28 | "devDependencies": { 29 | "@testing-library/dom": "^7.24.2", 30 | "@testing-library/jest-dom": "^5.11.4", 31 | "jest": "^26.4.2" 32 | }, 33 | "peerDependencies": { 34 | "@testing-library/dom": "^7.24.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/__tests__/by-selector-queries.test.js: -------------------------------------------------------------------------------- 1 | const { renderIntoDocument } = require("../../test/utils"); 2 | const { 3 | queryBySelector, 4 | queryAllBySelector, 5 | getBySelector, 6 | getAllBySelector, 7 | findBySelector, 8 | findAllBySelector 9 | } = require("../by-selector-queries"); 10 | 11 | test("should support all queries by standard css selectors", async () => { 12 | const { unmount } = renderIntoDocument( 13 | `
14 | Item 1 15 | Item 2 16 | Item 3 17 |
` 18 | ); 19 | 20 | const container = document.body; 21 | const ERR_MESSAGE = /unable to find/i; 22 | 23 | expect(queryBySelector(container, ".wrapper")).toBeTruthy(); 24 | expect(queryAllBySelector(container, ".child")).toHaveLength(3); 25 | 26 | expect(getBySelector(container, ".wrapper")).toBeTruthy(); 27 | expect(getAllBySelector(container, ".child")).toHaveLength(3); 28 | 29 | await expect(findBySelector(container, ".wrapper")).resolves.toBeTruthy(); 30 | await expect(findAllBySelector(container, ".child")).resolves.toHaveLength(3); 31 | 32 | unmount(); 33 | 34 | expect(queryBySelector(container, ".wrapper")).toBeNull(); 35 | expect(queryAllBySelector(container, ".child")).toHaveLength(0); 36 | 37 | expect(() => getBySelector(container, ".wrapper")).toThrow(ERR_MESSAGE); 38 | expect(() => getAllBySelector(container, ".child")).toThrow(ERR_MESSAGE); 39 | 40 | await expect(findBySelector(container, ".wrapper")).rejects.toThrow( 41 | ERR_MESSAGE 42 | ); 43 | await expect(findAllBySelector(container, ".child")).rejects.toThrow( 44 | ERR_MESSAGE 45 | ); 46 | }); 47 | 48 | test("should support standard @testing-library multiple/missing errors", async () => { 49 | const { unmount } = renderIntoDocument( 50 | `
51 | Item 1 52 | Item 2 53 | Item 3 54 |
` 55 | ); 56 | 57 | const container = document.body; 58 | const MULTIPLE_ERROR = /found multiple elements/i; 59 | const MISSING_ERROR = /unable to find/i; 60 | 61 | expect(() => getBySelector(container, ".child")).toThrow(MULTIPLE_ERROR); 62 | expect(() => getBySelector(container, ".not-found")).toThrow(MISSING_ERROR); 63 | 64 | await expect(findBySelector(container, ".child")).rejects.toThrow( 65 | MULTIPLE_ERROR 66 | ); 67 | await expect(findBySelector(container, ".not-found")).rejects.toThrow( 68 | MISSING_ERROR 69 | ); 70 | }); 71 | -------------------------------------------------------------------------------- /src/__tests__/enhance-queries.test.js: -------------------------------------------------------------------------------- 1 | const { screen, within } = require("../index"); 2 | const { renderIntoDocument } = require("../../test/utils"); 3 | 4 | test("enhanced queries can reuse data for any of get/query/find query types", async () => { 5 | const { unmount } = renderIntoDocument( 6 | 'logo' 7 | ); 8 | 9 | const logoData = { filter: "role", params: ["img", { name: "logo" }] }; 10 | 11 | await expect(screen.find(logoData)).resolves.toBeTruthy(); 12 | expect(screen.get(logoData)).toBeTruthy(); 13 | expect(screen.query(logoData)).toBeTruthy(); 14 | 15 | unmount(); 16 | 17 | await expect(screen.find(logoData)).rejects.toThrow(/unable to find/i); 18 | expect(() => screen.get(logoData)).toThrow(/unable to find/i); 19 | expect(screen.query(logoData)).toBeNull(); 20 | }); 21 | 22 | test("enhanced queries can reuse data for any of getAll/queryAll/findAll query types", async () => { 23 | const { unmount } = renderIntoDocument( 24 | `
    25 |
  • one
  • 26 |
  • two
  • 27 |
  • three
  • 28 |
` 29 | ); 30 | 31 | const liData = { filter: "role", params: ["listitem"] }; 32 | 33 | await expect(screen.findAll(liData)).resolves.toHaveLength(3); 34 | expect(screen.getAll(liData)).toHaveLength(3); 35 | expect(screen.queryAll(liData)).toHaveLength(3); 36 | 37 | unmount(); 38 | 39 | await expect(screen.findAll(liData)).rejects.toThrow(/unable to find/i); 40 | expect(() => screen.getAll(liData)).toThrow(/unable to find/i); 41 | expect(screen.queryAll(liData)).toEqual([]); 42 | }); 43 | 44 | test("higher-level API should be able to leverage custom queries", async () => { 45 | const { unmount } = renderIntoDocument( 46 | '' 47 | ); 48 | 49 | const logoData = { filter: "selector", params: [".company-logo"] }; 50 | 51 | await expect(screen.find(logoData)).resolves.toBeTruthy(); 52 | expect(screen.get(logoData)).toBeTruthy(); 53 | expect(screen.query(logoData)).toBeTruthy(); 54 | 55 | unmount(); 56 | 57 | await expect(screen.find(logoData)).rejects.toThrow(/unable to find/i); 58 | expect(() => screen.get(logoData)).toThrow(/unable to find/i); 59 | expect(screen.query(logoData)).toBeNull(); 60 | }); 61 | 62 | test("within is extended to return enhanced query bundle", async () => { 63 | const { unmount } = renderIntoDocument( 64 | `
65 | 66 |
67 |
68 | 69 |
` 70 | ); 71 | 72 | const okBtnData = { filter: "role", params: ["button", { name: "OK" }] }; 73 | const dialogData = { filter: "role", params: ["dialog"] }; 74 | 75 | // querying for OK button should yield two results 76 | expect(screen.getAll(okBtnData)).toHaveLength(2); 77 | 78 | // scoping with `within` should limit to single result 79 | expect(within(screen.get(dialogData)).get(okBtnData)).toBeTruthy(); 80 | }); 81 | 82 | test("within is accessible via within property of query descriptor", async () => { 83 | const { unmount } = renderIntoDocument( 84 | ` 90 |
91 | 92 |
93 | 94 |
95 |
` 96 | ); 97 | 98 | const okBtnData = { filter: "role", params: ["button", { name: "OK" }] }; 99 | const dialogData = { filter: "role", params: ["dialog"] }; 100 | 101 | // querying for OK button should yield two results 102 | expect(screen.getAll(okBtnData)).toHaveLength(2); 103 | 104 | // scoping with `within` should limit to single result 105 | expect(screen.get({ ...okBtnData, within: dialogData })).toBeTruthy(); 106 | 107 | // I guess it can compose/recurse? The button within the complementary div 108 | // within the dialog should be the back button 109 | const complementaryData = { filter: "role", params: ["complementary"] }; 110 | const buttonData = { filter: "role", params: ["button"] }; 111 | const backButton = screen.get({ 112 | ...buttonData, 113 | within: { ...complementaryData, within: dialogData } 114 | }); 115 | expect(backButton.textContent).toEqual("Back"); 116 | 117 | // The button within the complementary div within the *nav* should be the 118 | // forward button 119 | const navData = { filter: "role", params: ["navigation"] }; 120 | const forwardButton = screen.get({ 121 | ...buttonData, 122 | within: { ...complementaryData, within: navData } 123 | }); 124 | expect(forwardButton.textContent).toEqual("Forward"); 125 | 126 | }); 127 | 128 | test("within can also access custom queries with/without higher level API", async () => { 129 | const { unmount } = renderIntoDocument( 130 | `
131 | 132 |
133 | ` 136 | ); 137 | 138 | const okBtnData = { filter: "selector", params: [".ok-btn"] }; 139 | const dialogData = { filter: "selector", params: [".modal-dialog"] }; 140 | const formData = { filter: "selector", params: ["#main-form"] }; 141 | 142 | // querying for OK button should yield two results 143 | expect(screen.getAll(okBtnData)).toHaveLength(2); 144 | expect(screen.getAllBySelector(".ok-btn")).toHaveLength(2); 145 | 146 | // scoping with `within` should limit to single result 147 | expect(within(screen.get(dialogData)).get(okBtnData)).toBeTruthy(); 148 | expect( 149 | within(screen.getBySelector(".modal-dialog")).getBySelector(".ok-btn") 150 | ).toBeTruthy(); 151 | 152 | // should also be able to scope within form element instead 153 | expect(within(screen.get(formData)).get(okBtnData)).toBeTruthy(); 154 | expect( 155 | within(screen.getBySelector("#main-form")).getBySelector(".ok-btn") 156 | ).toBeTruthy(); 157 | }); 158 | 159 | test("using unsupported filter / API will throw", () => { 160 | expect(() => screen.get({ filter: "unavailable thing" })).toThrow( 161 | /unsupported filter: unavailable thing/i 162 | ); 163 | }); 164 | 165 | test("omitting filter will throw a custom TypeError", () => { 166 | const noArgs = () => screen.get(); 167 | const missingFilter = () => screen.get({}); 168 | 169 | expect(noArgs).toThrow(TypeError); 170 | expect(missingFilter).toThrow(TypeError); 171 | 172 | expect(noArgs).toThrow(/filter parameter is required/i); 173 | expect(missingFilter).toThrow(/filter parameter is required/i); 174 | }); 175 | -------------------------------------------------------------------------------- /src/__tests__/matchers.test.js: -------------------------------------------------------------------------------- 1 | const { within } = require("../within"); 2 | const { screen } = require("../within"); 3 | const { renderIntoDocument } = require("../../test/utils"); 4 | 5 | function toHaveDescriptor(element, descriptor) { 6 | if (this.isNot) { 7 | return { 8 | pass: !!within(element).query(descriptor), 9 | }; 10 | } 11 | 12 | return { 13 | pass: !!within(element).get(descriptor), 14 | }; 15 | } 16 | 17 | expect.extend({ 18 | toHaveDescriptor, 19 | }); 20 | 21 | test("should be able to test presence and absence", async () => { 22 | const logo = { filter: "role", params: ["img", { name: "logo" }] }; 23 | 24 | const { unmount } = renderIntoDocument('logo'); 25 | 26 | expect(document).toHaveDescriptor(logo); 27 | 28 | unmount(); 29 | 30 | expect(document).not.toHaveDescriptor(logo); 31 | }); 32 | -------------------------------------------------------------------------------- /src/by-selector-queries.js: -------------------------------------------------------------------------------- 1 | const { buildQueries } = require("@testing-library/dom"); 2 | 3 | const getMultipleError = (c, selector) => 4 | `Found multiple elements with the selector text: ${selector}`; 5 | 6 | const getMissingError = (c, selector) => 7 | `Unable to find an element with the selector text: ${selector}`; 8 | 9 | const queryAllBySelector = (container, selector) => [ 10 | ...container.querySelectorAll(selector), 11 | ]; 12 | 13 | const [ 14 | queryBySelector, 15 | getAllBySelector, 16 | getBySelector, 17 | findAllBySelector, 18 | findBySelector, 19 | ] = buildQueries(queryAllBySelector, getMultipleError, getMissingError); 20 | 21 | module.exports = { 22 | queryBySelector, 23 | queryAllBySelector, 24 | getBySelector, 25 | getAllBySelector, 26 | findBySelector, 27 | findAllBySelector, 28 | }; 29 | -------------------------------------------------------------------------------- /src/enhance-queries.js: -------------------------------------------------------------------------------- 1 | const { within: dtlWithin } = require("@testing-library/dom"); 2 | 3 | const capitalize = (s) => `${s.charAt(0).toUpperCase()}${s.slice(1)}`; 4 | 5 | const required = (name) => { 6 | throw new TypeError(`${name} parameter is required`); 7 | }; 8 | 9 | module.exports = { 10 | enhanceQueries: (queries) => { 11 | const buildApiAccessor = (api) => ({ 12 | filter = required("filter"), 13 | params = [], 14 | within, 15 | } = {}) => { 16 | const fnName = `${api}By${capitalize(filter)}`; 17 | 18 | const fn = queries[fnName]; 19 | if (!fn) { 20 | throw new Error(`Unsupported filter: ${filter}`); 21 | } 22 | 23 | if (within) { 24 | // use the same query descriptor API to fetch the scoping element 25 | const scopedElement = buildApiAccessor("get")(within); 26 | return dtlWithin(scopedElement)[fnName](...params); 27 | } else { 28 | return queries[fnName](...params); 29 | } 30 | }; 31 | 32 | return { 33 | ...queries, 34 | get: buildApiAccessor("get"), 35 | getAll: buildApiAccessor("getAll"), 36 | query: buildApiAccessor("query"), 37 | queryAll: buildApiAccessor("queryAll"), 38 | find: buildApiAccessor("find"), 39 | findAll: buildApiAccessor("findAll"), 40 | }; 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const { screen } = require("./screen"); 2 | const { within } = require("./within"); 3 | const { enhanceQueries } = require("./enhance-queries"); 4 | 5 | module.exports = { 6 | enhanceQueries, 7 | screen, 8 | within, 9 | }; 10 | -------------------------------------------------------------------------------- /src/matchers.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjefferson08/query-extensions/b6e236779fae82ce45d040c8e817eb62328a4df9/src/matchers.js -------------------------------------------------------------------------------- /src/screen.js: -------------------------------------------------------------------------------- 1 | const { getQueriesForElement, screen } = require("@testing-library/dom"); 2 | const { enhanceQueries } = require("./enhance-queries"); 3 | const bySelectorQueries = require("./by-selector-queries"); 4 | 5 | const enhancedScreen = getQueriesForElement(document.body, bySelectorQueries); 6 | 7 | module.exports = { 8 | screen: enhanceQueries({ 9 | ...screen, 10 | ...enhancedScreen, 11 | }), 12 | }; 13 | -------------------------------------------------------------------------------- /src/within.js: -------------------------------------------------------------------------------- 1 | // Remember that `within` is simply an alias for `getQueriesForElement` 2 | const { within: dtlWithin } = require("@testing-library/dom"); 3 | const { enhanceQueries } = require("./enhance-queries"); 4 | const bySelectorQueries = require("./by-selector-queries"); 5 | 6 | module.exports = { 7 | within: (element, ...rest) => 8 | enhanceQueries({ 9 | ...dtlWithin(element, ...rest), 10 | ...dtlWithin(element, { ...bySelectorQueries }), 11 | }), 12 | }; 13 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | afterEach(() => { 2 | document.body.innerHTML = ""; 3 | }); 4 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | const renderIntoDocument = (html) => { 2 | const container = document.createElement("div"); 3 | container.id = "render-container"; 4 | container.innerHTML = html; 5 | document.body.appendChild(container); 6 | return { container, unmount: () => document.body.removeChild(container) }; 7 | }; 8 | 9 | module.exports = { 10 | renderIntoDocument, 11 | }; 12 | --------------------------------------------------------------------------------