Repository: kentcdodds/dom-testing-library (js)

Issue:
`queryByTestId`, `getByTestId` use partial case-insensitive text match by default, which is unexpected
<!--
Thanks for your interest in the project. I appreciate bugs filed and PRs submitted!
Please make sure that you are familiar with and follow the Code of Conduct for
this project (found in the CODE_OF_CONDUCT.md file).

Please fill out this template with all the relevant information so we can
understand what's going on and fix the issue.

I'll probably ask you to submit the fix (after giving some direction). If you've
never done that before, that's great! Check this free short video tutorial to
learn how: http://kcd.im/pull-request
-->

* `dom-testing-library` version: 1.1.0
* `node` version: N/A
* `npm` (or `yarn`) version: N/A

Relevant code or config

```javascript
import { getByTestId } from "dom-testing-library";

document.getElementById("test").innerHTML = `
<div data-testid="supertest-123"></div>
`;

document.getElementById("output").innerHTML = `
${getByTestId(document, "tEsT")
  .outerHTML.replace(/</g, "&lt;")
  .replace(/>/g, "&gt;")}
`;
```

What you did:

1. Added an element with `data-testid="supertest-123"` to an empty DOM.
2. Queried the DOM via `getByTestId` by string `"tEsT"`.

What happened:

The element with `data-testid="supertest-123"` was found.

<!-- Please provide the full error message/screenshots/anything -->

Reproduction repository:

https://codesandbox.io/s/pwrpl1yy9x?module=%2Fsrc%2Findex.js

<!--
If possible, please create a repository that reproduces the issue with the
minimal amount of code possible.
-->

Problem description:

The current behavior is unexpected, the identifiers aren't supposed to be matched partially and case-insensitively.

Suggested solution:

Use case-sensitive full-string match in `queryByTestId`, `getByTestId`.

