From 344e9b3bdf13f832f8fd91f30e3b37992462f227 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Thu, 24 Aug 2023 22:11:11 +0200 Subject: [PATCH] feat: `toHaveDisplayValue` matcher (#1463) * feat: to have display value * add tests for toHaveDisplayValue() * update type of toHaveDisplayValue matcher * rename test file * format * chore: add more tests * refactor: extract common TextInput utils * chore: fix codecov --------- Co-authored-by: Jan Jaworski --- src/fireEvent.ts | 3 +- src/helpers/__tests__/text-input.test.tsx | 22 +++++ src/helpers/text-input.ts | 22 +++++ .../__tests__/to-have-display-value.test.tsx | 87 +++++++++++++++++++ src/matchers/extend-expect.d.ts | 3 + src/matchers/extend-expect.ts | 2 + src/matchers/to-have-display-value.tsx | 49 +++++++++++ src/queries/displayValue.ts | 8 +- src/user-event/clear.ts | 5 +- src/user-event/press/press.ts | 19 ++-- src/user-event/type/type.ts | 4 +- src/user-event/utils/host-components.ts | 6 -- src/user-event/utils/index.ts | 1 - 13 files changed, 207 insertions(+), 24 deletions(-) create mode 100644 src/helpers/__tests__/text-input.test.tsx create mode 100644 src/helpers/text-input.ts create mode 100644 src/matchers/__tests__/to-have-display-value.test.tsx create mode 100644 src/matchers/to-have-display-value.tsx delete mode 100644 src/user-event/utils/host-components.ts diff --git a/src/fireEvent.ts b/src/fireEvent.ts index 68746e300..a27be7c78 100644 --- a/src/fireEvent.ts +++ b/src/fireEvent.ts @@ -10,6 +10,7 @@ import act from './act'; import { isHostElement } from './helpers/component-tree'; import { isHostTextInput } from './helpers/host-component-names'; import { isPointerEventEnabled } from './helpers/pointer-events'; +import { isTextInputEditable } from './helpers/text-input'; type EventHandler = (...args: unknown[]) => unknown; @@ -53,7 +54,7 @@ export function isEventEnabled( ) { if (isHostTextInput(nearestTouchResponder)) { return ( - nearestTouchResponder?.props.editable !== false || + isTextInputEditable(nearestTouchResponder) || textInputEventsIgnoringEditableProp.has(eventName) ); } diff --git a/src/helpers/__tests__/text-input.test.tsx b/src/helpers/__tests__/text-input.test.tsx new file mode 100644 index 000000000..88606b722 --- /dev/null +++ b/src/helpers/__tests__/text-input.test.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { View } from 'react-native'; +import { render, screen } from '../..'; +import { getTextInputValue, isTextInputEditable } from '../text-input'; + +test('getTextInputValue() throws error when invoked on non-text input', () => { + render(); + + const view = screen.getByTestId('view'); + expect(() => getTextInputValue(view)).toThrowErrorMatchingInlineSnapshot( + `"Element is not a "TextInput", but it has type "View"."` + ); +}); + +test('isTextInputEditable() throws error when invoked on non-text input', () => { + render(); + + const view = screen.getByTestId('view'); + expect(() => isTextInputEditable(view)).toThrowErrorMatchingInlineSnapshot( + `"Element is not a "TextInput", but it has type "View"."` + ); +}); diff --git a/src/helpers/text-input.ts b/src/helpers/text-input.ts new file mode 100644 index 000000000..de0af158b --- /dev/null +++ b/src/helpers/text-input.ts @@ -0,0 +1,22 @@ +import { ReactTestInstance } from 'react-test-renderer'; +import { isHostTextInput } from './host-component-names'; + +export function isTextInputEditable(element: ReactTestInstance) { + if (!isHostTextInput(element)) { + throw new Error( + `Element is not a "TextInput", but it has type "${element.type}".` + ); + } + + return element.props.editable !== false; +} + +export function getTextInputValue(element: ReactTestInstance) { + if (!isHostTextInput(element)) { + throw new Error( + `Element is not a "TextInput", but it has type "${element.type}".` + ); + } + + return element.props.value ?? element.props.defaultValue; +} diff --git a/src/matchers/__tests__/to-have-display-value.test.tsx b/src/matchers/__tests__/to-have-display-value.test.tsx new file mode 100644 index 000000000..b523f81db --- /dev/null +++ b/src/matchers/__tests__/to-have-display-value.test.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; +import { TextInput, View } from 'react-native'; +import { render, screen } from '../..'; +import '../extend-expect'; + +test('example test', () => { + render(); + + const textInput = screen.getByTestId('text-input'); + expect(textInput).toHaveDisplayValue('test'); +}); + +test('toHaveDisplayValue() on matching display value', () => { + render(); + + const textInput = screen.getByTestId('text-input'); + expect(textInput).toHaveDisplayValue('test'); + + expect(() => expect(textInput).not.toHaveDisplayValue('test')) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).not.toHaveDisplayValue() + + Expected element not to have display value: + test + Received: + test" + `); +}); + +test('toHaveDisplayValue() on non-matching display value', () => { + render(); + + const textInput = screen.getByTestId('text-input'); + expect(textInput).not.toHaveDisplayValue('non-test'); + + expect(() => expect(textInput).toHaveDisplayValue('non-test')) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).toHaveDisplayValue() + + Expected element to have display value: + non-test + Received: + test" + `); +}); + +test("toHaveDisplayValue() on non-'TextInput' elements", () => { + render(); + + const view = screen.getByTestId('view'); + expect(() => + expect(view).toHaveDisplayValue('test') + ).toThrowErrorMatchingInlineSnapshot( + `"toHaveDisplayValue() works only with host "TextInput" elements. Passed element has type "View"."` + ); +}); + +test('toHaveDisplayValue() performing partial match', () => { + render(); + + const textInput = screen.getByTestId('text-input'); + expect(textInput).toHaveDisplayValue('Hello World'); + + expect(textInput).not.toHaveDisplayValue('hello world'); + expect(textInput).not.toHaveDisplayValue('Hello'); + expect(textInput).not.toHaveDisplayValue('World'); + + expect(textInput).toHaveDisplayValue('Hello World', { exact: false }); + expect(textInput).toHaveDisplayValue('hello', { exact: false }); + expect(textInput).toHaveDisplayValue('world', { exact: false }); +}); + +test('toHaveDisplayValue() uses defaultValue', () => { + render(); + + const textInput = screen.getByTestId('text-input'); + expect(textInput).toHaveDisplayValue('default'); +}); + +test('toHaveDisplayValue() prioritizes value over defaultValue', () => { + render( + + ); + + const textInput = screen.getByTestId('text-input'); + expect(textInput).toHaveDisplayValue('value'); +}); diff --git a/src/matchers/extend-expect.d.ts b/src/matchers/extend-expect.d.ts index 435d4c509..16a31109e 100644 --- a/src/matchers/extend-expect.d.ts +++ b/src/matchers/extend-expect.d.ts @@ -1,6 +1,9 @@ +import { TextMatch, TextMatchOptions } from '../matches'; + export interface JestNativeMatchers { toBeOnTheScreen(): R; toBeEmptyElement(): R; + toHaveDisplayValue(expectedValue: TextMatch, options?: TextMatchOptions): R; } // Implicit Jest global `expect`. diff --git a/src/matchers/extend-expect.ts b/src/matchers/extend-expect.ts index dc7744189..bf6b1bac5 100644 --- a/src/matchers/extend-expect.ts +++ b/src/matchers/extend-expect.ts @@ -2,8 +2,10 @@ import { toBeOnTheScreen } from './to-be-on-the-screen'; import { toBeEmptyElement } from './to-be-empty-element'; +import { toHaveDisplayValue } from './to-have-display-value'; expect.extend({ toBeOnTheScreen, toBeEmptyElement, + toHaveDisplayValue, }); diff --git a/src/matchers/to-have-display-value.tsx b/src/matchers/to-have-display-value.tsx new file mode 100644 index 000000000..8813f2273 --- /dev/null +++ b/src/matchers/to-have-display-value.tsx @@ -0,0 +1,49 @@ +import type { ReactTestInstance } from 'react-test-renderer'; +import { matcherHint } from 'jest-matcher-utils'; +import { isHostTextInput } from '../helpers/host-component-names'; +import { ErrorWithStack } from '../helpers/errors'; +import { getTextInputValue } from '../helpers/text-input'; +import { TextMatch, TextMatchOptions, matches } from '../matches'; +import { checkHostElement, formatMessage } from './utils'; + +export function toHaveDisplayValue( + this: jest.MatcherContext, + element: ReactTestInstance, + expectedValue: TextMatch, + options?: TextMatchOptions +) { + checkHostElement(element, toHaveDisplayValue, this); + + if (!isHostTextInput(element)) { + throw new ErrorWithStack( + `toHaveDisplayValue() works only with host "TextInput" elements. Passed element has type "${element.type}".`, + toHaveDisplayValue + ); + } + + const receivedValue = getTextInputValue(element); + + return { + pass: matches( + expectedValue, + receivedValue, + options?.normalizer, + options?.exact + ), + message: () => { + return [ + formatMessage( + matcherHint( + `${this.isNot ? '.not' : ''}.toHaveDisplayValue`, + 'element', + '' + ), + `Expected element ${this.isNot ? 'not to' : 'to'} have display value`, + expectedValue, + 'Received', + receivedValue + ), + ].join('\n'); + }, + }; +} diff --git a/src/queries/displayValue.ts b/src/queries/displayValue.ts index d521dbf36..0d43e4c7c 100644 --- a/src/queries/displayValue.ts +++ b/src/queries/displayValue.ts @@ -1,6 +1,7 @@ import type { ReactTestInstance } from 'react-test-renderer'; import { findAll } from '../helpers/findAll'; import { isHostTextInput } from '../helpers/host-component-names'; +import { getTextInputValue } from '../helpers/text-input'; import { matches, TextMatch, TextMatchOptions } from '../matches'; import { makeQueries } from './makeQueries'; import type { @@ -17,13 +18,12 @@ type ByDisplayValueOptions = CommonQueryOptions & TextMatchOptions; const matchDisplayValue = ( node: ReactTestInstance, - value: TextMatch, + expectedValue: TextMatch, options: TextMatchOptions = {} ) => { const { exact, normalizer } = options; - const nodeValue = node.props.value ?? node.props.defaultValue; - - return matches(value, nodeValue, normalizer, exact); + const nodeValue = getTextInputValue(node); + return matches(expectedValue, nodeValue, normalizer, exact); }; const queryAllByDisplayValue = ( diff --git a/src/user-event/clear.ts b/src/user-event/clear.ts index 46df2a22d..a60013b08 100644 --- a/src/user-event/clear.ts +++ b/src/user-event/clear.ts @@ -1,10 +1,11 @@ import { ReactTestInstance } from 'react-test-renderer'; import { ErrorWithStack } from '../helpers/errors'; import { isHostTextInput } from '../helpers/host-component-names'; +import { isTextInputEditable } from '../helpers/text-input'; import { isPointerEventEnabled } from '../helpers/pointer-events'; import { EventBuilder } from './event-builder'; import { UserEventInstance } from './setup'; -import { dispatchEvent, wait, isEditableTextInput } from './utils'; +import { dispatchEvent, wait } from './utils'; import { emitTypingEvents } from './type/type'; export async function clear( @@ -18,7 +19,7 @@ export async function clear( ); } - if (!isEditableTextInput(element) || !isPointerEventEnabled(element)) { + if (!isTextInputEditable(element) || !isPointerEventEnabled(element)) { return; } diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index 7c4835cf7..18e7fef47 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,16 +1,15 @@ import { ReactTestInstance } from 'react-test-renderer'; import act from '../../act'; import { getHostParent } from '../../helpers/component-tree'; +import { isTextInputEditable } from '../../helpers/text-input'; import { isPointerEventEnabled } from '../../helpers/pointer-events'; -import { isHostText } from '../../helpers/host-component-names'; +import { + isHostText, + isHostTextInput, +} from '../../helpers/host-component-names'; import { EventBuilder } from '../event-builder'; import { UserEventConfig, UserEventInstance } from '../setup'; -import { - dispatchEvent, - isEditableTextInput, - wait, - warnAboutRealTimersIfNeeded, -} from '../utils'; +import { dispatchEvent, wait, warnAboutRealTimersIfNeeded } from '../utils'; import { DEFAULT_MIN_PRESS_DURATION } from './constants'; export interface PressOptions { @@ -53,7 +52,11 @@ const basePress = async ( return; } - if (isEditableTextInput(element) && isPointerEventEnabled(element)) { + if ( + isHostTextInput(element) && + isTextInputEditable(element) && + isPointerEventEnabled(element) + ) { await emitTextInputPressEvents(config, element, options); return; } diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 6ea309376..13cc5448a 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -2,10 +2,10 @@ import { ReactTestInstance } from 'react-test-renderer'; import { isHostTextInput } from '../../helpers/host-component-names'; import { EventBuilder } from '../event-builder'; import { ErrorWithStack } from '../../helpers/errors'; +import { isTextInputEditable } from '../../helpers/text-input'; import { isPointerEventEnabled } from '../../helpers/pointer-events'; import { UserEventConfig, UserEventInstance } from '../setup'; import { dispatchEvent, wait, getTextContentSize } from '../utils'; - import { parseKeys } from './parseKeys'; export interface TypeOptions { @@ -27,7 +27,7 @@ export async function type( } // Skip events if the element is disabled - if (element.props.editable === false || !isPointerEventEnabled(element)) { + if (!isTextInputEditable(element) || !isPointerEventEnabled(element)) { return; } diff --git a/src/user-event/utils/host-components.ts b/src/user-event/utils/host-components.ts deleted file mode 100644 index 1a43785be..000000000 --- a/src/user-event/utils/host-components.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ReactTestInstance } from 'react-test-renderer'; -import { isHostTextInput } from '../../helpers/host-component-names'; - -export function isEditableTextInput(element: ReactTestInstance) { - return isHostTextInput(element) && element.props.editable !== false; -} diff --git a/src/user-event/utils/index.ts b/src/user-event/utils/index.ts index d97431dae..56e00613b 100644 --- a/src/user-event/utils/index.ts +++ b/src/user-event/utils/index.ts @@ -1,6 +1,5 @@ export * from './content-size'; export * from './dispatch-event'; -export * from './host-components'; export * from './text-range'; export * from './wait'; export * from './warn-about-real-timers';