diff --git a/package.json b/package.json index 22f0441a4..7d85af1d5 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "testTimeout": 60000, "transformIgnorePatterns": [ "/node_modules/(?!(@react-native|react-native)/).*/" - ] + ], + "clearMocks": true } } diff --git a/src/__tests__/__snapshots__/render-debug.test.tsx.snap b/src/__tests__/__snapshots__/render-debug.test.tsx.snap new file mode 100644 index 000000000..59bef3870 --- /dev/null +++ b/src/__tests__/__snapshots__/render-debug.test.tsx.snap @@ -0,0 +1,480 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`debug 1`] = ` +" + + Is the banana fresh? + + + not fresh + + + + + + + + Change freshness! + + + + First Text + + + Second Text + + + 0 + +" +`; + +exports[`debug changing component: bananaFresh button message should now be "fresh" 1`] = ` +" + + Is the banana fresh? + + + fresh + + + + + + + + Change freshness! + + + + First Text + + + Second Text + + + 0 + +" +`; + +exports[`debug should use debugOptions from config when no option is specified 1`] = ` +" + + hello + +" +`; + +exports[`debug should use given options over config debugOptions 1`] = ` +" + + hello + +" +`; + +exports[`debug with only children prop 1`] = ` +" + + Is the banana fresh? + + + not fresh + + + + + + + + Change freshness! + + + + First Text + + + Second Text + + + 0 + +" +`; + +exports[`debug with only prop whose value is bananaChef 1`] = ` +" + + Is the banana fresh? + + + not fresh + + + + + + + + Change freshness! + + + + First Text + + + Second Text + + + 0 + +" +`; + +exports[`debug with only props from TextInput components 1`] = ` +" + + Is the banana fresh? + + + not fresh + + + + + + + + Change freshness! + + + + First Text + + + Second Text + + + 0 + +" +`; + +exports[`debug: another custom message 1`] = ` +"another custom message + + + + Is the banana fresh? + + + not fresh + + + + + + + + Change freshness! + + + + First Text + + + Second Text + + + 0 + +" +`; + +exports[`debug: shallow 1`] = ` +" + + Is the banana fresh? + + + not fresh + + + + + + + Change freshness! + + + First Text + + + Second Text + + + 0 + +" +`; + +exports[`debug: shallow with message 1`] = ` +"my other custom message + + + + Is the banana fresh? + + + not fresh + + + + + + + Change freshness! + + + First Text + + + Second Text + + + 0 + +" +`; + +exports[`debug: with message 1`] = ` +"my custom message + + + + Is the banana fresh? + + + not fresh + + + + + + + + Change freshness! + + + + First Text + + + Second Text + + + 0 + +" +`; diff --git a/src/__tests__/__snapshots__/render.test.tsx.snap b/src/__tests__/__snapshots__/render.test.tsx.snap index 54a4b292f..8fd16ad2f 100644 --- a/src/__tests__/__snapshots__/render.test.tsx.snap +++ b/src/__tests__/__snapshots__/render.test.tsx.snap @@ -1,295 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`debug 1`] = ` -" - - Is the banana fresh? - - - not fresh - - - - - - - - Change freshness! - - - - First Text - - - Second Text - - - 0 - -" -`; - -exports[`debug changing component: bananaFresh button message should now be "fresh" 1`] = ` -" - - Is the banana fresh? - - - fresh - - - - - - - - Change freshness! - - - - First Text - - - Second Text - - - 0 - -" -`; - -exports[`debug: shallow 1`] = ` -" - - Is the banana fresh? - - - not fresh - - - - - - - Change freshness! - - - First Text - - - Second Text - - - 0 - -" -`; - -exports[`debug: shallow with message 1`] = ` -"my other custom message - - - - Is the banana fresh? - - - not fresh - - - - - - - Change freshness! - - - First Text - - - Second Text - - - 0 - -" -`; - -exports[`debug: with message 1`] = ` -"my custom message - - - - Is the banana fresh? - - - not fresh - - - - - - - - Change freshness! - - - - First Text - - - Second Text - - - 0 - -" -`; - exports[`toJSON 1`] = ` { test('configure() overrides existing config values', () => { configure({ asyncUtilTimeout: 5000 }); - expect(getConfig().asyncUtilTimeout).toEqual(5000); + configure({ defaultDebugOptions: { message: 'debug message' } }); + expect(getConfig()).toEqual({ + asyncUtilTimeout: 5000, + defaultDebugOptions: { message: 'debug message' }, + }); }); test('resetToDefaults() resets config to defaults', () => { diff --git a/src/__tests__/render-debug.test.tsx b/src/__tests__/render-debug.test.tsx new file mode 100644 index 000000000..6697de3da --- /dev/null +++ b/src/__tests__/render-debug.test.tsx @@ -0,0 +1,208 @@ +/* eslint-disable no-console */ +import * as React from 'react'; +import { View, Text, TextInput, Pressable } from 'react-native'; +import stripAnsi from 'strip-ansi'; +import { render, fireEvent, resetToDefaults, configure } from '..'; + +type ConsoleLogMock = jest.Mock>; + +const PLACEHOLDER_FRESHNESS = 'Add custom freshness'; +const PLACEHOLDER_CHEF = 'Who inspected freshness?'; +const INPUT_FRESHNESS = 'Custom Freshie'; +const INPUT_CHEF = 'I inspected freshie'; +const DEFAULT_INPUT_CHEF = 'What did you inspect?'; +const DEFAULT_INPUT_CUSTOMER = 'What banana?'; + +const ignoreWarnings = ['Using debug("message") is deprecated']; + +const realConsoleWarn = console.warn; + +beforeEach(() => { + resetToDefaults(); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation((message) => { + if (!ignoreWarnings.some((warning) => message.includes(warning))) { + realConsoleWarn(message); + } + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +class MyButton extends React.Component { + render() { + return ( + + {this.props.children} + + ); + } +} + +class Banana extends React.Component { + state = { + fresh: false, + }; + + componentDidUpdate() { + if (this.props.onUpdate) { + this.props.onUpdate(); + } + } + + componentWillUnmount() { + if (this.props.onUnmount) { + this.props.onUnmount(); + } + } + + changeFresh = () => { + this.setState((state) => ({ + fresh: !state.fresh, + })); + }; + + render() { + const test = 0; + return ( + + Is the banana fresh? + + {this.state.fresh ? 'fresh' : 'not fresh'} + + + + + + + Change freshness! + + First Text + Second Text + {test} + + ); + } +} + +test('debug', () => { + const { debug } = render(); + + debug(); + debug('my custom message'); + debug.shallow(); + debug.shallow('my other custom message'); + debug({ message: 'another custom message' }); + + const mockCalls = (console.log as any as ConsoleLogMock).mock.calls; + expect(stripAnsi(mockCalls[0][0])).toMatchSnapshot(); + expect(stripAnsi(mockCalls[1][0] + mockCalls[1][1])).toMatchSnapshot( + 'with message' + ); + expect(stripAnsi(mockCalls[2][0])).toMatchSnapshot('shallow'); + expect(stripAnsi(mockCalls[3][0] + mockCalls[3][1])).toMatchSnapshot( + 'shallow with message' + ); + expect(stripAnsi(mockCalls[4][0] + mockCalls[4][1])).toMatchSnapshot( + 'another custom message' + ); + + const mockWarnCalls = (console.warn as any as ConsoleLogMock).mock.calls; + expect(mockWarnCalls[0]).toEqual([ + 'Using debug("message") is deprecated and will be removed in future release, please use debug({ message; "message" }) instead.', + ]); +}); + +test('debug changing component', () => { + const { UNSAFE_getByProps, debug } = render(); + fireEvent.press(UNSAFE_getByProps({ type: 'primary' })); + + debug(); + + const mockCalls = (console.log as any as ConsoleLogMock).mock.calls; + expect(stripAnsi(mockCalls[0][0])).toMatchSnapshot( + 'bananaFresh button message should now be "fresh"' + ); +}); + +test('debug with only children prop', () => { + const { debug } = render(); + debug({ mapProps: () => ({}) }); + + const mockCalls = (console.log as any as ConsoleLogMock).mock.calls; + expect(stripAnsi(mockCalls[0][0])).toMatchSnapshot(); +}); + +test('debug with only prop whose value is bananaChef', () => { + const { debug } = render(); + debug({ + mapProps: (props) => { + const filterProps: Record = {}; + Object.keys(props).forEach((key) => { + if (props[key] === 'bananaChef') { + filterProps[key] = props[key]; + } + }); + return filterProps; + }, + }); + + const mockCalls = (console.log as any as ConsoleLogMock).mock.calls; + expect(stripAnsi(mockCalls[0][0])).toMatchSnapshot(); +}); + +test('debug with only props from TextInput components', () => { + const { debug } = render(); + debug({ + mapProps: (props, node) => (node.type === 'TextInput' ? props : {}), + }); + + const mockCalls = (console.log as any as ConsoleLogMock).mock.calls; + expect(stripAnsi(mockCalls[0][0])).toMatchSnapshot(); +}); + +test('debug should use debugOptions from config when no option is specified', () => { + configure({ defaultDebugOptions: { mapProps: () => ({}) } }); + + const { debug } = render( + + hello + + ); + debug(); + + const mockCalls = (console.log as any as ConsoleLogMock).mock.calls; + expect(stripAnsi(mockCalls[0][0])).toMatchSnapshot(); +}); + +test('filtering out props through mapProps option should not modify component', () => { + const { debug, getByTestId } = render(); + debug({ mapProps: () => ({}) }); + + expect(getByTestId('viewTestID')).toBeTruthy(); +}); + +test('debug should use given options over config debugOptions', () => { + configure({ defaultDebugOptions: { mapProps: () => ({}) } }); + + const { debug } = render( + + hello + + ); + debug({ mapProps: (props) => props }); + + const mockCalls = (console.log as any as ConsoleLogMock).mock.calls; + expect(stripAnsi(mockCalls[0][0])).toMatchSnapshot(); +}); diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx index 0e3a46feb..5cd7e0f97 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -1,9 +1,7 @@ +/* eslint-disable no-console */ import * as React from 'react'; import { View, Text, TextInput, Pressable, SafeAreaView } from 'react-native'; -import stripAnsi from 'strip-ansi'; -import { render, fireEvent, RenderAPI } from '..'; - -type ConsoleLogMock = jest.Mock>; +import { render, fireEvent, RenderAPI, resetToDefaults } from '..'; const PLACEHOLDER_FRESHNESS = 'Add custom freshness'; const PLACEHOLDER_CHEF = 'Who inspected freshness?'; @@ -12,6 +10,10 @@ const INPUT_CHEF = 'I inspected freshie'; const DEFAULT_INPUT_CHEF = 'What did you inspect?'; const DEFAULT_INPUT_CUSTOMER = 'What banana?'; +beforeEach(() => { + resetToDefaults(); +}); + class MyButton extends React.Component { render() { return ( @@ -156,46 +158,6 @@ test('toJSON', () => { expect(toJSON()).toMatchSnapshot(); }); -test('debug', () => { - jest.spyOn(console, 'log').mockImplementation((x) => x); - - const { debug } = render(); - - debug(); - debug('my custom message'); - debug.shallow(); - debug.shallow('my other custom message'); - - // eslint-disable-next-line no-console - const mockCalls = (console.log as any as ConsoleLogMock).mock.calls; - - expect(stripAnsi(mockCalls[0][0])).toMatchSnapshot(); - expect(stripAnsi(mockCalls[1][0] + mockCalls[1][1])).toMatchSnapshot( - 'with message' - ); - expect(stripAnsi(mockCalls[2][0])).toMatchSnapshot('shallow'); - expect(stripAnsi(mockCalls[3][0] + mockCalls[3][1])).toMatchSnapshot( - 'shallow with message' - ); -}); - -test('debug changing component', () => { - jest.spyOn(console, 'log').mockImplementation((x) => x); - - const { UNSAFE_getByProps, debug } = render(); - - fireEvent.press(UNSAFE_getByProps({ type: 'primary' })); - - debug(); - - // eslint-disable-next-line no-console - const mockCalls = (console.log as any as ConsoleLogMock).mock.calls; - - expect(stripAnsi(mockCalls[4][0])).toMatchSnapshot( - 'bananaFresh button message should now be "fresh"' - ); -}); - test('renders options.wrapper around node', () => { type WrapperComponentProps = { children: React.ReactNode }; const WrapperComponent = ({ children }: WrapperComponentProps) => ( diff --git a/src/config.ts b/src/config.ts index 940f367df..65d617935 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,11 @@ +import { DebugOptions } from './helpers/debugDeep'; + export type Config = { /** Default timeout, in ms, for `waitFor` and `findBy*` queries. */ asyncUtilTimeout: number; + + /** Default options for `debug` helper. */ + defaultDebugOptions?: Partial; }; const defaultConfig: Config = { diff --git a/src/helpers/debugDeep.ts b/src/helpers/debugDeep.ts index bde90f155..0f7a78464 100644 --- a/src/helpers/debugDeep.ts +++ b/src/helpers/debugDeep.ts @@ -1,18 +1,27 @@ import type { ReactTestRendererJSON } from 'react-test-renderer'; -import format from './format'; +import format, { FormatOptions } from './format'; + +export type DebugOptions = { + message?: string; +} & FormatOptions; /** * Log pretty-printed deep test component instance */ export default function debugDeep( instance: ReactTestRendererJSON | ReactTestRendererJSON[], - message?: string + options?: DebugOptions | string ) { + const message = typeof options === 'string' ? options : options?.message; + + const formatOptions = + typeof options === 'object' ? { mapProps: options?.mapProps } : undefined; + if (message) { // eslint-disable-next-line no-console - console.log(`${message}\n\n`, format(instance)); + console.log(`${message}\n\n`, format(instance, formatOptions)); } else { // eslint-disable-next-line no-console - console.log(format(instance)); + console.log(format(instance, formatOptions)); } } diff --git a/src/helpers/format.ts b/src/helpers/format.ts index 0a3868754..f8dbf6d3e 100644 --- a/src/helpers/format.ts +++ b/src/helpers/format.ts @@ -1,10 +1,42 @@ import type { ReactTestRendererJSON } from 'react-test-renderer'; -import prettyFormat, { plugins } from 'pretty-format'; +import prettyFormat, { NewPlugin, plugins } from 'pretty-format'; -const format = (input: ReactTestRendererJSON | ReactTestRendererJSON[]) => +type MapPropsFunction = ( + props: Record, + node: ReactTestRendererJSON +) => Record; + +export type FormatOptions = { + mapProps?: MapPropsFunction; +}; + +const format = ( + input: ReactTestRendererJSON | ReactTestRendererJSON[], + options: FormatOptions = {} +) => prettyFormat(input, { - plugins: [plugins.ReactTestComponent, plugins.ReactElement], + plugins: [getCustomPlugin(options.mapProps), plugins.ReactElement], highlight: true, }); +const getCustomPlugin = (mapProps?: MapPropsFunction): NewPlugin => { + return { + test: (val) => plugins.ReactTestComponent.test(val), + serialize: (val, config, indentation, depth, refs, printer) => { + let newVal = val; + if (mapProps && val.props) { + newVal = { ...val, props: mapProps(val.props, val) }; + } + return plugins.ReactTestComponent.serialize( + newVal, + config, + indentation, + depth, + refs, + printer + ); + }, + }; +}; + export default format; diff --git a/src/render.tsx b/src/render.tsx index 88951f515..a25828985 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -5,10 +5,11 @@ import { Profiler } from 'react'; import act from './act'; import { addToCleanupQueue } from './cleanup'; import debugShallow from './helpers/debugShallow'; -import debugDeep from './helpers/debugDeep'; +import debugDeep, { DebugOptions } from './helpers/debugDeep'; import { getQueriesForElement } from './within'; import { setRenderResult, screen } from './screen'; import { validateStringsRenderedWithinText } from './helpers/stringValidation'; +import { getConfig } from './config'; export type RenderOptions = { wrapper?: React.ComponentType; @@ -135,7 +136,7 @@ function updateWithAct( } interface DebugFunction { - (message?: string): void; + (options?: DebugOptions | string): void; shallow: (message?: string) => void; } @@ -143,10 +144,23 @@ function debug( instance: ReactTestInstance, renderer: ReactTestRenderer ): DebugFunction { - function debugImpl(message?: string) { + function debugImpl(options?: DebugOptions | string) { + const { defaultDebugOptions } = getConfig(); + const debugOptions = + typeof options === 'string' + ? { ...defaultDebugOptions, message: options } + : { ...defaultDebugOptions, ...options }; + + if (typeof options === 'string') { + // eslint-disable-next-line no-console + console.warn( + 'Using debug("message") is deprecated and will be removed in future release, please use debug({ message; "message" }) instead.' + ); + } + const json = renderer.toJSON(); if (json) { - return debugDeep(json, message); + return debugDeep(json, debugOptions); } } debugImpl.shallow = (message?: string) => debugShallow(instance, message); diff --git a/typings/index.flow.js b/typings/index.flow.js index 0f1282bfd..a1a27a20b 100644 --- a/typings/index.flow.js +++ b/typings/index.flow.js @@ -210,10 +210,7 @@ type ByRoleOptions = { interface A11yAPI { // Label - getByLabelText: ( - matcher: TextMatch, - options?: TextMatchOptions - ) => GetReturn; + getByLabelText: (matcher: TextMatch, options?: TextMatchOptions) => GetReturn; getAllByLabelText: ( matcher: TextMatch, options?: TextMatchOptions @@ -238,14 +235,8 @@ interface A11yAPI { ) => FindAllReturn; // Hint - getByA11yHint: ( - matcher: TextMatch, - options?: TextMatchOptions - ) => GetReturn; - getByHintText: ( - matcher: TextMatch, - options?: TextMatchOptions - ) => GetReturn; + getByA11yHint: (matcher: TextMatch, options?: TextMatchOptions) => GetReturn; + getByHintText: (matcher: TextMatch, options?: TextMatchOptions) => GetReturn; getAllByA11yHint: ( matcher: TextMatch, options?: TextMatchOptions @@ -349,8 +340,18 @@ interface Thenable { then: (resolve: () => any, reject?: () => any) => any; } +type MapPropsFunction = ( + props: { [string]: mixed }, + node: ReactTestRendererJSON +) => { [string]: mixed }; + +type DebugOptions = { + message?: string, + mapProps?: MapPropsFunction, +}; + type Debug = { - (message?: string): void, + (options?: DebugOptions | string): void, shallow: (message?: string) => void, }; @@ -413,6 +414,7 @@ declare module '@testing-library/react-native' { declare interface Config { asyncUtilTimeout: number; + defaultDebugOptions?: $Shape; } declare export var configure: (options: $Shape) => void; diff --git a/website/docs/API.md b/website/docs/API.md index 212ca9393..1591a92e0 100644 --- a/website/docs/API.md +++ b/website/docs/API.md @@ -15,6 +15,8 @@ title: API - [`update`](#update) - [`unmount`](#unmount) - [`debug`](#debug) + - [`message` option](#message-option) + - [`mapProps` option](#mapprops-option) - [`debug.shallow`](#debugshallow) - [`toJSON`](#tojson) - [`container`](#container) @@ -48,6 +50,7 @@ title: API - [Configuration](#configuration) - [`configure`](#configure) - [`asyncUtilTimeout` option](#asyncutiltimeout-option) + - [`defaultDebugOptions` option](#defaultdebugoptions-option) - [`resetToDefaults()`](#resettodefaults) - [Environment variables](#environment-variables) - [`RNTL_SKIP_AUTO_CLEANUP`](#rntl_skip_auto_cleanup) @@ -170,15 +173,23 @@ Usually you should not need to call `unmount` as it is done automatically if you ### `debug` ```ts -debug(message?: string): void +interface DebugOptions { + message?: string; + mapProps?: MapPropsFunction; +} + +debug(options?: DebugOptions | string): void ``` -Pretty prints deeply rendered component passed to `render` with optional message on top. +Pretty prints deeply rendered component passed to `render`. + +#### `message` option + +You can provide a message that will be printed on top. ```jsx render(); - -screen.debug('optional message'); +screen.debug({ message: 'optional message' }); ``` logs optional message and colored JSX: @@ -193,6 +204,41 @@ optional message ``` + +#### `mapProps` option + +You can use the `mapProps` option to transform the props that will be printed : + +```jsx +render(); +debug({ mapProps : ({ style, ...props }) => ({ props }) }) +``` + +This will log the rendered JSX without the `style` props. + +The `children` prop cannot be filtered out so the following will print all rendered components with all props but `children` filtered out. + + +```ts +debug({ mapProps : props => ({}) }) +``` + +This option can be used to target specific props when debugging a query (for instance keeping only `children` prop when debugging a `getByText` query). + + You can also transform prop values so that they are more readable (e.g. flatten styles). + + ```ts +import { StyleSheet } from 'react-native'; + +debug({ mapProps : {({ style, ...props })} => ({ style : StyleSheet.flatten(style), ...props }) }); + ``` + +Or remove props that have little value when debugging tests, e.g. path prop for svgs + +```ts +debug({ mapProps : ({ path, ...props }) => ({ ...props })}); +``` + #### `debug.shallow` Pretty prints shallowly rendered component passed to `render` with optional message on top. @@ -223,6 +269,13 @@ Hold the value of latest render call for easier access to query and other functi Its value is automatically cleared after each test by calling [`cleanup`](#cleanup). If no `render` call has been made in a given test then it holds a special object that implements `RenderResult` but throws a helpful error on each property and method access. +This can also be used to build test utils that would normally require to be in render scope, either in a test file or globally for your project. For instance: + +```ts +// Prints the rendered components omitting all props except children. +const debugText = () => screen.debug({ mapProps : props => ({}) }) +``` + ## `cleanup` ```ts @@ -493,7 +546,7 @@ If you receive warnings related to `act()` function consult our [Undestanding Ac Defined as: -```jsx +```ts function waitForElementToBeRemoved( expectation: () => T, { timeout: number = 4500, interval: number = 50 } @@ -729,6 +782,7 @@ it('should use context value', () => { ```ts type Config = { asyncUtilTimeout: number; + defaultDebugOptions: Partial }; function configure(options: Partial) {} @@ -737,6 +791,9 @@ function configure(options: Partial) {} Default timeout, in ms, for async helper functions (`waitFor`, `waitForElementToBeRemoved`) and `findBy*` queries. Defaults to 1000 ms. +#### `defaultDebugOptions` option + +Default [debug options](#debug) to be used when calling `debug()`. These default options will be overridden by the ones you specify directly when calling `debug()`. ### `resetToDefaults()`