Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(custom normalizer): allow custom control of normalization #172

Merged
merged 6 commits into from
Dec 12, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 50 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to not allow people to call this function and instead simply export it:

export const normalizer = getDefaultNormalizer({trim: true, collapseWhitespace: true})

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, easy to do, but then somebody wouldn't be able to get hold of, say, a default normalizer that only trims but doesn't collapse whitespace.

If you're happy with that, that's fine, and I'll hide getDefaultNormalizer and take all mention of trim and collapseWhitespace out of the docs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

then somebody wouldn't be able to get hold of, say, a default normalizer that only trims but doesn't collapse whitespace.

I'm thinking that's a pretty minimal use-case, and if it is, they just need to copy/paste this:

function getDefaultNormalizer({trim = true, collapseWhitespace = true} = {}) {
  return text => {
    let normalizedText = text
    normalizedText = trim ? normalizedText.trim() : normalizedText
    normalizedText = collapseWhitespace
      ? normalizedText.replace(/\s+/g, ' ')
      : normalizedText
    return normalizedText
  }
}

Considering we're getting simplicity in exchange for a tiny bit of flexibility for 1% of use cases I think that's a win :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll also have to remove the example for how to match text without trimming, for instance:
getByText(node, 'text', {normalizer: getDefaultNormalizer({trim: false})})

There'd be no easy way to do that without getDefaultNormalizer: they'd have to implement their own whitespace-collapsing normalizer. I don't have much of an opinion on that, but presumably the existing trim and collapseWhitespace options were added because somebody wanted to be able to turn them off?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, that makes sense, considering I want to deprecate trim as an option. Ok, let's stick with what you have. I'd like to have @alexkrolick give this a look over too, but I'm 👍

Copy link
Collaborator

@alexkrolick alexkrolick Dec 10, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think some of the queries initially had different defaults for trim and collapseWhitespace, and they had different enough effects to keep separate (see #31).

I'm on the fence about exporting the factory function, but I definitely don't want to encourage copy-pasting the default implementation - that would be opting-out of future changes.

I think this is good as-is and leaves room to come up with future more streamlined APIs for other use cases.

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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be a breaking change

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, as I commented in the commit, I removed matches and fuzzyMatches: they looked like they were exported accidentally, and aren't documented. Are they officially supported?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They were used in jest-dom but it looks like that dependency was removed:

testing-library/jest-dom#9

testing-library/jest-dom@4c6b572

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So... that means they still need to be exposed for old versions of jest-dom that are bringing in new versions of dom-testing-library?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Actually, looking closer at those links, where were matches and fuzzyMatches used in jest-dom? I can see getNodeText, but at a quick skim I can't see matches and fuzzyMatches. It looks like they had their own implementation of matches. Am I missing something?)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was referring to this comment testing-library/jest-dom#9 (comment)

But you're right it doesn't look like it was used directly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kentcdodds can you confirm whether this is a breaking change? I'm happy to change the PR so matches and fuzzyMatches aren't changed, if necessary, but it'd be preferable not to continue to expose those internals or to have to keep the same APIs for them.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with this change.

export * from './get-node-text'
export * from './events'
export * from './get-queries-for-element'
Expand Down
Loading