diff --git a/docs/api/browser/assertions.md b/docs/api/browser/assertions.md index 7c2d905da..72f3535f6 100644 --- a/docs/api/browser/assertions.md +++ b/docs/api/browser/assertions.md @@ -34,7 +34,7 @@ test('error banner is rendered', async () => { // It will repeatedly check that the element exists in the DOM and that // the content of `element.textContent` is equal to "Error!" // until all the conditions are met - await expect.element(banner).toHaveTextContent('Error!') + await expect.element(banner).toMatchTextContent('Error!') }) ``` @@ -56,11 +56,11 @@ interface ExpectPollOptions { ::: tip Like [`expect.poll`](/api/expect#poll), `expect.element` retries DOM assertions until they pass or the timeout is reached. When it receives a locator, Vitest resolves it with [`locator.findElement()`](/api/browser/locators#findelement) before running the DOM assertion. The `timeout` option applies to the whole retry operation. The `interval` option controls how often failed DOM assertions are retried, but locator resolution uses `findElement`'s own increasing retry intervals. -`toHaveTextContent` and all other assertions are still available on a regular `expect` without a built-in retry-ability mechanism: +`toMatchTextContent` and all other assertions are still available on a regular `expect` without a built-in retry-ability mechanism: ```ts // will fail immediately if .textContent is not `'Error!'` -expect(banner).toHaveTextContent('Error!') +expect(banner).toMatchTextContent('Error!') ``` ::: @@ -738,12 +738,37 @@ The usual rules of css precedence apply. ```ts function toHaveTextContent( - text: string | RegExp, + text: string | number, options?: { normalizeWhitespace: boolean } ): Promise ``` -This allows you to check whether the given node has a text content or not. This +This matcher allows you to validate that an element's text matches provided string exactly. This +supports elements, but also text nodes and fragments. + +If you wish to perform a partial check or do a case-sensitive match, use [`toMatchTextContent`](#tomatchtextcontent) instead. + +```html +Text Content +``` + +```ts +const element = getByTestId('text-content') + +await expect.element(element).toHaveTextContent('Text Content') +await expect.element(element).not.toHaveTextContent('Content') +``` + +## toMatchTextContent + +```ts +function toMatchTextContent( + text: string | number | RegExp, + options?: { normalizeWhitespace: boolean } +): Promise +``` + +This matcher allows you to check whether the given node has a text content or not. This supports elements, but also text nodes and fragments. When a `string` argument is passed through, it will perform a partial @@ -752,7 +777,7 @@ case-sensitive match to the node content. To perform a case-insensitive match, you can use a `RegExp` with the `/i` modifier. -If you want to match the whole content, you can use a `RegExp` to do it. +If you want to match the whole content, you can use a `RegExp` to do it or [`toHaveTextContent`](#tohavetextcontent) matcher instead. ```html Text Content @@ -761,12 +786,12 @@ If you want to match the whole content, you can use a `RegExp` to do it. ```ts const element = getByTestId('text-content') -await expect.element(element).toHaveTextContent('Content') +await expect.element(element).toMatchTextContent('Content') // to match the whole content -await expect.element(element).toHaveTextContent(/^Text Content$/) +await expect.element(element).toMatchTextContent(/^Text Content$/) // to use case-insensitive match -await expect.element(element).toHaveTextContent(/content$/i) -await expect.element(element).not.toHaveTextContent('content') +await expect.element(element).toMatchTextContent(/content$/i) +await expect.element(element).not.toMatchTextContent('content') ``` ## toHaveValue diff --git a/docs/guide/migration.md b/docs/guide/migration.md index 23e81f9bf..4d8ebbe3e 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -110,6 +110,21 @@ Several entry points were marked as deprecated in Vitest 4.1. This release remov - `vitest/mocker` is removed completely, use `@vitest/mocker` package directly (this was published by accident at one point and never removed) - `vitest/internal/module-runner` is removed +### `toHaveTextContent` Now Performs Strict Equality + +The browser-mode [`toHaveTextContent`](/api/browser/assertions#tohavetextcontent) matcher now validates that an element's text content is exactly equal to the expected string instead of performing a partial, case-sensitive match. Regular expressions are no longer accepted. The previous behaviour, including `RegExp` support, has moved to the new [`toMatchTextContent`](/api/browser/assertions#tomatchtextcontent) matcher. + +```ts +// Partial or regex matches: +await expect.element(banner).toHaveTextContent('Error') // [!code --] +await expect.element(banner).toHaveTextContent(/error/i) // [!code --] +await expect.element(banner).toMatchTextContent('Error') // [!code ++] +await expect.element(banner).toMatchTextContent(/error/i) // [!code ++] + +// Exact matches stay on `toHaveTextContent`: +await expect.element(banner).toHaveTextContent('Error!') +``` + ## Migrating to Vitest 4.0 {#vitest-4} ::: warning Prerequisites diff --git a/examples/lit/test/basic.test.ts b/examples/lit/test/basic.test.ts index 654353eea..efe993580 100644 --- a/examples/lit/test/basic.test.ts +++ b/examples/lit/test/basic.test.ts @@ -14,13 +14,13 @@ describe('Button with increment', async () => { it('should increment the count on each click', async () => { await page.getByRole('button').click() - await expect.element(page.getByRole('button')).toHaveTextContent('2') + await expect.element(page.getByRole('button')).toMatchTextContent('2') if (import.meta.env.VITE_FAIL_TEST) { - await expect.element(page.getByRole('button'), { timeout: 3000 }).toHaveTextContent('3') + await expect.element(page.getByRole('button'), { timeout: 3000 }).toMatchTextContent('3') } }) it('should show name props', async () => { - await expect.element(page.getByRole('heading')).toHaveTextContent('World') + await expect.element(page.getByRole('heading')).toMatchTextContent('World') }) }) diff --git a/packages/browser/jest-dom.d.ts b/packages/browser/jest-dom.d.ts index 0242faa43..835052bb4 100644 --- a/packages/browser/jest-dom.d.ts +++ b/packages/browser/jest-dom.d.ts @@ -377,6 +377,25 @@ export interface TestingLibraryMatchers { * @see https://vitest.dev/api/browser/assertions#tohavestyle */ toHaveStyle(css: string | Partial): R + /** + * @description + * Validate that the given element's text content matches the provided string exactly. + * + * Supports elements, but also text nodes and fragments. + * + * If you wish to perform a partial check or use a RegExp, use `toMatchTextContent` instead. + * @example + * Text Content + * + * const element = page.getByTestId('text-content') + * await expect.element(element).toHaveTextContent('Text Content') + * await expect.element(element).not.toHaveTextContent('Content') + * @see https://vitest.dev/api/browser/assertions#tohavetextcontent + */ + toHaveTextContent( + text: string | number, + options?: {normalizeWhitespace: boolean}, + ): R /** * @description * Check whether the given element has a text content or not. @@ -391,15 +410,15 @@ export interface TestingLibraryMatchers { * Text Content * * const element = page.getByTestId('text-content') - * await expect.element(element).toHaveTextContent('Content') + * await expect.element(element).toMatchTextContent('Content') * // to match the whole content - * await expect.element(element).toHaveTextContent(/^Text Content$/) + * await expect.element(element).toMatchTextContent(/^Text Content$/) * // to use case-insensitive match - * await expect.element(element).toHaveTextContent(/content$/i) - * await expect.element(element).not.toHaveTextContent('content') - * @see https://vitest.dev/api/browser/assertions#tohavetextcontent + * await expect.element(element).toMatchTextContent(/content$/i) + * await expect.element(element).not.toMatchTextContent('content') + * @see https://vitest.dev/api/browser/assertions#tomatchtextcontent */ - toHaveTextContent( + toMatchTextContent( text: string | number | RegExp, options?: {normalizeWhitespace: boolean}, ): R diff --git a/packages/browser/src/client/tester/expect/index.ts b/packages/browser/src/client/tester/expect/index.ts index fe2ccffc2..4c6617215 100644 --- a/packages/browser/src/client/tester/expect/index.ts +++ b/packages/browser/src/client/tester/expect/index.ts @@ -24,6 +24,7 @@ import toHaveStyle from './toHaveStyle' import toHaveTextContent from './toHaveTextContent' import toHaveValue from './toHaveValue' import toMatchScreenshot from './toMatchScreenshot' +import toMatchTextContent from './toMatchTextContent' export const matchers: MatchersObject = { toBeDisabled, @@ -46,6 +47,7 @@ export const matchers: MatchersObject = { toHaveFormValues, toHaveStyle, toHaveTextContent, + toMatchTextContent, toHaveValue, toHaveDisplayValue, toBeChecked, diff --git a/packages/browser/src/client/tester/expect/toHaveTextContent.ts b/packages/browser/src/client/tester/expect/toHaveTextContent.ts index 069f0e5cd..ff932a445 100644 --- a/packages/browser/src/client/tester/expect/toHaveTextContent.ts +++ b/packages/browser/src/client/tester/expect/toHaveTextContent.ts @@ -15,12 +15,12 @@ import type { MatcherResult, MatcherState } from 'vitest' import type { Locator } from '../locators' -import { getMessage, getNodeFromUserInput, matches, normalize } from './utils' +import { getMessage, getNodeFromUserInput, normalize } from './utils' export default function toHaveTextContent( this: MatcherState, actual: Element | Locator, - matcher: string | RegExp, + matcher: string | number, options: { normalizeWhitespace?: boolean } = { normalizeWhitespace: true }, ): MatcherResult { const node = getNodeFromUserInput(actual, toHaveTextContent, this) @@ -29,10 +29,10 @@ export default function toHaveTextContent( ? normalize(node.textContent || '') : (node.textContent || '').replace(/\u00A0/g, ' ') // Replace   with normal spaces - const checkingWithEmptyString = textContent !== '' && matcher === '' + const expectedText = String(matcher) return { - pass: !checkingWithEmptyString && matches(textContent, matcher), + pass: textContent === expectedText, message: () => { const to = this.isNot ? 'not to' : 'to' return getMessage( @@ -42,10 +42,8 @@ export default function toHaveTextContent( 'element', '', ), - checkingWithEmptyString - ? `Checking with empty string will always match, use .toBeEmptyDOMElement() instead` - : `Expected element ${to} have text content`, - matcher, + `Expected element ${to} have text content`, + expectedText, 'Received', textContent, ) diff --git a/packages/browser/src/client/tester/expect/toMatchTextContent.ts b/packages/browser/src/client/tester/expect/toMatchTextContent.ts new file mode 100644 index 000000000..dec2c7e78 --- /dev/null +++ b/packages/browser/src/client/tester/expect/toMatchTextContent.ts @@ -0,0 +1,54 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { MatcherResult, MatcherState } from 'vitest' +import type { Locator } from '../locators' +import { getMessage, getNodeFromUserInput, matches, normalize } from './utils' + +export default function toMatchTextContent( + this: MatcherState, + actual: Element | Locator, + matcher: string | RegExp, + options: { normalizeWhitespace?: boolean } = { normalizeWhitespace: true }, +): MatcherResult { + const node = getNodeFromUserInput(actual, toMatchTextContent, this) + + const textContent = options.normalizeWhitespace + ? normalize(node.textContent || '') + : (node.textContent || '').replace(/\u00A0/g, ' ') // Replace   with normal spaces + + const checkingWithEmptyString = textContent !== '' && matcher === '' + + return { + pass: !checkingWithEmptyString && matches(textContent, matcher), + message: () => { + const to = this.isNot ? 'not to' : 'to' + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toMatchTextContent`, + 'element', + '', + ), + checkingWithEmptyString + ? `Checking with empty string will always match, use .toBeEmptyDOMElement() instead` + : `Expected element ${to} match text content`, + matcher, + 'Received', + textContent, + ) + }, + } +} diff --git a/test/browser/fixtures/expect-dom/toHaveTextContent.test.ts b/test/browser/fixtures/expect-dom/toHaveTextContent.test.ts index 273b2a70c..b91d0362d 100644 --- a/test/browser/fixtures/expect-dom/toHaveTextContent.test.ts +++ b/test/browser/fixtures/expect-dom/toHaveTextContent.test.ts @@ -3,30 +3,35 @@ import { render } from './utils' describe('.toHaveTextContent', () => { test('handles positive test cases', () => { - const {queryByTestId} = render(`2`) + const { queryByTestId } = render(`2`) expect(queryByTestId('count-value')).toHaveTextContent('2') expect(queryByTestId('count-value')).toHaveTextContent(2) - expect(queryByTestId('count-value')).toHaveTextContent(/2/) expect(queryByTestId('count-value')).not.toHaveTextContent('21') }) + test('performs strict equality, not partial match', () => { + const { queryByTestId } = render(`Text Content`) + + expect(queryByTestId('text')).toHaveTextContent('Text Content') + expect(queryByTestId('text')).not.toHaveTextContent('Content') + expect(queryByTestId('text')).not.toHaveTextContent('Text') + }) + test('handles text nodes', () => { - const {container} = render(`example`) + const { container } = render(`example`) - expect(container.querySelector('span').firstChild).toHaveTextContent( - 'example', - ) + expect(container.querySelector('span')?.firstChild).toHaveTextContent('example') }) test('handles fragments', () => { - const {asFragment} = render(`example`) + const { asFragment } = render(`example`) expect(asFragment()).toHaveTextContent('example') }) test('handles negative test cases', () => { - const {queryByTestId} = render(`2`) + const { queryByTestId } = render(`2`) expect(() => expect(queryByTestId('count-value2')).toHaveTextContent('2'), @@ -41,7 +46,7 @@ describe('.toHaveTextContent', () => { }) test('normalizes whitespace by default', () => { - const {container} = render(` + const { container } = render(` Step 1 @@ -54,7 +59,7 @@ describe('.toHaveTextContent', () => { }) test('allows whitespace normalization to be turned off', () => { - const {container} = render(`  Step 1 of 4`) + const { container } = render(`  Step 1 of 4`) expect(container.querySelector('span')).toHaveTextContent(' Step 1 of 4', { normalizeWhitespace: false, @@ -62,15 +67,15 @@ describe('.toHaveTextContent', () => { }) test('can handle multiple levels', () => { - const {container} = render(`Step 1 - + const { container } = render(`Step 1 + of 4`) expect(container.querySelector('#parent')).toHaveTextContent('Step 1 of 4') }) test('can handle multiple levels with content spread across descendants', () => { - const {container} = render(` + const { container } = render(` Step 1 @@ -85,25 +90,24 @@ describe('.toHaveTextContent', () => { }) test('does not throw error with empty content', () => { - const {container} = render(``) + const { container } = render(``) expect(container.querySelector('span')).toHaveTextContent('') }) + test('throws when element has content but matcher is empty', () => { + const { container } = render('not empty') + + expect(() => + expect(container.querySelector('span')).toHaveTextContent(''), + ).toThrow() + }) + test('is case-sensitive', () => { - const {container} = render('Sensitive text') + const { container } = render('Sensitive text') expect(container.querySelector('span')).toHaveTextContent('Sensitive text') expect(container.querySelector('span')).not.toHaveTextContent( 'sensitive text', ) }) - - test('when matching with empty string and element with content, suggest using toBeEmptyDOMElement instead', () => { - // https://github.com/testing-library/jest-dom/issues/104 - const {container} = render('not empty') - - expect(() => - expect(container.querySelector('span')).toHaveTextContent(''), - ).toThrow(/toBeEmptyDOMElement\(\)/) - }) -}) \ No newline at end of file +}) diff --git a/test/browser/fixtures/expect-dom/toMatchTextContent.test.ts b/test/browser/fixtures/expect-dom/toMatchTextContent.test.ts new file mode 100644 index 000000000..690e41143 --- /dev/null +++ b/test/browser/fixtures/expect-dom/toMatchTextContent.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from 'vitest' +import { render } from './utils' + +describe('.toMatchTextContent', () => { + test('handles positive test cases', () => { + const {queryByTestId} = render(`2`) + + expect(queryByTestId('count-value')).toMatchTextContent('2') + expect(queryByTestId('count-value')).toMatchTextContent(2) + expect(queryByTestId('count-value')).toMatchTextContent(/2/) + expect(queryByTestId('count-value')).not.toMatchTextContent('21') + }) + + test('handles text nodes', () => { + const {container} = render(`example`) + + expect(container.querySelector('span')?.firstChild).toMatchTextContent( + 'example', + ) + }) + + test('handles fragments', () => { + const {asFragment} = render(`example`) + + expect(asFragment()).toMatchTextContent('example') + }) + + test('handles negative test cases', () => { + const {queryByTestId} = render(`2`) + + expect(() => + expect(queryByTestId('count-value2')).toMatchTextContent('2'), + ).toThrow() + + expect(() => + expect(queryByTestId('count-value')).toMatchTextContent('3'), + ).toThrow() + expect(() => + expect(queryByTestId('count-value')).not.toMatchTextContent('2'), + ).toThrow() + }) + + test('normalizes whitespace by default', () => { + const {container} = render(` + + Step + 1 + of + 4 + + `) + + expect(container.querySelector('span')).toMatchTextContent('Step 1 of 4') + }) + + test('allows whitespace normalization to be turned off', () => { + const {container} = render(`  Step 1 of 4`) + + expect(container.querySelector('span')).toMatchTextContent(' Step 1 of 4', { + normalizeWhitespace: false, + }) + }) + + test('can handle multiple levels', () => { + const {container} = render(`Step 1 + + of 4`) + + expect(container.querySelector('#parent')).toMatchTextContent('Step 1 of 4') + }) + + test('can handle multiple levels with content spread across descendants', () => { + const {container} = render(` + + Step + 1 + of + + + 4 + + `) + + expect(container.querySelector('#parent')).toMatchTextContent('Step 1 of 4') + }) + + test('does not throw error with empty content', () => { + const {container} = render(``) + expect(container.querySelector('span')).toMatchTextContent('') + }) + + test('is case-sensitive', () => { + const {container} = render('Sensitive text') + + expect(container.querySelector('span')).toMatchTextContent('Sensitive text') + expect(container.querySelector('span')).not.toMatchTextContent( + 'sensitive text', + ) + }) + + test('when matching with empty string and element with content, suggest using toBeEmptyDOMElement instead', () => { + // https://github.com/testing-library/jest-dom/issues/104 + const {container} = render('not empty') + + expect(() => + expect(container.querySelector('span')).toMatchTextContent(''), + ).toThrow(/toBeEmptyDOMElement\(\)/) + }) +}) \ No newline at end of file diff --git a/test/browser/fixtures/locators/blog.test.tsx b/test/browser/fixtures/locators/blog.test.tsx index e33c88d24..12b42e155 100644 --- a/test/browser/fixtures/locators/blog.test.tsx +++ b/test/browser/fixtures/locators/blog.test.tsx @@ -13,19 +13,19 @@ test('renders blog posts', async () => { const [firstPost, secondPost] = posts - expect(firstPost.element()).toHaveTextContent(/molestiae ut ut quas/) - expect(firstPost.getByRole('heading').element()).toHaveTextContent(/occaecati excepturi/) + expect(firstPost.element()).toMatchTextContent(/molestiae ut ut quas/) + expect(firstPost.getByRole('heading').element()).toMatchTextContent(/occaecati excepturi/) - await expect.element(secondPost.getByRole('heading')).toHaveTextContent('qui est esse') + await expect.element(secondPost.getByRole('heading')).toMatchTextContent('qui est esse') await userEvent.click(secondPost.getByRole('button', { name: 'Delete' })) expect(screen.getByRole('listitem').all()).toHaveLength(3) - expect(screen.getByRole('listitem').nth(0).element()).toHaveTextContent(/molestiae ut ut quas/) + expect(screen.getByRole('listitem').nth(0).element()).toMatchTextContent(/molestiae ut ut quas/) await expect.element(screen.getByRole('listitem').nth(666)).not.toBeInTheDocument() - expect(screen.getByRole('listitem').first().element()).toHaveTextContent(/molestiae ut ut quas/) - expect(screen.getByRole('listitem').last().element()).toHaveTextContent(/eum et est/) + expect(screen.getByRole('listitem').first().element()).toMatchTextContent(/molestiae ut ut quas/) + expect(screen.getByRole('listitem').last().element()).toMatchTextContent(/eum et est/) expect(screen.getByPlaceholder('non-existing').query()).not.toBeInTheDocument() })