Pull request:
fix: use exact match for data-testid
[fixes #8]

<!--
Thanks for your interest in the project. Bugs filed and PRs submitted are appreciated!

Please make sure that you are familiar with and follow the Code of Conduct for
this project (found in the CODE_OF_CONDUCT.md file).

Also, please make sure you're familiar with and follow the instructions in the
contributing guidelines (found in the CONTRIBUTING.md file).

If you're new to contributing to open source projects, you might find this free
video course helpful: http://kcd.im/pull-request

Please fill out the information below to expedite the review and (hopefully)
merge of your pull request!
-->

<!-- What changes are being made? (What feature/bug is being fixed here?) -->

**What**:

<!-- Why are these changes necessary? -->

**Why**:

<!-- How were these changes implemented? -->

**How**:

<!-- Have you done all of these things?  -->

**Checklist**:

<!-- add "N/A" to the end of each line that's irrelevant to your changes -->

<!-- to check an item, place an "x" in the box like so: "- [x] Documentation" -->

* [x] ~~~Documentation~~~
* [x] Tests
* [x] Ready to be merged <!-- In your opinion, is this ready to be merged as soon as it's reviewed? -->
* [x] Added myself to contributors table <!-- this is optional, see the contributing guidelines for instructions -->

<!-- feel free to add additional comments -->


Patch:
diff --git a/README.md b/README.md
index eb67260..cf79013 100644
--- a/README.md
+++ b/README.md
@@ -76,12 +76,14 @@ when a real user uses it.
   * [`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)
+  * [`getByTestId(container: HTMLElement, text: ExactTextMatch): HTMLElement`](#getbytestidcontainer-htmlelement-text-exacttextmatch-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)
 * [`query` APIs](#query-apis)
 * [`queryAll` and `getAll` APIs](#queryall-and-getall-apis)
 * [`bindElementToQueries`](#bindelementtoqueries)
@@ -248,10 +250,10 @@ and [`<area>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/area)
 const incrediblesPosterImg = getByAltText(container, /incredibles.*poster$/i)
 ```
 
-#### `getByTestId(container: HTMLElement, text: TextMatch): HTMLElement`
+### `getByTestId(container: HTMLElement, text: ExactTextMatch): HTMLElement`
 
 A shortcut to `` container.querySelector(`[data-testid="${yourId}"]`) `` (and it
-also accepts a [`TextMatch`](#textmatch)).
+also accepts an [`ExactTextMatch`](#exacttextmatch)).
 
 ```javascript
 // <input data-testid="username-input" />
@@ -477,6 +479,25 @@ getByText(container, (content, element) => {
 })
 ```
 
+### ExactTextMatch
+
+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.
+
+```js
+// <button data-testid="submit-button">Go</button>
+
+// 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
+
+// all of the following will NOT find the button
+getByTestId(container, 'submit-') // no substrings
+getByTestId(container, 'Submit-Button') // case-sensitive
+```
+
 ## `query` APIs
 
 Each of the `get` APIs listed in [the 'Usage'](#usage) section above have a
diff --git a/src/matches.js b/src/matches.js
index 387e9fe..96c8f2b 100644
--- a/src/matches.js
+++ b/src/matches.js
@@ -12,4 +12,17 @@ function matches(textToMatch, node, matcher) {
   }
 }
 
-export {matches}
+function matchesExact(textToMatch, node, matcher) {
+  if (typeof textToMatch !== 'string') {
+    return false
+  }
+  if (typeof matcher === 'string') {
+    return textToMatch === matcher
+  } else if (typeof matcher === 'function') {
+    return matcher(textToMatch, node)
+  } else {
+    return matcher.test(textToMatch)
+  }
+}
+
+export {matches, matchesExact}
diff --git a/src/queries.js b/src/queries.js
index 49d1422..97b035f 100644
--- a/src/queries.js
+++ b/src/queries.js
@@ -1,4 +1,4 @@
-import {matches} from './matches'
+import {matches, matchesExact} from './matches'
 import {getNodeText} from './get-node-text'
 import {prettyDOM} from './pretty-dom'
 
@@ -70,22 +70,25 @@ function queryByText(container, text, opts) {
 
 // this is just a utility and not an exposed query.
 // There are no plans to expose this.
-function queryAllByAttribute(attribute, container, text) {
+function queryAllByAttribute(attribute, container, text, {exact = false} = {}) {
+  const matcher = exact ? matchesExact : matches
   return Array.from(container.querySelectorAll(`[${attribute}]`)).filter(node =>
-    matches(node.getAttribute(attribute), node, text),
+    matcher(node.getAttribute(attribute), node, text),
   )
 }
 
 // this is just a utility and not an exposed query.
 // There are no plans to expose this.
-function queryByAttribute(attribute, container, text) {
-  return firstResultOrNull(queryAllByAttribute, attribute, container, text)
+function queryByAttribute(...args) {
+  return firstResultOrNull(queryAllByAttribute, ...args)
 }
 
 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 queryByTestId = (...args) =>
+  queryByAttribute('data-testid', ...args, {exact: true})
+const queryAllByTestId = (...args) =>
+  queryAllByAttribute('data-testid', ...args, {exact: true})
 
 function queryAllByAltText(container, alt) {
   return Array.from(container.querySelectorAll('img,input,area')).filter(node =>


Test patch:
diff --git a/src/__tests__/element-queries.js b/src/__tests__/element-queries.js
index 1cf4e91..2d646d7 100644
--- a/src/__tests__/element-queries.js
+++ b/src/__tests__/element-queries.js
@@ -117,6 +117,18 @@ test('get element by its alt text', () => {
   expect(getByAltText(/fin.*nem.*poster$/i).src).toBe('/finding-nemo.png')
 })
 
+test('can get elements by data-testid attribute', () => {
+  const {queryByTestId} = render(`<div data-testid="firstName"></div>`)
+  expect(queryByTestId('firstName')).toBeInTheDOM()
+  expect(queryByTestId(/first/)).toBeInTheDOM()
+  expect(queryByTestId(testid => testid === 'firstName')).toBeInTheDOM()
+  // match should be exact, case-sensitive
+  expect(queryByTestId('firstname')).not.toBeInTheDOM()
+  expect(queryByTestId('first')).not.toBeInTheDOM()
+  expect(queryByTestId('firstNamePlusMore')).not.toBeInTheDOM()
+  expect(queryByTestId('first-name')).not.toBeInTheDOM()
+})
+
 test('getAll* matchers return an array', () => {
   const {
     getAllByAltText,
@@ -161,9 +173,12 @@ test('getAll* matchers throw for 0 matches', () => {
   } = render(`
     <div>
       <label>No Matches Please</label>
+      <div data-testid="ABC"></div>
+      <div data-testid="a-b-c"></div>
     </div>,
   `)
   expect(() => getAllByTestId('nope')).toThrow()
+  expect(() => getAllByTestId('abc')).toThrow()
   expect(() => getAllByAltText('nope')).toThrow()
   expect(() => getAllByLabelText('nope')).toThrow()
   expect(() => getAllByLabelText('no matches please')).toThrow()
diff --git a/src/__tests__/matches.js b/src/__tests__/matches.js
new file mode 100644
index 0000000..abef386
--- /dev/null
+++ b/src/__tests__/matches.js
@@ -0,0 +1,26 @@
+import {matches, matchesExact} from '../'
+
+// unit tests for text match utils
+
+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)
+  // should match
+  expect(matches('ABC', node, 'abc')).toBe(true)
+  expect(matches('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)
+  // should match
+  expect(matchesExact('ABC', node, 'ABC')).toBe(true)
+})