Skip to content

Commit ce84f06

Browse files
authored
feat(expect): toContain can handle classList and Node.contains (#4239)
1 parent 969f185 commit ce84f06

File tree

3 files changed

+124
-6
lines changed

3 files changed

+124
-6
lines changed

docs/api/expect.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,14 +422,20 @@ test('structurally the same, but semantically different', () => {
422422

423423
- **Type:** `(received: string) => Awaitable<void>`
424424

425-
`toContain` asserts if the actual value is in an array. `toContain` can also check whether a string is a substring of another string.
425+
`toContain` asserts if the actual value is in an array. `toContain` can also check whether a string is a substring of another string. Since Vitest 1.0, if you are running tests in a browser-like environment, this assertion can also check if class is contained in a `classList`, or an element is inside another one.
426426

427427
```ts
428428
import { expect, test } from 'vitest'
429429
import { getAllFruits } from './stocks.js'
430430

431431
test('the fruit list contains orange', () => {
432432
expect(getAllFruits()).toContain('orange')
433+
434+
const element = document.querySelector('#el')
435+
// element has a class
436+
expect(element.classList).toContain('flex')
437+
// element is inside another one
438+
expect(document.querySelector('#wrapper')).toContain(element)
433439
})
434440
```
435441

packages/expect/src/jest-expect.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ import { diff, stringify } from './jest-matcher-utils'
1010
import { JEST_MATCHERS_OBJECT } from './constants'
1111
import { recordAsyncExpect, wrapSoft } from './utils'
1212

13+
// polyfill globals because expect can be used in node environment
14+
declare class Node {
15+
contains(item: unknown): boolean
16+
}
17+
declare class DOMTokenList {
18+
value: string
19+
contains(item: unknown): boolean
20+
}
21+
1322
// Jest Expect Compact
1423
export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
1524
const { AssertionError } = chai
@@ -164,6 +173,36 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
164173
return this.match(expected)
165174
})
166175
def('toContain', function (item) {
176+
const actual = this._obj as Iterable<unknown> | string | Node | DOMTokenList
177+
178+
if (typeof Node !== 'undefined' && actual instanceof Node) {
179+
if (!(item instanceof Node))
180+
throw new TypeError(`toContain() expected a DOM node as the argument, but got ${typeof item}`)
181+
182+
return this.assert(
183+
actual.contains(item),
184+
'expected #{this} to contain element #{exp}',
185+
'expected #{this} not to contain element #{exp}',
186+
item,
187+
actual,
188+
)
189+
}
190+
191+
if (typeof DOMTokenList !== 'undefined' && actual instanceof DOMTokenList) {
192+
assertTypes(item, 'class name', ['string'])
193+
const isNot = utils.flag(this, 'negate') as boolean
194+
const expectedClassList = isNot ? actual.value.replace(item, '').trim() : `${actual.value} ${item}`
195+
return this.assert(
196+
actual.contains(item),
197+
`expected "${actual.value}" to contain "${item}"`,
198+
`expected "${actual.value}" not to contain "${item}"`,
199+
expectedClassList,
200+
actual.value,
201+
)
202+
}
203+
// make "actual" indexable to have compatibility with jest
204+
if (actual != null && typeof actual !== 'string')
205+
utils.flag(this, 'object', Array.from(actual as Iterable<unknown>))
167206
return this.contain(item)
168207
})
169208
def('toContainEqual', function (expected) {
@@ -200,7 +239,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
200239
)
201240
})
202241
def('toBeGreaterThan', function (expected: number | bigint) {
203-
const actual = this._obj
242+
const actual = this._obj as number | bigint
204243
assertTypes(actual, 'actual', ['number', 'bigint'])
205244
assertTypes(expected, 'expected', ['number', 'bigint'])
206245
return this.assert(
@@ -213,7 +252,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
213252
)
214253
})
215254
def('toBeGreaterThanOrEqual', function (expected: number | bigint) {
216-
const actual = this._obj
255+
const actual = this._obj as number | bigint
217256
assertTypes(actual, 'actual', ['number', 'bigint'])
218257
assertTypes(expected, 'expected', ['number', 'bigint'])
219258
return this.assert(
@@ -226,7 +265,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
226265
)
227266
})
228267
def('toBeLessThan', function (expected: number | bigint) {
229-
const actual = this._obj
268+
const actual = this._obj as number | bigint
230269
assertTypes(actual, 'actual', ['number', 'bigint'])
231270
assertTypes(expected, 'expected', ['number', 'bigint'])
232271
return this.assert(
@@ -239,7 +278,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
239278
)
240279
})
241280
def('toBeLessThanOrEqual', function (expected: number | bigint) {
242-
const actual = this._obj
281+
const actual = this._obj as number | bigint
243282
assertTypes(actual, 'actual', ['number', 'bigint'])
244283
assertTypes(expected, 'expected', ['number', 'bigint'])
245284
return this.assert(

test/core/test/environments/jsdom.spec.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
// @vitest-environment jsdom
22

3-
import { expect, test } from 'vitest'
3+
import { createColors, getDefaultColors, setupColors } from '@vitest/utils'
4+
import { processError } from '@vitest/utils/error'
5+
import { afterEach, expect, test } from 'vitest'
6+
7+
afterEach(() => {
8+
setupColors(createColors(true))
9+
})
410

511
const nodeMajor = Number(process.version.slice(1).split('.')[0])
612

@@ -21,3 +27,70 @@ test.runIf(nodeMajor >= 18)('fetch, Request, Response, and BroadcastChannel are
2127
expect(TextDecoder).toBeDefined()
2228
expect(BroadcastChannel).toBeDefined()
2329
})
30+
31+
test('toContain correctly handles DOM nodes', () => {
32+
const wrapper = document.createElement('div')
33+
const child = document.createElement('div')
34+
const external = document.createElement('div')
35+
wrapper.appendChild(child)
36+
37+
const parent = document.createElement('div')
38+
parent.appendChild(wrapper)
39+
parent.appendChild(external)
40+
41+
document.body.appendChild(parent)
42+
const divs = document.querySelectorAll('div')
43+
44+
expect(divs).toContain(wrapper)
45+
expect(divs).toContain(parent)
46+
expect(divs).toContain(external)
47+
48+
expect(wrapper).toContain(child)
49+
expect(wrapper).not.toContain(external)
50+
51+
wrapper.classList.add('flex', 'flex-col')
52+
53+
expect(wrapper.classList).toContain('flex-col')
54+
expect(wrapper.classList).not.toContain('flex-row')
55+
56+
expect(() => {
57+
expect(wrapper).toContain('some-element')
58+
}).toThrowErrorMatchingInlineSnapshot(`[TypeError: toContain() expected a DOM node as the argument, but got string]`)
59+
60+
expect(() => {
61+
expect(wrapper.classList).toContain('flex-row')
62+
}).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected "flex flex-col" to contain "flex-row"]`)
63+
expect(() => {
64+
expect(wrapper.classList).toContain(2)
65+
}).toThrowErrorMatchingInlineSnapshot(`[TypeError: class name value must be string, received "number"]`)
66+
67+
setupColors(getDefaultColors())
68+
69+
try {
70+
expect(wrapper.classList).toContain('flex-row')
71+
expect.unreachable()
72+
}
73+
catch (err: any) {
74+
expect(processError(err).diff).toMatchInlineSnapshot(`
75+
"- Expected
76+
+ Received
77+
78+
- flex flex-col flex-row
79+
+ flex flex-col"
80+
`)
81+
}
82+
83+
try {
84+
expect(wrapper.classList).not.toContain('flex')
85+
expect.unreachable()
86+
}
87+
catch (err: any) {
88+
expect(processError(err).diff).toMatchInlineSnapshot(`
89+
"- Expected
90+
+ Received
91+
92+
- flex-col
93+
+ flex flex-col"
94+
`)
95+
}
96+
})

0 commit comments

Comments
 (0)