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

Issue:
.toHaveClass custom matcher
At risk of sounding like I want to bloat the set of custom matchers provided by this lib, I'd like to propose `.toHaveClass('dropdown-toggle')`. The `class` attribute in html is designed to have multiple classes encoded in it, and it's often nice to be able to reliably check if an element has a certain class while ignoring the rest of what's encoded in the `class` attribute value.

```javascript
// ...
// <button data-testid="delete-button" type="submit" class="btn btn-danger">
//   Delete item
// </button>
expect(getByTestId('delete-button')).toHaveClass('btn-danger')
```

Raising it as an issue first to see if it's considered useful for this lib, and also to discuss potential names:

- `toHaveClass`
- `toIncludeClass`
- `toContainClass`
- something else?

Or all of the above but ending with `*ClassName` instead of just `*Class`.

Pull request description:
Add toHaveClass custom matcher (closes #2)
As discussed in #2 (closes #2)

One thing to note about it: I did this custom matcher so that it can even be used to match more than one class at the same time:

```javascript
// <button data-testid="delete-button" class="btn extra btn-danger">
//   Delete item
// </button>

// This passes, even though `"btn btn-danger"` are not contiguous in the actual class attr
expect(getByTestId('delete-button')).toHaveClass('btn btn-danger')
```

I think this makes it much more useful, and was not difficult to support.

Patch:
diff --git a/README.md b/README.md
index c38741a..50cf689 100644
--- a/README.md
+++ b/README.md
@@ -81,6 +81,7 @@ when a real user uses it.
   * [`toBeInTheDOM`](#tobeinthedom)
   * [`toHaveTextContent`](#tohavetextcontent)
   * [`toHaveAttribute`](#tohaveattribute)
+  * [`toHaveClass`](#tohaveclass)
   * [Custom Jest Matchers - Typescript](#custom-jest-matchers---typescript)
 * [`TextMatch`](#textmatch)
 * [`query` APIs](#query-apis)
@@ -364,6 +365,25 @@ expect(getByTestId(container, 'ok-button')).not.toHaveAttribute(
 // ...
 ```
 
+### `toHaveClass`
+
+This allows you to check wether the given element has certain classes within its
+`class` attribute.
+
+```javascript
+// add the custom expect matchers
+import 'dom-testing-library/extend-expect'
+
+// ...
+// <button data-testid="delete-button" class="btn extra btn-danger">
+//   Delete item
+// </button>
+expect(getByTestId(container, 'delete-button')).toHaveClass('extra')
+expect(getByTestId(container, 'delete-button')).toHaveClass('btn-danger btn')
+expect(getByTestId(container, 'delete-button')).not.toHaveClass('btn-link')
+// ...
+```
+
 ### Custom Jest Matchers - Typescript
 
 When you use custom Jest Matchers with Typescript, you will need to extend the
@@ -415,7 +435,7 @@ getByText(container, (content, element) => {
 
 ## `query` APIs
 
-Each of the `get` APIs listed in [the `render`](#render) section above have a
+Each of the `get` APIs listed in [the 'Usage'](#usage) section above have a
 complimentary `query` API. The `get` APIs will throw errors if a proper node
 cannot be found. This is normally the desired effect. However, if you want to
 make an assertion that an element is _not_ present in the DOM, then you can use
diff --git a/src/extend-expect.js b/src/extend-expect.js
index a2ddc2b..a18d54f 100644
--- a/src/extend-expect.js
+++ b/src/extend-expect.js
@@ -1,4 +1,9 @@
 import extensions from './jest-extensions'
 
-const {toBeInTheDOM, toHaveTextContent, toHaveAttribute} = extensions
-expect.extend({toBeInTheDOM, toHaveTextContent, toHaveAttribute})
+const {
+  toBeInTheDOM,
+  toHaveTextContent,
+  toHaveAttribute,
+  toHaveClass,
+} = extensions
+expect.extend({toBeInTheDOM, toHaveTextContent, toHaveAttribute, toHaveClass})
diff --git a/src/jest-extensions.js b/src/jest-extensions.js
index 228a40e..a44e728 100644
--- a/src/jest-extensions.js
+++ b/src/jest-extensions.js
@@ -49,6 +49,17 @@ function getAttributeComment(name, value) {
     : `element.getAttribute(${stringify(name)}) === ${stringify(value)}`
 }
 
+function splitClassNames(str) {
+  if (!str) {
+    return []
+  }
+  return str.split(/\s+/).filter(s => s.length > 0)
+}
+
+function isSubset(subset, superset) {
+  return subset.every(item => superset.includes(item))
+}
+
 const extensions = {
   toBeInTheDOM(received) {
     if (received) {
@@ -130,6 +141,29 @@ const extensions = {
       },
     }
   },
+
+  toHaveClass(htmlElement, expectedClassNames) {
+    checkHtmlElement(htmlElement)
+    const received = splitClassNames(htmlElement.getAttribute('class'))
+    const expected = splitClassNames(expectedClassNames)
+    return {
+      pass: isSubset(expected, received),
+      message: () => {
+        const to = this.isNot ? 'not to' : 'to'
+        return getMessage(
+          matcherHint(
+            `${this.isNot ? '.not' : ''}.toHaveClass`,
+            'element',
+            printExpected(expected.join(' ')),
+          ),
+          `Expected the element ${to} have class`,
+          expected.join(' '),
+          'Received',
+          received.join(' '),
+        )
+      },
+    }
+  },
 }
 
 export default extensions


Test patch:
diff --git a/src/__tests__/element-queries.js b/src/__tests__/element-queries.js
index 39a0eec..33ff610 100644
--- a/src/__tests__/element-queries.js
+++ b/src/__tests__/element-queries.js
@@ -150,4 +150,47 @@ test('using jest helpers to check element attributes', () => {
   ).toThrowError()
 })
 
+test('using jest helpers to check element class names', () => {
+  const {getByTestId} = render(`
+    <div>
+      <button data-testid="delete-button" class="btn extra btn-danger">
+        Delete item
+      </button>
+      <button data-testid="cancel-button">
+        Cancel
+      </button>
+    </div>
+  `)
+
+  expect(getByTestId('delete-button')).toHaveClass('btn')
+  expect(getByTestId('delete-button')).toHaveClass('btn-danger')
+  expect(getByTestId('delete-button')).toHaveClass('extra')
+  expect(getByTestId('delete-button')).not.toHaveClass('xtra')
+  expect(getByTestId('delete-button')).toHaveClass('btn btn-danger')
+  expect(getByTestId('delete-button')).not.toHaveClass('btn-link')
+  expect(getByTestId('cancel-button')).not.toHaveClass('btn-danger')
+
+  expect(() =>
+    expect(getByTestId('delete-button')).not.toHaveClass('btn'),
+  ).toThrowError()
+  expect(() =>
+    expect(getByTestId('delete-button')).not.toHaveClass('btn-danger'),
+  ).toThrowError()
+  expect(() =>
+    expect(getByTestId('delete-button')).not.toHaveClass('extra'),
+  ).toThrowError()
+  expect(() =>
+    expect(getByTestId('delete-button')).toHaveClass('xtra'),
+  ).toThrowError()
+  expect(() =>
+    expect(getByTestId('delete-button')).not.toHaveClass('btn btn-danger'),
+  ).toThrowError()
+  expect(() =>
+    expect(getByTestId('delete-button')).toHaveClass('btn-link'),
+  ).toThrowError()
+  expect(() =>
+    expect(getByTestId('cancel-button')).toHaveClass('btn-danger'),
+  ).toThrowError()
+})
+
 /* eslint jsx-a11y/label-has-for:0 */