Skip to content

Commit

Permalink
feat(custom normalizer): allow custom control of normalization (#172)
Browse files Browse the repository at this point in the history
* feat(custom normalizer): allow custom control of normalization

Adds an optional {normalizer} option to query functions; this
is a transformation function run over candidate match text
after it has had `trim` or `collapseWhitespace` run on it,
but before any matching text/function/regexp is tested
against it.

The use case is for tidying up DOM text (which may
contain, for instance, invisible Unicode control characters)
before running matching logic, keeping the matching logic
and normalization logic separate.

* Expand acronyms out

* Add `getDefaultNormalizer()` and move existing options

This commit moves the implementation of `trim` + `collapseWhitespace`,
making them just another normalizer. It also exposes a
`getDefaultNormalizer()` function which provides the default
normalization and allows for the configuration of it.

Removed `matches` and `fuzzyMatches` from being publicly exposed.

Updated tests, added new documentation for normalizer and
getDefaultNormalizer, and removed documentation for the
previous top-level `trim` and `collapseWhitespace` options.

* Apply normalizer treatment to queryAllByDisplayValue
  • Loading branch information
RoystonS authored and Kent C. Dodds committed Dec 12, 2018
1 parent dec7856 commit a03f056
Show file tree
Hide file tree
Showing 9 changed files with 294 additions and 87 deletions.
69 changes: 50 additions & 19 deletions README.md
Expand Up @@ -97,6 +97,7 @@ when a real user uses it.
- [Using other assertion libraries](#using-other-assertion-libraries)
- [`TextMatch`](#textmatch)
- [Precision](#precision)
- [Normalization](#normalization)
- [TextMatch Examples](#textmatch-examples)
- [`query` APIs](#query-apis)
- [`queryAll` and `getAll` APIs](#queryall-and-getall-apis)
Expand Down Expand Up @@ -207,8 +208,7 @@ getByLabelText(
options?: {
selector?: string = '*',
exact?: boolean = true,
collapseWhitespace?: boolean = true,
trim?: boolean = true,
normalizer?: NormalizerFn,
}): HTMLElement
```

Expand Down Expand Up @@ -259,8 +259,7 @@ getByPlaceholderText(
text: TextMatch,
options?: {
exact?: boolean = true,
collapseWhitespace?: boolean = false,
trim?: boolean = true,
normalizer?: NormalizerFn,
}): HTMLElement
```

Expand All @@ -284,9 +283,8 @@ getByText(
options?: {
selector?: string = '*',
exact?: boolean = true,
collapseWhitespace?: boolean = true,
trim?: boolean = true,
ignore?: string|boolean = 'script, style'
ignore?: string|boolean = 'script, style',
normalizer?: NormalizerFn,
}): HTMLElement
```

Expand Down Expand Up @@ -316,8 +314,7 @@ getByAltText(
text: TextMatch,
options?: {
exact?: boolean = true,
collapseWhitespace?: boolean = false,
trim?: boolean = true,
normalizer?: NormalizerFn,
}): HTMLElement
```

Expand All @@ -341,8 +338,7 @@ getByTitle(
title: TextMatch,
options?: {
exact?: boolean = true,
collapseWhitespace?: boolean = false,
trim?: boolean = true,
normalizer?: NormalizerFn,
}): HTMLElement
```

Expand All @@ -368,8 +364,7 @@ getByDisplayValue(
value: TextMatch,
options?: {
exact?: boolean = true,
collapseWhitespace?: boolean = false,
trim?: boolean = true,
normalizer?: NormalizerFn,
}): HTMLElement
```

Expand Down Expand Up @@ -416,8 +411,7 @@ getByRole(
text: TextMatch,
options?: {
exact?: boolean = true,
collapseWhitespace?: boolean = false,
trim?: boolean = true,
normalizer?: NormalizerFn,
}): HTMLElement
```

Expand All @@ -437,9 +431,8 @@ getByTestId(
text: TextMatch,
options?: {
exact?: boolean = true,
collapseWhitespace?: boolean = false,
trim?: boolean = true,
}): HTMLElement`
normalizer?: NormalizerFn,
}): HTMLElement
```

A shortcut to `` container.querySelector(`[data-testid="${yourId}"]`) `` (and it
Expand Down Expand Up @@ -801,9 +794,47 @@ 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.
- `normalizer`: An optional function which overrides normalization behavior.
See [`Normalization`](#normalization).

### Normalization

Before running any matching logic against text in the DOM, `dom-testing-library`
automatically normalizes that text. By default, normalization consists of
trimming whitespace from the start and end of text, and collapsing multiple
adjacent whitespace characters into a single space.

If you want to prevent that normalization, or provide alternative
normalization (e.g. to remove Unicode control characters), you can provide a
`normalizer` function in the options object. This function will be given
a string and is expected to return a normalized version of that string.

Note: Specifying a value for `normalizer` _replaces_ the built-in normalization, but
you can call `getDefaultNormalizer` to obtain a built-in normalizer, either
to adjust that normalization or to call it from your own normalizer.

`getDefaultNormalizer` takes an options object which allows the selection of behaviour:

- `trim`: Defaults to `true`. Trims leading and trailing whitespace
- `collapseWhitespace`: Defaults to `true`. Collapses inner whitespace (newlines, tabs, repeated spaces) into a single space.

#### Normalization Examples

To perform a match against text without trimming:

```javascript
getByText(node, 'text', {normalizer: getDefaultNormalizer({trim: false})})
```

To override normalization to remove some Unicode characters whilst keeping some (but not all) of the built-in normalization behavior:

```javascript
getByText(node, 'text', {
normalizer: str =>
getDefaultNormalizer({trim: false})(str).replace(/[\u200E-\u200F]*/g, ''),
})
```

### TextMatch Examples

```javascript
Expand Down
21 changes: 12 additions & 9 deletions src/__tests__/matches.js
@@ -1,25 +1,28 @@
import {fuzzyMatches, matches} from '../'
import {fuzzyMatches, matches} from '../matches'

// unit tests for text match utils

const node = null
const normalizer = str => str

test('matchers accept strings', () => {
expect(matches('ABC', node, 'ABC')).toBe(true)
expect(fuzzyMatches('ABC', node, 'ABC')).toBe(true)
expect(matches('ABC', node, 'ABC', normalizer)).toBe(true)
expect(fuzzyMatches('ABC', node, 'ABC', normalizer)).toBe(true)
})

test('matchers accept regex', () => {
expect(matches('ABC', node, /ABC/)).toBe(true)
expect(fuzzyMatches('ABC', node, /ABC/)).toBe(true)
expect(matches('ABC', node, /ABC/, normalizer)).toBe(true)
expect(fuzzyMatches('ABC', node, /ABC/, normalizer)).toBe(true)
})

test('matchers accept functions', () => {
expect(matches('ABC', node, text => text === 'ABC')).toBe(true)
expect(fuzzyMatches('ABC', node, text => text === 'ABC')).toBe(true)
expect(matches('ABC', node, text => text === 'ABC', normalizer)).toBe(true)
expect(fuzzyMatches('ABC', node, text => text === 'ABC', normalizer)).toBe(
true,
)
})

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)
expect(matches(null, node, 'ABC', normalizer)).toBe(false)
expect(fuzzyMatches(null, node, 'ABC', normalizer)).toBe(false)
})
134 changes: 133 additions & 1 deletion src/__tests__/text-matchers.js
@@ -1,5 +1,7 @@
import 'jest-dom/extend-expect'
import cases from 'jest-in-case'

import {getDefaultNormalizer} from '../'
import {render} from './helpers/test-utils'

cases(
Expand Down Expand Up @@ -68,7 +70,12 @@ cases(
const queries = render(dom)
expect(queries[queryFn](query)).toHaveLength(1)
expect(
queries[queryFn](query, {collapseWhitespace: false, trim: false}),
queries[queryFn](query, {
normalizer: getDefaultNormalizer({
collapseWhitespace: false,
trim: false,
}),
}),
).toHaveLength(0)
},
{
Expand Down Expand Up @@ -194,3 +201,128 @@ cases(
},
},
)

// A good use case for a custom normalizer is stripping
// out Unicode control characters such as LRM (left-right-mark)
// before matching
const LRM = '\u200e'
function removeUCC(str) {
return str.replace(/[\u200e]/g, '')
}

cases(
'{ normalizer } option allows custom pre-match normalization',
({dom, queryFn}) => {
const queries = render(dom)

const query = queries[queryFn]

// With the correct normalizer, we should match
expect(query(/user n.me/i, {normalizer: removeUCC})).toHaveLength(1)
expect(query('User name', {normalizer: removeUCC})).toHaveLength(1)

// Without the normalizer, we shouldn't
expect(query(/user n.me/i)).toHaveLength(0)
expect(query('User name')).toHaveLength(0)
},
{
queryAllByLabelText: {
dom: `
<label for="username">User ${LRM}name</label>
<input id="username" />`,
queryFn: 'queryAllByLabelText',
},
queryAllByPlaceholderText: {
dom: `<input placeholder="User ${LRM}name" />`,
queryFn: 'queryAllByPlaceholderText',
},
queryAllBySelectText: {
dom: `<select><option>User ${LRM}name</option></select>`,
queryFn: 'queryAllBySelectText',
},
queryAllByText: {
dom: `<div>User ${LRM}name</div>`,
queryFn: 'queryAllByText',
},
queryAllByAltText: {
dom: `<img alt="User ${LRM}name" src="username.jpg" />`,
queryFn: 'queryAllByAltText',
},
queryAllByTitle: {
dom: `<div title="User ${LRM}name" />`,
queryFn: 'queryAllByTitle',
},
queryAllByValue: {
dom: `<input value="User ${LRM}name" />`,
queryFn: 'queryAllByValue',
},
queryAllByDisplayValue: {
dom: `<input value="User ${LRM}name" />`,
queryFn: 'queryAllByDisplayValue',
},
queryAllByRole: {
dom: `<input role="User ${LRM}name" />`,
queryFn: 'queryAllByRole',
},
},
)

test('normalizer works with both exact and non-exact matching', () => {
const {queryAllByText} = render(`<div>MiXeD ${LRM}CaSe</div>`)

expect(
queryAllByText('mixed case', {exact: false, normalizer: removeUCC}),
).toHaveLength(1)
expect(
queryAllByText('mixed case', {exact: true, normalizer: removeUCC}),
).toHaveLength(0)
expect(
queryAllByText('MiXeD CaSe', {exact: true, normalizer: removeUCC}),
).toHaveLength(1)
expect(queryAllByText('MiXeD CaSe', {exact: true})).toHaveLength(0)
})

test('top-level trim and collapseWhitespace options are not supported if normalizer is specified', () => {
const {queryAllByText} = render('<div> abc def </div>')
const normalizer = str => str

expect(() => queryAllByText('abc', {trim: false, normalizer})).toThrow()
expect(() => queryAllByText('abc', {trim: true, normalizer})).toThrow()
expect(() =>
queryAllByText('abc', {collapseWhitespace: false, normalizer}),
).toThrow()
expect(() =>
queryAllByText('abc', {collapseWhitespace: true, normalizer}),
).toThrow()
})

test('getDefaultNormalizer returns a normalizer that supports trim and collapseWhitespace', () => {
// Default is trim: true and collapseWhitespace: true
expect(getDefaultNormalizer()(' abc def ')).toEqual('abc def')

// Turning off trimming should not turn off whitespace collapsing
expect(getDefaultNormalizer({trim: false})(' abc def ')).toEqual(
' abc def ',
)

// Turning off whitespace collapsing should not turn off trimming
expect(
getDefaultNormalizer({collapseWhitespace: false})(' abc def '),
).toEqual('abc def')

// Whilst it's rather pointless, we should be able to turn both off
expect(
getDefaultNormalizer({trim: false, collapseWhitespace: false})(
' abc def ',
),
).toEqual(' abc def ')
})

test('we support an older API with trim and collapseWhitespace instead of a normalizer', () => {
const {queryAllByText} = render('<div> x y </div>')
expect(queryAllByText('x y')).toHaveLength(1)
expect(queryAllByText('x y', {trim: false})).toHaveLength(0)
expect(queryAllByText(' x y ', {trim: false})).toHaveLength(1)
expect(queryAllByText('x y', {collapseWhitespace: false})).toHaveLength(0)
expect(queryAllByText('x y', {collapseWhitespace: false})).toHaveLength(1)
})
2 changes: 1 addition & 1 deletion src/index.js
Expand Up @@ -6,7 +6,7 @@ export * from './queries'
export * from './wait'
export * from './wait-for-element'
export * from './wait-for-dom-change'
export * from './matches'
export {getDefaultNormalizer} from './matches'
export * from './get-node-text'
export * from './events'
export * from './get-queries-for-element'
Expand Down

0 comments on commit a03f056

Please sign in to comment.