From 5b0a7178c4cea80e4781c8fda88c6b201e1b7203 Mon Sep 17 00:00:00 2001 From: Alex Krolick Date: Fri, 4 May 2018 18:20:11 -0700 Subject: [PATCH 01/10] breaking: opt-in to fuzzy matching - Changes queries to default to exact string matching - Can opt-in to fuzzy matches by passing { exact: true } as the last arg - Queries that search inner text collapse whitespace (queryByText, queryByLabelText) This is a breaking change! --- .../__snapshots__/element-queries.js.snap | 2 +- src/__tests__/element-queries.js | 120 +++++++++++++++++- src/__tests__/matches.js | 34 +++-- src/__tests__/text-matchers.js | 19 +++ src/matches.js | 19 ++- src/queries.js | 57 +++++---- 6 files changed, 199 insertions(+), 52 deletions(-) diff --git a/src/__tests__/__snapshots__/element-queries.js.snap b/src/__tests__/__snapshots__/element-queries.js.snap index 67c2c6a9..825e4813 100644 --- a/src/__tests__/__snapshots__/element-queries.js.snap +++ b/src/__tests__/__snapshots__/element-queries.js.snap @@ -49,7 +49,7 @@ exports[`get throws a useful error message 6`] = ` `; exports[`label with no form control 1`] = ` -"Found a label with the text of: alone, however no form control was found associated to that label. Make sure you're using the \\"for\\" attribute or \\"aria-labelledby\\" attribute correctly. +"Found a label with the text of: /alone/, however no form control was found associated to that label. Make sure you're using the \\"for\\" attribute or \\"aria-labelledby\\" attribute correctly. 
 
, `) expect(getAllByAltText(/finding.*poster$/i)).toHaveLength(2) - expect(getAllByAltText('jumanji')).toHaveLength(1) + expect(getAllByAltText(/jumanji/)).toHaveLength(1) expect(getAllByTestId('poster')).toHaveLength(3) expect(getAllByPlaceholderText(/The Rock/)).toHaveLength(1) expect(getAllByLabelText('User Name')).toHaveLength(1) - expect(getAllByText('where')).toHaveLength(1) + expect(getAllByText(/^where/i)).toHaveLength(1) }) test('getAll* matchers throw for 0 matches', () => { @@ -188,8 +296,6 @@ test('getAll* matchers throw for 0 matches', () => { } = render(`
-
-
, `) expect(() => getAllByTestId('nope')).toThrow() diff --git a/src/__tests__/matches.js b/src/__tests__/matches.js index abef386d..aad577f3 100644 --- a/src/__tests__/matches.js +++ b/src/__tests__/matches.js @@ -1,4 +1,4 @@ -import {matches, matchesExact} from '../' +import {fuzzyMatches, matches} from '../' // unit tests for text match utils @@ -6,21 +6,31 @@ const node = null test('matches should get fuzzy matches', () => { // should not match - expect(matchesExact(null, node, 'abc')).toBe(false) - expect(matchesExact('', node, 'abc')).toBe(false) + expect(matches(null, node, 'abc')).toBe(false) + expect(matches('', node, 'abc')).toBe(false) // should match - expect(matches('ABC', node, 'abc')).toBe(true) - expect(matches('ABC', node, 'ABC')).toBe(true) + expect(fuzzyMatches('ABC', node, 'abc')).toBe(true) + expect(fuzzyMatches('ABC', node, 'ABC')).toBe(true) }) test('matchesExact should only get exact matches', () => { // should not match - expect(matchesExact(null, node, null)).toBe(false) - expect(matchesExact(null, node, 'abc')).toBe(false) - expect(matchesExact('', node, 'abc')).toBe(false) - expect(matchesExact('ABC', node, 'abc')).toBe(false) - expect(matchesExact('ABC', node, 'A')).toBe(false) - expect(matchesExact('ABC', node, 'ABCD')).toBe(false) + expect(matches(null, node, null)).toBe(false) + expect(matches(null, node, 'abc')).toBe(false) + expect(matches('', node, 'abc')).toBe(false) + expect(matches('ABC', node, 'abc')).toBe(false) + expect(matches('ABC', node, 'A')).toBe(false) + expect(matches('ABC', node, 'ABCD')).toBe(false) + // should match + expect(matches('ABC', node, 'ABC')).toBe(true) +}) + +test('matchers should collapse whitespace if requested', () => { // should match - expect(matchesExact('ABC', node, 'ABC')).toBe(true) + expect(matches('ABC\n \t', node, 'ABC', true)).toBe(true) + expect(matches('ABC\n \t', node, 'ABC', false)).toBe(false) + expect(fuzzyMatches('ABC\n \t', node, 'ABC', true)).toBe(true) + expect(fuzzyMatches(' ABC\n \t ', node, 'ABC', false)).toBe(true) + expect(fuzzyMatches(' ABC\n \t ', node, /^ABC/, true)).toBe(true) + expect(fuzzyMatches(' ABC\n \t ', node, /^ABC/, false)).toBe(false) }) diff --git a/src/__tests__/text-matchers.js b/src/__tests__/text-matchers.js index 36abc98a..8f4bb38e 100644 --- a/src/__tests__/text-matchers.js +++ b/src/__tests__/text-matchers.js @@ -9,6 +9,25 @@ cases( `) expect(getByText(opts.textMatch).id).toBe('anchor') }, + [ + {name: 'string match', textMatch: 'About'}, + {name: 'regex', textMatch: /^about$/i}, + { + name: 'function', + textMatch: (text, element) => + element.tagName === 'A' && text.includes('out'), + }, + ], +) + +cases( + 'fuzzy text matchers', + opts => { + const {getByText} = render(` + About + `) + expect(getByText(opts.textMatch, {exact: false}).id).toBe('anchor') + }, [ {name: 'string match', textMatch: 'About'}, {name: 'case insensitive', textMatch: 'about'}, diff --git a/src/matches.js b/src/matches.js index 96c8f2b3..8ef737f0 100644 --- a/src/matches.js +++ b/src/matches.js @@ -1,8 +1,10 @@ -function matches(textToMatch, node, matcher) { +function fuzzyMatches(textToMatch, node, matcher, collapseWhitespace = true) { if (typeof textToMatch !== 'string') { return false } - const normalizedText = textToMatch.trim().replace(/\s+/g, ' ') + const normalizedText = collapseWhitespace + ? textToMatch.trim().replace(/\s+/g, ' ') + : textToMatch if (typeof matcher === 'string') { return normalizedText.toLowerCase().includes(matcher.toLowerCase()) } else if (typeof matcher === 'function') { @@ -12,17 +14,20 @@ function matches(textToMatch, node, matcher) { } } -function matchesExact(textToMatch, node, matcher) { +function matches(textToMatch, node, matcher, collapseWhitespace = false) { if (typeof textToMatch !== 'string') { return false } + const normalizedText = collapseWhitespace + ? textToMatch.trim().replace(/\s+/g, ' ') + : textToMatch if (typeof matcher === 'string') { - return textToMatch === matcher + return normalizedText === matcher } else if (typeof matcher === 'function') { - return matcher(textToMatch, node) + return matcher(normalizedText, node) } else { - return matcher.test(textToMatch) + return matcher.test(normalizedText) } } -export {matches, matchesExact} +export {fuzzyMatches, matches} diff --git a/src/queries.js b/src/queries.js index 7e666549..bf55ce47 100644 --- a/src/queries.js +++ b/src/queries.js @@ -1,4 +1,4 @@ -import {matches, matchesExact} from './matches' +import {fuzzyMatches, matches} from './matches' import {getNodeText} from './get-node-text' import {prettyDOM} from './pretty-dom' @@ -16,14 +16,20 @@ function firstResultOrNull(queryFunction, ...args) { return result[0] } -function queryAllLabelsByText(container, text) { +function queryAllLabelsByText(container, text, {exact = true} = {}) { + const matcher = exact ? matches : fuzzyMatches + const COLLAPSE_WHITESPACE = true // a little more fuzzy than other queries return Array.from(container.querySelectorAll('label')).filter(label => - matches(label.textContent, label, text), + matcher(label.textContent, label, text, COLLAPSE_WHITESPACE), ) } -function queryAllByLabelText(container, text, {selector = '*'} = {}) { - const labels = queryAllLabelsByText(container, text) +function queryAllByLabelText( + container, + text, + {selector = '*', exact = true} = {}, +) { + const labels = queryAllLabelsByText(container, text, {exact}) const labelledElements = labels .map(label => { /* istanbul ignore if */ @@ -49,23 +55,25 @@ function queryAllByLabelText(container, text, {selector = '*'} = {}) { } }) .filter(label => label !== null) - .concat(queryAllByAttribute('aria-label', container, text)) + .concat(queryAllByAttribute('aria-label', container, text, {exact})) return labelledElements } -function queryByLabelText(container, text, opts) { - return firstResultOrNull(queryAllByLabelText, container, text, opts) +function queryByLabelText(...args) { + return firstResultOrNull(queryAllByLabelText, ...args) } -function queryAllByText(container, text, {selector = '*'} = {}) { +function queryAllByText(container, text, {selector = '*', exact = true} = {}) { + const matcher = exact ? matches : fuzzyMatches + const COLLAPSE_WHITESPACE = true // a little more fuzzy than other queries return Array.from(container.querySelectorAll(selector)).filter(node => - matches(getNodeText(node), node, text), + matcher(getNodeText(node), node, text, COLLAPSE_WHITESPACE), ) } -function queryByText(container, text, opts) { - return firstResultOrNull(queryAllByText, container, text, opts) +function queryByText(...args) { + return firstResultOrNull(queryAllByText, ...args) } const queryAllByTitle = (...args) => @@ -92,8 +100,8 @@ function getByTitle(...args) { // this is just a utility and not an exposed query. // There are no plans to expose this. -function queryAllByAttribute(attribute, container, text, {exact = false} = {}) { - const matcher = exact ? matchesExact : matches +function queryAllByAttribute(attribute, container, text, {exact = true} = {}) { + const matcher = exact ? matches : fuzzyMatches return Array.from(container.querySelectorAll(`[${attribute}]`)).filter(node => matcher(node.getAttribute(attribute), node, text), ) @@ -107,19 +115,18 @@ function queryByAttribute(...args) { const queryByPlaceholderText = queryByAttribute.bind(null, 'placeholder') const queryAllByPlaceholderText = queryAllByAttribute.bind(null, 'placeholder') -const queryByTestId = (...args) => - queryByAttribute('data-testid', ...args, {exact: true}) -const queryAllByTestId = (...args) => - queryAllByAttribute('data-testid', ...args, {exact: true}) +const queryByTestId = queryByAttribute.bind(null, 'data-testid') +const queryAllByTestId = queryAllByAttribute.bind(null, 'data-testid') -function queryAllByAltText(container, alt) { +function queryAllByAltText(container, alt, {exact = true} = {}) { + const matcher = exact ? matches : fuzzyMatches return Array.from(container.querySelectorAll('img,input,area')).filter(node => - matches(node.getAttribute('alt'), node, alt), + matcher(node.getAttribute('alt'), node, alt), ) } -function queryByAltText(container, alt) { - return firstResultOrNull(queryAllByAltText, container, alt) +function queryByAltText(...args) { + return firstResultOrNull(queryAllByAltText, ...args) } // getters @@ -162,7 +169,7 @@ function getByPlaceholderText(...args) { function getAllByLabelText(container, text, ...rest) { const els = queryAllByLabelText(container, text, ...rest) if (!els.length) { - const labels = queryAllLabelsByText(container, text) + const labels = queryAllLabelsByText(container, text, ...rest) if (labels.length) { throw new Error( `Found a label with the text of: ${text}, however no form control was found associated to that label. Make sure you're using the "for" attribute or "aria-labelledby" attribute correctly. \n\n${debugDOM( @@ -200,8 +207,8 @@ function getByText(...args) { return firstResultOrNull(getAllByText, ...args) } -function getAllByAltText(container, alt) { - const els = queryAllByAltText(container, alt) +function getAllByAltText(container, alt, ...rest) { + const els = queryAllByAltText(container, alt, ...rest) if (!els.length) { throw new Error( `Unable to find an element with the alt text: ${alt} \n\n${debugDOM( From 3bf940a55b56c3697f5baa5aef038d2cc5d9321a Mon Sep 17 00:00:00 2001 From: Alex Krolick Date: Fri, 4 May 2018 19:48:35 -0700 Subject: [PATCH 02/10] Update docs --- README.md | 98 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 55 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 57fe9766..70be65e7 100644 --- a/README.md +++ b/README.md @@ -72,19 +72,19 @@ when a real user uses it. * [Installation](#installation) * [Usage](#usage) - * [`getByLabelText(container: HTMLElement, text: TextMatch, options: {selector: string = '*'}): HTMLElement`](#getbylabeltextcontainer-htmlelement-text-textmatch-options-selector-string---htmlelement) - * [`getByPlaceholderText(container: HTMLElement, text: TextMatch): HTMLElement`](#getbyplaceholdertextcontainer-htmlelement-text-textmatch-htmlelement) - * [`getByText(container: HTMLElement, text: TextMatch): HTMLElement`](#getbytextcontainer-htmlelement-text-textmatch-htmlelement) - * [`getByAltText(container: HTMLElement, text: TextMatch): HTMLElement`](#getbyalttextcontainer-htmlelement-text-textmatch-htmlelement) - * [`getByTitle(container: HTMLElement, title: ExactTextMatch): HTMLElement`](#getbytitlecontainer-htmlelement-title-exacttextmatch-htmlelement) - * [`getByTestId(container: HTMLElement, text: ExactTextMatch): HTMLElement`](#getbytestidcontainer-htmlelement-text-exacttextmatch-htmlelement) + * [`getByLabelText(container: HTMLElement, text: TextMatch, options: {selector: string = '*', exact: boolean = true}): HTMLElement`](#getbylabeltextcontainer-htmlelement-text-textmatch-options-selector-string---exact-boolean--true-htmlelement) + * [`getByPlaceholderText(container: HTMLElement, text: TextMatch, {exact: boolean = true}): HTMLElement`](#getbyplaceholdertextcontainer-htmlelement-text-textmatch-exact-boolean--true-htmlelement) + * [`getByText(container: HTMLElement, text: TextMatch, {exact: boolean = true}): HTMLElement`](#getbytextcontainer-htmlelement-text-textmatch-exact-boolean--true-htmlelement) + * [`getByAltText(container: HTMLElement, text: TextMatch, {exact: boolean = true}): HTMLElement`](#getbyalttextcontainer-htmlelement-text-textmatch-exact-boolean--true-htmlelement) + * [`getByTestId(container: HTMLElement, text: ExactTextMatch, {exact: boolean = true}): HTMLElement`](#getbytestidcontainer-htmlelement-text-exacttextmatch-exact-boolean--true-htmlelement) * [`wait`](#wait) * [`waitForElement`](#waitforelement) * [`fireEvent(node: HTMLElement, event: Event)`](#fireeventnode-htmlelement-event-event) * [Custom Jest Matchers](#custom-jest-matchers) * [Using other assertion libraries](#using-other-assertion-libraries) * [`TextMatch`](#textmatch) - * [ExactTextMatch](#exacttextmatch) + * [Precision](#precision) + * [TextMatch Examples](#textmatch-examples) * [`query` APIs](#query-apis) * [`queryAll` and `getAll` APIs](#queryall-and-getall-apis) * [`bindElementToQueries`](#bindelementtoqueries) @@ -110,7 +110,10 @@ npm install --save-dev dom-testing-library ## Usage -Note: each of the `get` APIs below have a matching [`getAll`](#queryall-and-getall-apis) API that returns all elements instead of just the first one, and [`query`](#query-apis)/[`getAll`](#queryall-and-getall-apis) that return `null`/`[]` instead of throwing an error. +Note: + +* Each of the `get` APIs below have a matching [`getAll`](#queryall-and-getall-apis) API that returns all elements instead of just the first one, and [`query`](#query-apis)/[`getAll`](#queryall-and-getall-apis) that return `null`/`[]` instead of throwing an error. +* Setting `exact: false` in the final option argument of a `get` API causes the query to use fuzzy matching. See [TextMatch](#textmatch) for details. ```javascript // src/__tests__/example.js @@ -179,7 +182,7 @@ test('examples of some things', async () => { }) ``` -### `getByLabelText(container: HTMLElement, text: TextMatch, options: {selector: string = '*'}): HTMLElement` +### `getByLabelText(container: HTMLElement, text: TextMatch, options: {selector: string = '*', exact: boolean = true}): HTMLElement` This will search for the label that matches the given [`TextMatch`](#textmatch), then find the element associated with that label. @@ -214,7 +217,7 @@ const inputNode = getByLabelText(container, 'username', {selector: 'input'}) > want this behavior (for example you wish to assert that it doesn't exist), > then use `queryByLabelText` instead. -### `getByPlaceholderText(container: HTMLElement, text: TextMatch): HTMLElement` +### `getByPlaceholderText(container: HTMLElement, text: TextMatch, {exact: boolean = true}): HTMLElement` This will search for all elements with a placeholder attribute and find one that matches the given [`TextMatch`](#textmatch). @@ -227,7 +230,7 @@ const inputNode = getByPlaceholderText(container, 'Username') > NOTE: a placeholder is not a good substitute for a label so you should > generally use `getByLabelText` instead. -### `getByText(container: HTMLElement, text: TextMatch): HTMLElement` +### `getByText(container: HTMLElement, text: TextMatch, {exact: boolean = true}): HTMLElement` This will search for all elements that have a text node with `textContent` matching the given [`TextMatch`](#textmatch). @@ -237,7 +240,7 @@ matching the given [`TextMatch`](#textmatch). const aboutAnchorNode = getByText(container, 'about') ``` -### `getByAltText(container: HTMLElement, text: TextMatch): HTMLElement` +### `getByAltText(container: HTMLElement, text: TextMatch, {exact: boolean = true}): HTMLElement` This will return the element (normally an ``) that has the given `alt` text. Note that it only supports elements which accept an `alt` attribute: @@ -260,7 +263,7 @@ This will return the element that has the matching `title` attribute. const deleteElement = getByTitle(container, 'Delete') ``` -### `getByTestId(container: HTMLElement, text: ExactTextMatch): HTMLElement` +### `getByTestId(container: HTMLElement, text: ExactTextMatch, {exact: boolean = true}): HTMLElement` A shortcut to `` container.querySelector(`[data-testid="${yourId}"]`) `` (and it also accepts an [`ExactTextMatch`](#exacttextmatch)). @@ -469,43 +472,52 @@ and add it here! Several APIs accept a `TextMatch` which can be a `string`, `regex` or a `function` which returns `true` for a match and `false` for a mismatch. -Here's an example +### Precision + +Queries that search inner tag content (i.e., `queryByLabelText`, +`queryByText`) collapse and trim whitespace (newlines, spaces, tabs); +queries using attributes do not. + +Some APIs accept an object as the final argument that can contain options that +affect the precision of string matching: + +* `exact`: Defaults to `true`; matches full strings, case-sensitive. When false, + matches substrings and is not case-sensitive. + * `exact` has no effect on `regex` or `function` arguments. + * In most cases using a regex instead of a string gives you more control over + fuzzy matching and should be preferred over `{ exact: false }`. + +### TextMatch Examples ```javascript -//
Hello World
-// all of the following will find the div -getByText(container, 'Hello World') // full match -getByText(container, 'llo worl') // substring match -getByText(container, 'hello world') // strings ignore case -getByText(container, /Hello W?oRlD/i) // regex -getByText(container, (content, element) => content.startsWith('Hello')) // function - -// all of the following will NOT find the div -getByText(container, 'Goodbye World') // non-string match -getByText(container, /hello world/) // case-sensitive regex with different case -// function looking for a span when it's actually a div -getByText(container, (content, element) => { - return element.tagName.toLowerCase() === 'span' && content.startsWith('Hello') -}) -``` +//
+// Hello World +//
+ +// WILL find the div: -### ExactTextMatch +// Matching a string: +getByText(container, 'Hello World') // full string match +getByText(container, 'llo Worl'), {exact: false} // substring match +getByText(container, 'hello world', {exact: false}) // ignore case -Some APIs use ExactTextMatch, which is the same as TextMatch but case-sensitive -and does not match substrings; however, regexes and functions are also accepted -for custom matching. +// Matching a regex: +getByText(container, /World/) // substring match +getByText(container, /world/i) // substring match, ignore case +getByText(container, /^hello world$/i) // full string match, ignore case +getByText(container, /Hello W?oRlD/i) // advanced regex -```js -// +// Matching with a custom function: +getByText(container, (content, element) => content.startsWith('Hello')) -// all of the following will find the button -getByTestId(container, 'submit-button') // exact match -getByTestId(container, /submit*/) // regex match -getByTestId(container, content => content.startsWith('submit')) // function +// WILL NOT find the div: -// all of the following will NOT find the button -getByTestId(container, 'submit-') // no substrings -getByTestId(container, 'Submit-Button') // case-sensitive +getByText(container, 'Goodbye World') // full string does not match +getByText(container, /hello world/) // case-sensitive regex with different case +// function looking for a span when it's actually a div: +getByText(container, (content, element) => { + return element.tagName.toLowerCase() === 'span' && content.startsWith('Hello') +}) ``` ## `query` APIs From 489e089235205c1fe3258b87bf31693112580eb8 Mon Sep 17 00:00:00 2001 From: Alex Krolick Date: Fri, 4 May 2018 21:54:45 -0700 Subject: [PATCH 03/10] chore: remove type definitions from headings --- README.md | 101 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 82 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 70be65e7..3838f9fc 100644 --- a/README.md +++ b/README.md @@ -72,14 +72,14 @@ when a real user uses it. * [Installation](#installation) * [Usage](#usage) - * [`getByLabelText(container: HTMLElement, text: TextMatch, options: {selector: string = '*', exact: boolean = true}): HTMLElement`](#getbylabeltextcontainer-htmlelement-text-textmatch-options-selector-string---exact-boolean--true-htmlelement) - * [`getByPlaceholderText(container: HTMLElement, text: TextMatch, {exact: boolean = true}): HTMLElement`](#getbyplaceholdertextcontainer-htmlelement-text-textmatch-exact-boolean--true-htmlelement) - * [`getByText(container: HTMLElement, text: TextMatch, {exact: boolean = true}): HTMLElement`](#getbytextcontainer-htmlelement-text-textmatch-exact-boolean--true-htmlelement) - * [`getByAltText(container: HTMLElement, text: TextMatch, {exact: boolean = true}): HTMLElement`](#getbyalttextcontainer-htmlelement-text-textmatch-exact-boolean--true-htmlelement) - * [`getByTestId(container: HTMLElement, text: ExactTextMatch, {exact: boolean = true}): HTMLElement`](#getbytestidcontainer-htmlelement-text-exacttextmatch-exact-boolean--true-htmlelement) + * [`getByLabelText`](#getbylabeltext) + * [`getByPlaceholderText`](#getbyplaceholdertext) + * [`getByText`](#getbytext) + * [`getByAltText`](#getbyalttext) + * [`getByTestId`](#getbytestid) * [`wait`](#wait) * [`waitForElement`](#waitforelement) - * [`fireEvent(node: HTMLElement, event: Event)`](#fireeventnode-htmlelement-event-event) + * [`fireEvent`](#fireevent) * [Custom Jest Matchers](#custom-jest-matchers) * [Using other assertion libraries](#using-other-assertion-libraries) * [`TextMatch`](#textmatch) @@ -182,7 +182,17 @@ test('examples of some things', async () => { }) ``` -### `getByLabelText(container: HTMLElement, text: TextMatch, options: {selector: string = '*', exact: boolean = true}): HTMLElement` +### `getByLabelText` + +```typescript +getByLabelText( + container: HTMLElement, + text: TextMatch, + options?: { + selector?: string = '*', + exact?: boolean = true, + }): HTMLElement +``` This will search for the label that matches the given [`TextMatch`](#textmatch), then find the element associated with that label. @@ -217,7 +227,16 @@ const inputNode = getByLabelText(container, 'username', {selector: 'input'}) > want this behavior (for example you wish to assert that it doesn't exist), > then use `queryByLabelText` instead. -### `getByPlaceholderText(container: HTMLElement, text: TextMatch, {exact: boolean = true}): HTMLElement` +### `getByPlaceholderText` + +```typescript +getByPlaceholderText( + container: HTMLElement, + text: TextMatch, + options?: { + exact?: boolean = true, + }): HTMLElement +``` This will search for all elements with a placeholder attribute and find one that matches the given [`TextMatch`](#textmatch). @@ -230,7 +249,16 @@ const inputNode = getByPlaceholderText(container, 'Username') > NOTE: a placeholder is not a good substitute for a label so you should > generally use `getByLabelText` instead. -### `getByText(container: HTMLElement, text: TextMatch, {exact: boolean = true}): HTMLElement` +### `getByText` + +```typescript +getByText( + container: HTMLElement, + text: TextMatch, + options?: { + exact?: boolean = true, + }): HTMLElement +``` This will search for all elements that have a text node with `textContent` matching the given [`TextMatch`](#textmatch). @@ -240,7 +268,16 @@ matching the given [`TextMatch`](#textmatch). const aboutAnchorNode = getByText(container, 'about') ``` -### `getByAltText(container: HTMLElement, text: TextMatch, {exact: boolean = true}): HTMLElement` +### `getByAltText` + +```typescript +getByAltText( + container: HTMLElement, + text: TextMatch, + options?: { + exact?: boolean = true, + }): HTMLElement +``` This will return the element (normally an ``) that has the given `alt` text. Note that it only supports elements which accept an `alt` attribute: @@ -254,7 +291,16 @@ and [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/area) const incrediblesPosterImg = getByAltText(container, /incredibles.*poster$/i) ``` -### `getByTitle(container: HTMLElement, title: ExactTextMatch): HTMLElement` +### `getByTitle` + +```typescript +getByTitle( + container: HTMLElement, + title: TextMatch, + options?: { + exact?: boolean = true, + }): HTMLElement +``` This will return the element that has the matching `title` attribute. @@ -263,7 +309,16 @@ This will return the element that has the matching `title` attribute. const deleteElement = getByTitle(container, 'Delete') ``` -### `getByTestId(container: HTMLElement, text: ExactTextMatch, {exact: boolean = true}): HTMLElement` +### `getByTestId` + +```typescript +getByTestId( + container: HTMLElement, + text: ExactTextMatch, + options?: { + exact?: boolean = true, + }): HTMLElement` +``` A shortcut to `` container.querySelector(`[data-testid="${yourId}"]`) `` (and it also accepts an [`ExactTextMatch`](#exacttextmatch)). @@ -283,8 +338,6 @@ const usernameInputElement = getByTestId(container, 'username-input') ### `wait` -Defined as: - ```typescript function wait( callback?: () => void, @@ -326,8 +379,6 @@ intervals. ### `waitForElement` -Defined as: - ```typescript function waitForElement( callback?: () => T | null | undefined, @@ -386,7 +437,11 @@ The default `timeout` is `4500ms` which will keep you under additions and removals of child elements (including text nodes) in the `container` and any of its descendants. It won't detect attribute changes unless you add `attributes: true` to the options. -### `fireEvent(node: HTMLElement, event: Event)` +### `fireEvent` + +```typescript +fireEvent(node: HTMLElement, event: Event) +``` Fire DOM events. @@ -401,7 +456,11 @@ fireEvent( ) ``` -#### `fireEvent[eventName](node: HTMLElement, eventProperties: Object)` +#### `fireEvent[eventName]` + +```typescript +fireEvent[eventName](node: HTMLElement, eventProperties: Object) +``` Convenience methods for firing DOM events. Check out [src/events.js](https://github.com/kentcdodds/dom-testing-library/blob/master/src/events.js) @@ -414,7 +473,11 @@ fireEvent.click(getElementByText('Submit'), rightClick) // default `button` property for click events is set to `0` which is a left click. ``` -#### `getNodeText(node: HTMLElement)` +#### `getNodeText` + +```typescript +getNodeText(node: HTMLElement) +``` Returns the complete text content of a html element, removing any extra whitespace. The intention is to treat text in nodes exactly as how it is From cb397161db92227d8cdb34174899d1184524bec3 Mon Sep 17 00:00:00 2001 From: Alex Krolick Date: Fri, 4 May 2018 22:15:25 -0700 Subject: [PATCH 04/10] Update refs to textmatch --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3838f9fc..8381765a 100644 --- a/README.md +++ b/README.md @@ -314,14 +314,14 @@ const deleteElement = getByTitle(container, 'Delete') ```typescript getByTestId( container: HTMLElement, - text: ExactTextMatch, + text: TextMatch, options?: { exact?: boolean = true, }): HTMLElement` ``` A shortcut to `` container.querySelector(`[data-testid="${yourId}"]`) `` (and it -also accepts an [`ExactTextMatch`](#exacttextmatch)). +also accepts a [`TextMatch`](#textmatch)). ```javascript // From 536478b89e08b1269287b4ad8ec18da22da11b0c Mon Sep 17 00:00:00 2001 From: Alex Krolick Date: Sat, 5 May 2018 11:06:03 -0700 Subject: [PATCH 05/10] Update getByTitle docs --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8381765a..aad58879 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ when a real user uses it. * [`getByPlaceholderText`](#getbyplaceholdertext) * [`getByText`](#getbytext) * [`getByAltText`](#getbyalttext) + * [`getByTitle`](#getbytitle) * [`getByTestId`](#getbytestid) * [`wait`](#wait) * [`waitForElement`](#waitforelement) @@ -302,7 +303,7 @@ getByTitle( }): HTMLElement ``` -This will return the element that has the matching `title` attribute. +Returns the element that has the matching `title` attribute. ```javascript // From 5ae4e1439b20d6b4adcdf8aaaf2b636851e26d5c Mon Sep 17 00:00:00 2001 From: Alex Krolick Date: Sat, 5 May 2018 11:19:48 -0700 Subject: [PATCH 06/10] Expose options arg on getTitle --- src/queries.js | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/src/queries.js b/src/queries.js index bf55ce47..13540da6 100644 --- a/src/queries.js +++ b/src/queries.js @@ -76,28 +76,6 @@ function queryByText(...args) { return firstResultOrNull(queryAllByText, ...args) } -const queryAllByTitle = (...args) => - queryAllByAttribute('title', ...args, {exact: true}) - -const queryByTitle = (...args) => - queryByAttribute('title', ...args, {exact: true}) - -function getAllByTitle(container, title, ...rest) { - const els = queryAllByTitle(container, title, ...rest) - if (!els.length) { - throw new Error( - `Unable to find an element with the title: ${title}. \n\n${debugDOM( - container, - )}`, - ) - } - return els -} - -function getByTitle(...args) { - return firstResultOrNull(getAllByTitle, ...args) -} - // this is just a utility and not an exposed query. // There are no plans to expose this. function queryAllByAttribute(attribute, container, text, {exact = true} = {}) { @@ -117,6 +95,8 @@ const queryByPlaceholderText = queryByAttribute.bind(null, 'placeholder') const queryAllByPlaceholderText = queryAllByAttribute.bind(null, 'placeholder') const queryByTestId = queryByAttribute.bind(null, 'data-testid') const queryAllByTestId = queryAllByAttribute.bind(null, 'data-testid') +const queryByTitle = queryByAttribute.bind(null, 'title') +const queryAllByTitle = queryAllByAttribute.bind(null, 'title') function queryAllByAltText(container, alt, {exact = true} = {}) { const matcher = exact ? matches : fuzzyMatches @@ -150,6 +130,22 @@ function getByTestId(...args) { return firstResultOrNull(getAllByTestId, ...args) } +function getAllByTitle(container, title, ...rest) { + const els = queryAllByTitle(container, title, ...rest) + if (!els.length) { + throw new Error( + `Unable to find an element with the title: ${title}. \n\n${debugDOM( + container, + )}`, + ) + } + return els +} + +function getByTitle(...args) { + return firstResultOrNull(getAllByTitle, ...args) +} + function getAllByPlaceholderText(container, text, ...rest) { const els = queryAllByPlaceholderText(container, text, ...rest) if (!els.length) { From 91361a6a2b21bc7549b41fa190b91a2fdb238985 Mon Sep 17 00:00:00 2001 From: Alex Krolick Date: Sat, 5 May 2018 12:37:38 -0700 Subject: [PATCH 07/10] Expose trim, collapseWhitespace options --- README.md | 20 ++++++++++---- src/__tests__/matches.js | 59 ++++++++++++++++++++++++++++++++-------- src/matches.js | 31 +++++++++++++++------ src/queries.js | 44 ++++++++++++++++++++++-------- 4 files changed, 117 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index aad58879..ae150e7a 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ npm install --save-dev dom-testing-library Note: * Each of the `get` APIs below have a matching [`getAll`](#queryall-and-getall-apis) API that returns all elements instead of just the first one, and [`query`](#query-apis)/[`getAll`](#queryall-and-getall-apis) that return `null`/`[]` instead of throwing an error. -* Setting `exact: false` in the final option argument of a `get` API causes the query to use fuzzy matching. See [TextMatch](#textmatch) for details. +* See [TextMatch](#textmatch) for details on the `exact`, `trim`, and `collapseWhitespace` options. ```javascript // src/__tests__/example.js @@ -192,6 +192,8 @@ getByLabelText( options?: { selector?: string = '*', exact?: boolean = true, + collapseWhitespace?: boolean = true, + trim?: boolean = true, }): HTMLElement ``` @@ -236,6 +238,8 @@ getByPlaceholderText( text: TextMatch, options?: { exact?: boolean = true, + collapseWhitespace?: boolean = false, + trim?: boolean = true, }): HTMLElement ``` @@ -258,6 +262,8 @@ getByText( text: TextMatch, options?: { exact?: boolean = true, + collapseWhitespace?: boolean = true, + trim?: boolean = true, }): HTMLElement ``` @@ -277,6 +283,8 @@ getByAltText( text: TextMatch, options?: { exact?: boolean = true, + collapseWhitespace?: boolean = false, + trim?: boolean = true, }): HTMLElement ``` @@ -300,6 +308,8 @@ getByTitle( title: TextMatch, options?: { exact?: boolean = true, + collapseWhitespace?: boolean = false, + trim?: boolean = true, }): HTMLElement ``` @@ -318,6 +328,8 @@ getByTestId( text: TextMatch, options?: { exact?: boolean = true, + collapseWhitespace?: boolean = false, + trim?: boolean = true, }): HTMLElement` ``` @@ -538,10 +550,6 @@ Several APIs accept a `TextMatch` which can be a `string`, `regex` or a ### Precision -Queries that search inner tag content (i.e., `queryByLabelText`, -`queryByText`) collapse and trim whitespace (newlines, spaces, tabs); -queries using attributes do not. - Some APIs accept an object as the final argument that can contain options that affect the precision of string matching: @@ -550,6 +558,8 @@ affect the precision of string matching: * `exact` has no effect on `regex` or `function` arguments. * In most cases using a regex instead of a string gives you more control over fuzzy matching and should be preferred over `{ exact: false }`. +* `trim`: Defaults to `true`; trim leading and trailing whitespace. +* `collapseWhitespace`: Defaults to `false` for attribute queries and `true` for content queries (i.e., `queryByLabelText`, `queryByText`). Collapses inner whitespace (newlines, tabs, repeated spaces) into a single space. ### TextMatch Examples diff --git a/src/__tests__/matches.js b/src/__tests__/matches.js index aad577f3..be08fb9a 100644 --- a/src/__tests__/matches.js +++ b/src/__tests__/matches.js @@ -4,16 +4,20 @@ import {fuzzyMatches, matches} from '../' const node = null -test('matches should get fuzzy matches', () => { +test('fuzzyMatches should get fuzzy matches', () => { // should not match expect(matches(null, node, 'abc')).toBe(false) expect(matches('', node, 'abc')).toBe(false) + expect(matches('ABC', node, 'ABCD')).toBe(false) // should match - expect(fuzzyMatches('ABC', node, 'abc')).toBe(true) - expect(fuzzyMatches('ABC', node, 'ABC')).toBe(true) + expect(fuzzyMatches('AB C', node, 'ab c')).toBe(true) + expect(fuzzyMatches('AB C', node, 'AB C')).toBe(true) + expect(fuzzyMatches('ABC', node, 'A')).toBe(true) + expect(fuzzyMatches('\nAB C\t', node, 'AB C')).toBe(true) + expect(fuzzyMatches('\nAB\nC\t', node, 'AB C')).toBe(true) }) -test('matchesExact should only get exact matches', () => { +test('matches should only get exact matches', () => { // should not match expect(matches(null, node, null)).toBe(false) expect(matches(null, node, 'abc')).toBe(false) @@ -25,12 +29,43 @@ test('matchesExact should only get exact matches', () => { expect(matches('ABC', node, 'ABC')).toBe(true) }) -test('matchers should collapse whitespace if requested', () => { - // should match - expect(matches('ABC\n \t', node, 'ABC', true)).toBe(true) - expect(matches('ABC\n \t', node, 'ABC', false)).toBe(false) - expect(fuzzyMatches('ABC\n \t', node, 'ABC', true)).toBe(true) - expect(fuzzyMatches(' ABC\n \t ', node, 'ABC', false)).toBe(true) - expect(fuzzyMatches(' ABC\n \t ', node, /^ABC/, true)).toBe(true) - expect(fuzzyMatches(' ABC\n \t ', node, /^ABC/, false)).toBe(false) +test('should trim strings of surrounding whitespace by default', () => { + expect(matches(' ABC ', node, /^ABC/)).toBe(true) + expect(fuzzyMatches(' ABC ', node, /^ABC/)).toBe(true) +}) + +test('collapseWhitespace option should be toggleable', () => { + const yes = { + collapseWhitespace: true, + } + const no = { + collapseWhitespace: false, + } + + expect(matches('AB\n \t C', node, 'ABC')).toBe(false) // default + expect(fuzzyMatches('AB\n \t C', node, 'AB C')).toBe(true) // default + + expect(matches('AB\n \t C', node, 'AB C', yes)).toBe(true) + expect(fuzzyMatches('AB\n \t C', node, 'AB C', yes)).toBe(true) + + expect(matches('AB\n \t C', node, 'AB C', no)).toBe(false) + expect(fuzzyMatches('AB\n \t C', node, 'AB C', no)).toBe(false) +}) + +test('trim option should be toggleable', () => { + const yes = { + trim: true, + } + const no = { + trim: false, + } + + expect(matches(' ABC \n\t', node, /^ABC$/)).toBe(true) // default + expect(fuzzyMatches(' ABC \n\t', node, /^ABC$/)).toBe(true) // default + + expect(matches(' ABC \n\t', node, /^ABC$/, yes)).toBe(true) + expect(fuzzyMatches(' ABC \n\t', node, /^ABC$/, yes)).toBe(true) + + expect(matches(' ABC \n\t', node, /^ABC$/, no)).toBe(false) + expect(fuzzyMatches(' ABC \n\t', node, /^ABC$/, no)).toBe(false) }) diff --git a/src/matches.js b/src/matches.js index 8ef737f0..bb91717c 100644 --- a/src/matches.js +++ b/src/matches.js @@ -1,10 +1,13 @@ -function fuzzyMatches(textToMatch, node, matcher, collapseWhitespace = true) { +function fuzzyMatches( + textToMatch, + node, + matcher, + {collapseWhitespace = true, trim = true} = {}, +) { if (typeof textToMatch !== 'string') { return false } - const normalizedText = collapseWhitespace - ? textToMatch.trim().replace(/\s+/g, ' ') - : textToMatch + const normalizedText = normalize(textToMatch, {trim, collapseWhitespace}) if (typeof matcher === 'string') { return normalizedText.toLowerCase().includes(matcher.toLowerCase()) } else if (typeof matcher === 'function') { @@ -14,13 +17,16 @@ function fuzzyMatches(textToMatch, node, matcher, collapseWhitespace = true) { } } -function matches(textToMatch, node, matcher, collapseWhitespace = false) { +function matches( + textToMatch, + node, + matcher, + {collapseWhitespace = false, trim = true} = {}, +) { if (typeof textToMatch !== 'string') { return false } - const normalizedText = collapseWhitespace - ? textToMatch.trim().replace(/\s+/g, ' ') - : textToMatch + const normalizedText = normalize(textToMatch, {trim, collapseWhitespace}) if (typeof matcher === 'string') { return normalizedText === matcher } else if (typeof matcher === 'function') { @@ -30,4 +36,13 @@ function matches(textToMatch, node, matcher, collapseWhitespace = false) { } } +function normalize(text, {trim, collapseWhitespace}) { + let normalizedText = text + normalizedText = trim ? normalizedText.trim() : normalizedText + normalizedText = collapseWhitespace + ? normalizedText.replace(/\s+/g, ' ') + : normalizedText + return normalizedText +} + export {fuzzyMatches, matches} diff --git a/src/queries.js b/src/queries.js index 13540da6..11086e4b 100644 --- a/src/queries.js +++ b/src/queries.js @@ -16,20 +16,25 @@ function firstResultOrNull(queryFunction, ...args) { return result[0] } -function queryAllLabelsByText(container, text, {exact = true} = {}) { +function queryAllLabelsByText( + container, + text, + {exact = true, trim = true, collapseWhitespace = true} = {}, +) { const matcher = exact ? matches : fuzzyMatches - const COLLAPSE_WHITESPACE = true // a little more fuzzy than other queries + const matchOpts = {collapseWhitespace, trim} return Array.from(container.querySelectorAll('label')).filter(label => - matcher(label.textContent, label, text, COLLAPSE_WHITESPACE), + matcher(label.textContent, label, text, matchOpts), ) } function queryAllByLabelText( container, text, - {selector = '*', exact = true} = {}, + {selector = '*', exact = true, collapseWhitespace = true, trim = true} = {}, ) { - const labels = queryAllLabelsByText(container, text, {exact}) + const matchOpts = {collapseWhitespace, trim} + const labels = queryAllLabelsByText(container, text, {exact, ...matchOpts}) const labelledElements = labels .map(label => { /* istanbul ignore if */ @@ -64,11 +69,15 @@ function queryByLabelText(...args) { return firstResultOrNull(queryAllByLabelText, ...args) } -function queryAllByText(container, text, {selector = '*', exact = true} = {}) { +function queryAllByText( + container, + text, + {selector = '*', exact = true, collapseWhitespace = true, trim = true} = {}, +) { const matcher = exact ? matches : fuzzyMatches - const COLLAPSE_WHITESPACE = true // a little more fuzzy than other queries + const matchOpts = {collapseWhitespace, trim} return Array.from(container.querySelectorAll(selector)).filter(node => - matcher(getNodeText(node), node, text, COLLAPSE_WHITESPACE), + matcher(getNodeText(node), node, text, matchOpts), ) } @@ -78,10 +87,16 @@ function queryByText(...args) { // this is just a utility and not an exposed query. // There are no plans to expose this. -function queryAllByAttribute(attribute, container, text, {exact = true} = {}) { +function queryAllByAttribute( + attribute, + container, + text, + {exact = true, collapseWhitespace = false, trim = true} = {}, +) { const matcher = exact ? matches : fuzzyMatches + const matchOpts = {collapseWhitespace, trim} return Array.from(container.querySelectorAll(`[${attribute}]`)).filter(node => - matcher(node.getAttribute(attribute), node, text), + matcher(node.getAttribute(attribute), node, text, matchOpts), ) } @@ -98,10 +113,15 @@ const queryAllByTestId = queryAllByAttribute.bind(null, 'data-testid') const queryByTitle = queryByAttribute.bind(null, 'title') const queryAllByTitle = queryAllByAttribute.bind(null, 'title') -function queryAllByAltText(container, alt, {exact = true} = {}) { +function queryAllByAltText( + container, + alt, + {exact = true, collapseWhitespace = false, trim = true} = {}, +) { const matcher = exact ? matches : fuzzyMatches + const matchOpts = {collapseWhitespace, trim} return Array.from(container.querySelectorAll('img,input,area')).filter(node => - matcher(node.getAttribute('alt'), node, alt), + matcher(node.getAttribute('alt'), node, alt, matchOpts), ) } From a825d12fe3770b787f25f398810bb827cf963eee Mon Sep 17 00:00:00 2001 From: Alex Krolick Date: Sat, 5 May 2018 14:12:49 -0700 Subject: [PATCH 08/10] Use jest-in-case for testing matchers --- src/__tests__/element-queries.js | 108 ----------------- src/__tests__/matches.js | 16 ++- src/__tests__/text-matchers.js | 194 +++++++++++++++++++++++++------ src/get-node-text.js | 2 - 4 files changed, 174 insertions(+), 146 deletions(-) diff --git a/src/__tests__/element-queries.js b/src/__tests__/element-queries.js index 3edf8506..9d30029d 100644 --- a/src/__tests__/element-queries.js +++ b/src/__tests__/element-queries.js @@ -144,114 +144,6 @@ test('can get elements by data-testid attribute', () => { expect(queryByTestId('first-name')).not.toBeInTheDOM() }) -test('queries passed strings match case-sensitive exact strings', () => { - const { - queryAllByTestId, - queryAllByAltText, - queryAllByText, - queryAllByLabelText, - queryAllByPlaceholderText, - } = render(` -
- Finding Nemo poster - Finding Dory poster - jumanji poster -

Where to next?

-

- content - with - linebreaks - is - ok -

- - -
, - `) - expect(queryAllByAltText('Finding Nemo poster')).toHaveLength(1) - expect(queryAllByAltText('Finding')).toHaveLength(0) - expect(queryAllByAltText('finding nemo poster')).toHaveLength(0) - expect(queryAllByTestId('poster')).toHaveLength(3) - expect(queryAllByTestId('Poster')).toHaveLength(0) - expect(queryAllByTestId('post')).toHaveLength(0) - expect(queryAllByPlaceholderText("Dwayne 'The Rock' Johnson")).toHaveLength(1) - expect(queryAllByPlaceholderText('The Rock')).toHaveLength(0) - expect(queryAllByPlaceholderText("dwayne 'the rock' johnson")).toHaveLength(0) - expect(queryAllByLabelText('User Name')).toHaveLength(1) - expect(queryAllByLabelText('user name')).toHaveLength(0) - expect(queryAllByLabelText('User')).toHaveLength(0) - expect(queryAllByText('Where to next?')).toHaveLength(1) - expect(queryAllByText('Where to next')).toHaveLength(0) - expect(queryAllByText('Where')).toHaveLength(0) - expect(queryAllByText('where to next?')).toHaveLength(0) - expect(queryAllByText('content with linebreaks is ok')).toHaveLength(1) -}) - -test('passing {exact: false} uses fuzzy matches', () => { - const fuzzy = Object.freeze({exact: false}) - const { - queryAllByTestId, - queryAllByAltText, - queryAllByText, - queryAllByLabelText, - queryAllByPlaceholderText, - } = render(` -
- Finding Nemo poster - Finding Dory poster - jumanji poster -

Where to next?

-

- content - with - linebreaks -

- - -
, - `) - expect(queryAllByAltText('Finding Nemo poster', fuzzy)).toHaveLength(1) - expect(queryAllByAltText('Finding', fuzzy)).toHaveLength(2) - expect(queryAllByAltText('finding nemo poster', fuzzy)).toHaveLength(1) - expect(queryAllByTestId('poster', fuzzy)).toHaveLength(3) - expect(queryAllByTestId('Poster', fuzzy)).toHaveLength(3) - expect(queryAllByTestId('post', fuzzy)).toHaveLength(3) - expect( - queryAllByPlaceholderText("Dwayne 'The Rock' Johnson", fuzzy), - ).toHaveLength(1) - expect(queryAllByPlaceholderText('The Rock', fuzzy)).toHaveLength(1) - expect( - queryAllByPlaceholderText("dwayne 'the rock' johnson", fuzzy), - ).toHaveLength(1) - expect(queryAllByLabelText('User Name', fuzzy)).toHaveLength(1) - expect(queryAllByLabelText('user name', fuzzy)).toHaveLength(1) - expect(queryAllByLabelText('user', fuzzy)).toHaveLength(1) - expect(queryAllByLabelText('User', fuzzy)).toHaveLength(1) - expect(queryAllByText('Where to next?', fuzzy)).toHaveLength(1) - expect(queryAllByText('Where to next', fuzzy)).toHaveLength(1) - expect(queryAllByText('Where', fuzzy)).toHaveLength(1) - expect(queryAllByText('where to next?', fuzzy)).toHaveLength(1) - expect(queryAllByText('content with linebreaks', fuzzy)).toHaveLength(1) -}) - test('getAll* matchers return an array', () => { const { getAllByAltText, diff --git a/src/__tests__/matches.js b/src/__tests__/matches.js index be08fb9a..7c0b16c2 100644 --- a/src/__tests__/matches.js +++ b/src/__tests__/matches.js @@ -4,11 +4,21 @@ import {fuzzyMatches, matches} from '../' const node = null +test('matches should accept regex', () => { + expect(matches('ABC', node, /ABC/)).toBe(true) + expect(fuzzyMatches('ABC', node, /ABC/)).toBe(true) +}) + +test('matches should accept functions', () => { + expect(matches('ABC', node, text => text === 'ABC')).toBe(true) + expect(fuzzyMatches('ABC', node, text => text === 'ABC')).toBe(true) +}) + test('fuzzyMatches should get fuzzy matches', () => { // should not match - expect(matches(null, node, 'abc')).toBe(false) - expect(matches('', node, 'abc')).toBe(false) - expect(matches('ABC', node, 'ABCD')).toBe(false) + expect(fuzzyMatches(null, node, 'abc')).toBe(false) + expect(fuzzyMatches('', node, 'abc')).toBe(false) + expect(fuzzyMatches('ABC', node, 'ABCD')).toBe(false) // should match expect(fuzzyMatches('AB C', node, 'ab c')).toBe(true) expect(fuzzyMatches('AB C', node, 'AB C')).toBe(true) diff --git a/src/__tests__/text-matchers.js b/src/__tests__/text-matchers.js index 8f4bb38e..05acfbf6 100644 --- a/src/__tests__/text-matchers.js +++ b/src/__tests__/text-matchers.js @@ -1,41 +1,169 @@ +import 'jest-dom/extend-expect' import cases from 'jest-in-case' import {render} from './helpers/test-utils' cases( - 'text matchers', - opts => { - const {getByText} = render(` - About - `) - expect(getByText(opts.textMatch).id).toBe('anchor') - }, - [ - {name: 'string match', textMatch: 'About'}, - {name: 'regex', textMatch: /^about$/i}, - { - name: 'function', - textMatch: (text, element) => - element.tagName === 'A' && text.includes('out'), - }, - ], + 'matches find case-sensitive full strings by default', + ({dom, query, queryFn}) => { + const queries = render(dom) + expect(queries[queryFn](query)).toHaveLength(1) + expect(queries[queryFn](query.toUpperCase())).toHaveLength(0) // case + expect(queries[queryFn](query.slice(1))).toHaveLength(0) // substring + }, + { + queryAllByTestId: { + dom: `Link`, + query: `link`, + queryFn: `queryAllByTestId`, + }, + queryAllByAltText: { + dom: ` + Finding Nemo poster`, + query: `Finding Nemo poster`, + queryFn: `queryAllByAltText`, + }, + queryAllByPlaceholderText: { + dom: ``, + query: `Dwayne 'The Rock' Johnson`, + queryFn: `queryAllByPlaceholderText`, + }, + queryAllByText: { + dom: ` +

+ Content + with + linebreaks + is + ok +

`, + query: `Content with linebreaks is ok`, + queryFn: `queryAllByText`, + }, + queryAllByLabelText: { + dom: ` + + `, + query: `User Name`, + queryFn: `queryAllByLabelText`, + }, + }, +) + +cases( + 'attribute queries trim leading & trailing whitespace by default', + ({dom, query, queryFn}) => { + const queries = render(dom) + expect(queries[queryFn](query)).toHaveLength(1) + expect(queries[queryFn](query, {trim: false})).toHaveLength(0) + }, + { + queryAllByTestId: { + dom: `Link`, + query: /^link$/, + queryFn: `queryAllByTestId`, + }, + queryAllByAltText: { + dom: ` + 
+            Finding Nemo poster `, + query: /^Finding Nemo poster$/, + queryFn: `queryAllByAltText`, + }, + queryAllByPlaceholderText: { + dom: ` + `, + query: /^Dwayne/, + queryFn: `queryAllByPlaceholderText`, + }, + }, +) + +cases( + 'content queries trim leading, trailing & inner whitespace by default', + ({dom, query, queryFn}) => { + const queries = render(dom) + expect(queries[queryFn](query)).toHaveLength(1) + expect( + queries[queryFn](query, {collapseWhitespace: false, trim: false}), + ).toHaveLength(0) + }, + { + queryAllByText: { + dom: ` +

+ Content + with + linebreaks + is + ok +

`, + query: `Content with linebreaks is ok`, + queryFn: `queryAllByText`, + }, + queryAllByLabelText: { + dom: ` + + `, + query: `User Name`, + queryFn: `queryAllByLabelText`, + }, + }, ) cases( - 'fuzzy text matchers', - opts => { - const {getByText} = render(` - About - `) - expect(getByText(opts.textMatch, {exact: false}).id).toBe('anchor') - }, - [ - {name: 'string match', textMatch: 'About'}, - {name: 'case insensitive', textMatch: 'about'}, - {name: 'regex', textMatch: /^about$/i}, - { - name: 'function', - textMatch: (text, element) => - element.tagName === 'A' && text.includes('out'), - }, - ], + '{ exact } option toggles case-insensitive partial matches', + ({dom, query, queryFn}) => { + const queries = render(dom) + expect(queries[queryFn](query)).toHaveLength(1) + expect(queries[queryFn](query.split(' ')[0], {exact: false})).toHaveLength( + 1, + ) + expect(queries[queryFn](query.toLowerCase(), {exact: false})).toHaveLength( + 1, + ) + }, + { + queryAllByPlaceholderText: { + dom: ``, + query: `Dwayne 'The Rock' Johnson`, + queryFn: `queryAllByPlaceholderText`, + }, + queryAllByLabelText: { + dom: ` + + `, + query: `User Name`, + queryFn: `queryAllByLabelText`, + }, + queryAllByText: { + dom: ` +

+ Content + with + linebreaks + is + ok +

`, + query: `Content with linebreaks is ok`, + queryFn: `queryAllByText`, + }, + queryAllByAltText: { + dom: ` + Finding Nemo poster`, + query: `Finding Nemo poster`, + queryFn: `queryAllByAltText`, + }, + }, ) diff --git a/src/get-node-text.js b/src/get-node-text.js index dc1c8a67..77db5dea 100644 --- a/src/get-node-text.js +++ b/src/get-node-text.js @@ -5,8 +5,6 @@ function getNodeText(node) { ) .map(c => c.textContent) .join(' ') - .trim() - .replace(/\s+/g, ' ') } export {getNodeText} From 7d5f11fba9293aeacc9876fb817fb965ed98e339 Mon Sep 17 00:00:00 2001 From: Alex Krolick Date: Sat, 5 May 2018 14:41:26 -0700 Subject: [PATCH 09/10] Make matcher tests primarily use public apis --- src/__tests__/matches.js | 76 +++++----------------------------- src/__tests__/text-matchers.js | 33 ++++++++++----- 2 files changed, 32 insertions(+), 77 deletions(-) diff --git a/src/__tests__/matches.js b/src/__tests__/matches.js index 7c0b16c2..8296a54f 100644 --- a/src/__tests__/matches.js +++ b/src/__tests__/matches.js @@ -4,78 +4,22 @@ import {fuzzyMatches, matches} from '../' const node = null -test('matches should accept regex', () => { +test('matchers accept strings', () => { + expect(matches('ABC', node, 'ABC')).toBe(true) + expect(fuzzyMatches('ABC', node, 'ABC')).toBe(true) +}) + +test('matchers accept regex', () => { expect(matches('ABC', node, /ABC/)).toBe(true) expect(fuzzyMatches('ABC', node, /ABC/)).toBe(true) }) -test('matches should accept functions', () => { +test('matchers accept functions', () => { expect(matches('ABC', node, text => text === 'ABC')).toBe(true) expect(fuzzyMatches('ABC', node, text => text === 'ABC')).toBe(true) }) -test('fuzzyMatches should get fuzzy matches', () => { - // should not match - expect(fuzzyMatches(null, node, 'abc')).toBe(false) - expect(fuzzyMatches('', node, 'abc')).toBe(false) - expect(fuzzyMatches('ABC', node, 'ABCD')).toBe(false) - // should match - expect(fuzzyMatches('AB C', node, 'ab c')).toBe(true) - expect(fuzzyMatches('AB C', node, 'AB C')).toBe(true) - expect(fuzzyMatches('ABC', node, 'A')).toBe(true) - expect(fuzzyMatches('\nAB C\t', node, 'AB C')).toBe(true) - expect(fuzzyMatches('\nAB\nC\t', node, 'AB C')).toBe(true) -}) - -test('matches should only get exact matches', () => { - // should not match - expect(matches(null, node, null)).toBe(false) - expect(matches(null, node, 'abc')).toBe(false) - expect(matches('', node, 'abc')).toBe(false) - expect(matches('ABC', node, 'abc')).toBe(false) - expect(matches('ABC', node, 'A')).toBe(false) - expect(matches('ABC', node, 'ABCD')).toBe(false) - // should match - expect(matches('ABC', node, 'ABC')).toBe(true) -}) - -test('should trim strings of surrounding whitespace by default', () => { - expect(matches(' ABC ', node, /^ABC/)).toBe(true) - expect(fuzzyMatches(' ABC ', node, /^ABC/)).toBe(true) -}) - -test('collapseWhitespace option should be toggleable', () => { - const yes = { - collapseWhitespace: true, - } - const no = { - collapseWhitespace: false, - } - - expect(matches('AB\n \t C', node, 'ABC')).toBe(false) // default - expect(fuzzyMatches('AB\n \t C', node, 'AB C')).toBe(true) // default - - expect(matches('AB\n \t C', node, 'AB C', yes)).toBe(true) - expect(fuzzyMatches('AB\n \t C', node, 'AB C', yes)).toBe(true) - - expect(matches('AB\n \t C', node, 'AB C', no)).toBe(false) - expect(fuzzyMatches('AB\n \t C', node, 'AB C', no)).toBe(false) -}) - -test('trim option should be toggleable', () => { - const yes = { - trim: true, - } - const no = { - trim: false, - } - - expect(matches(' ABC \n\t', node, /^ABC$/)).toBe(true) // default - expect(fuzzyMatches(' ABC \n\t', node, /^ABC$/)).toBe(true) // default - - expect(matches(' ABC \n\t', node, /^ABC$/, yes)).toBe(true) - expect(fuzzyMatches(' ABC \n\t', node, /^ABC$/, yes)).toBe(true) - - expect(matches(' ABC \n\t', node, /^ABC$/, no)).toBe(false) - expect(fuzzyMatches(' ABC \n\t', node, /^ABC$/, no)).toBe(false) +test('matchers return false if text to match is not a string', () => { + expect(matches(null, node, 'ABC')).toBe(false) + expect(fuzzyMatches(null, node, 'ABC')).toBe(false) }) diff --git a/src/__tests__/text-matchers.js b/src/__tests__/text-matchers.js index 05acfbf6..b84bf2e0 100644 --- a/src/__tests__/text-matchers.js +++ b/src/__tests__/text-matchers.js @@ -6,9 +6,17 @@ cases( 'matches find case-sensitive full strings by default', ({dom, query, queryFn}) => { const queries = render(dom) - expect(queries[queryFn](query)).toHaveLength(1) + + const queryString = query + const queryRegex = new RegExp(query) + const queryFunc = text => text === query + + expect(queries[queryFn](queryString)).toHaveLength(1) + expect(queries[queryFn](queryRegex)).toHaveLength(1) + expect(queries[queryFn](queryFunc)).toHaveLength(1) + expect(queries[queryFn](query.toUpperCase())).toHaveLength(0) // case - expect(queries[queryFn](query.slice(1))).toHaveLength(0) // substring + expect(queries[queryFn](query.slice(0, 1))).toHaveLength(0) // substring }, { queryAllByTestId: { @@ -31,15 +39,8 @@ cases( queryFn: `queryAllByPlaceholderText`, }, queryAllByText: { - dom: ` -

- Content - with - linebreaks - is - ok -

`, - query: `Content with linebreaks is ok`, + dom: `

Some content

`, + query: `Some content`, queryFn: `queryAllByText`, }, queryAllByLabelText: { @@ -123,7 +124,17 @@ cases( '{ exact } option toggles case-insensitive partial matches', ({dom, query, queryFn}) => { const queries = render(dom) + + const queryString = query + const queryRegex = new RegExp(query) + const queryFunc = text => text === query + expect(queries[queryFn](query)).toHaveLength(1) + + expect(queries[queryFn](queryString, {exact: false})).toHaveLength(1) + expect(queries[queryFn](queryRegex, {exact: false})).toHaveLength(1) + expect(queries[queryFn](queryFunc, {exact: false})).toHaveLength(1) + expect(queries[queryFn](query.split(' ')[0], {exact: false})).toHaveLength( 1, ) From 69b4f593048a58cdb8d62df1e450c2f1ec41fb60 Mon Sep 17 00:00:00 2001 From: Alex Krolick Date: Sat, 5 May 2018 16:00:25 -0700 Subject: [PATCH 10/10] Always collapse whitespace --- README.md | 2 +- src/__tests__/text-matchers.js | 19 ++++--------------- src/matches.js | 2 +- src/queries.js | 4 ++-- 4 files changed, 8 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ae150e7a..59650138 100644 --- a/README.md +++ b/README.md @@ -559,7 +559,7 @@ affect the precision of string matching: * In most cases using a regex instead of a string gives you more control over fuzzy matching and should be preferred over `{ exact: false }`. * `trim`: Defaults to `true`; trim leading and trailing whitespace. -* `collapseWhitespace`: Defaults to `false` for attribute queries and `true` for content queries (i.e., `queryByLabelText`, `queryByText`). Collapses inner whitespace (newlines, tabs, repeated spaces) into a single space. +* `collapseWhitespace`: Defaults to `true`. Collapses inner whitespace (newlines, tabs, repeated spaces) into a single space. ### TextMatch Examples diff --git a/src/__tests__/text-matchers.js b/src/__tests__/text-matchers.js index b84bf2e0..4cc66303 100644 --- a/src/__tests__/text-matchers.js +++ b/src/__tests__/text-matchers.js @@ -54,11 +54,13 @@ cases( ) cases( - 'attribute queries trim leading & trailing whitespace by default', + 'queries trim leading, trailing & inner whitespace by default', ({dom, query, queryFn}) => { const queries = render(dom) expect(queries[queryFn](query)).toHaveLength(1) - expect(queries[queryFn](query, {trim: false})).toHaveLength(0) + expect( + queries[queryFn](query, {collapseWhitespace: false, trim: false}), + ).toHaveLength(0) }, { queryAllByTestId: { @@ -82,19 +84,6 @@ cases( query: /^Dwayne/, queryFn: `queryAllByPlaceholderText`, }, - }, -) - -cases( - 'content queries trim leading, trailing & inner whitespace by default', - ({dom, query, queryFn}) => { - const queries = render(dom) - expect(queries[queryFn](query)).toHaveLength(1) - expect( - queries[queryFn](query, {collapseWhitespace: false, trim: false}), - ).toHaveLength(0) - }, - { queryAllByText: { dom: `

diff --git a/src/matches.js b/src/matches.js index bb91717c..1a79cb4f 100644 --- a/src/matches.js +++ b/src/matches.js @@ -21,7 +21,7 @@ function matches( textToMatch, node, matcher, - {collapseWhitespace = false, trim = true} = {}, + {collapseWhitespace = true, trim = true} = {}, ) { if (typeof textToMatch !== 'string') { return false diff --git a/src/queries.js b/src/queries.js index 11086e4b..f7b8e806 100644 --- a/src/queries.js +++ b/src/queries.js @@ -91,7 +91,7 @@ function queryAllByAttribute( attribute, container, text, - {exact = true, collapseWhitespace = false, trim = true} = {}, + {exact = true, collapseWhitespace = true, trim = true} = {}, ) { const matcher = exact ? matches : fuzzyMatches const matchOpts = {collapseWhitespace, trim} @@ -116,7 +116,7 @@ const queryAllByTitle = queryAllByAttribute.bind(null, 'title') function queryAllByAltText( container, alt, - {exact = true, collapseWhitespace = false, trim = true} = {}, + {exact = true, collapseWhitespace = true, trim = true} = {}, ) { const matcher = exact ? matches : fuzzyMatches const matchOpts = {collapseWhitespace, trim}