diff --git a/.all-contributorsrc b/.all-contributorsrc index 16957ca9..de2ba851 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1353,6 +1353,15 @@ "contributions": [ "doc" ] + }, + { + "login": "yinm", + "name": "Yusuke Iinuma", + "avatar_url": "https://avatars.githubusercontent.com/u/13295106?v=4", + "profile": "http://yinm.info", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/.github/ISSUE_TEMPLATE/Bug_Report.md b/.github/ISSUE_TEMPLATE/Bug_Report.md index daefe8c6..c04bef38 100644 --- a/.github/ISSUE_TEMPLATE/Bug_Report.md +++ b/.github/ISSUE_TEMPLATE/Bug_Report.md @@ -60,13 +60,8 @@ https://github.com/testing-library/testing-library-docs ### Reproduction: ### Problem description: diff --git a/README.md b/README.md index a3731749..1ffc881d 100644 --- a/README.md +++ b/README.md @@ -630,6 +630,7 @@ Thanks goes to these people ([emoji key][emojis]):
diff --git a/src/__tests__/config.js b/src/__tests__/config.js
new file mode 100644
index 00000000..7fdb1e00
--- /dev/null
+++ b/src/__tests__/config.js
@@ -0,0 +1,66 @@
+import {configure, getConfig} from '../'
+
+describe('configuration API', () => {
+ let originalConfig
+ beforeEach(() => {
+ // Grab the existing configuration so we can restore
+ // it at the end of the test
+ configure(existingConfig => {
+ originalConfig = existingConfig
+ // Don't change the existing config
+ return {}
+ })
+ })
+
+ afterEach(() => {
+ configure(originalConfig)
+ })
+
+ describe('DTL options', () => {
+ test('configure can set by a plain JS object', () => {
+ const testIdAttribute = 'not-data-testid'
+ configure({testIdAttribute})
+
+ expect(getConfig().testIdAttribute).toBe(testIdAttribute)
+ })
+
+ test('configure can set by a function', () => {
+ // setup base option
+ const baseTestIdAttribute = 'data-testid'
+ configure({testIdAttribute: baseTestIdAttribute})
+
+ const modifiedPrefix = 'modified-'
+ configure(existingConfig => ({
+ testIdAttribute: `${modifiedPrefix}${existingConfig.testIdAttribute}`,
+ }))
+
+ expect(getConfig().testIdAttribute).toBe(
+ `${modifiedPrefix}${baseTestIdAttribute}`,
+ )
+ })
+ })
+
+ describe('RTL options', () => {
+ test('configure can set by a plain JS object', () => {
+ configure({reactStrictMode: true})
+
+ expect(getConfig().reactStrictMode).toBe(true)
+ })
+
+ test('configure can set by a function', () => {
+ configure(existingConfig => ({
+ reactStrictMode: !existingConfig.reactStrictMode,
+ }))
+
+ expect(getConfig().reactStrictMode).toBe(true)
+ })
+ })
+
+ test('configure can set DTL and RTL options at once', () => {
+ const testIdAttribute = 'not-data-testid'
+ configure({testIdAttribute, reactStrictMode: true})
+
+ expect(getConfig().testIdAttribute).toBe(testIdAttribute)
+ expect(getConfig().reactStrictMode).toBe(true)
+ })
+})
diff --git a/src/__tests__/render.js b/src/__tests__/render.js
index 46925f49..39f4bc92 100644
--- a/src/__tests__/render.js
+++ b/src/__tests__/render.js
@@ -1,84 +1,100 @@
import * as React from 'react'
import ReactDOM from 'react-dom'
import ReactDOMServer from 'react-dom/server'
-import {fireEvent, render, screen} from '../'
+import {fireEvent, render, screen, configure} from '../'
+
+describe('render API', () => {
+ let originalConfig
+ beforeEach(() => {
+ // Grab the existing configuration so we can restore
+ // it at the end of the test
+ configure(existingConfig => {
+ originalConfig = existingConfig
+ // Don't change the existing config
+ return {}
+ })
+ })
-test('renders div into document', () => {
- const ref = React.createRef()
- const {container} = render()
- expect(container.firstChild).toBe(ref.current)
-})
+ afterEach(() => {
+ configure(originalConfig)
+ })
-test('works great with react portals', () => {
- class MyPortal extends React.Component {
- constructor(...args) {
- super(...args)
- this.portalNode = document.createElement('div')
- this.portalNode.dataset.testid = 'my-portal'
- }
- componentDidMount() {
- document.body.appendChild(this.portalNode)
- }
- componentWillUnmount() {
- this.portalNode.parentNode.removeChild(this.portalNode)
- }
- render() {
- return ReactDOM.createPortal(
- ,
- this.portalNode,
- )
- }
- }
-
- function Greet({greeting, subject}) {
- return (
-
-
- {greeting} {subject}
-
-
- )
- }
-
- const {unmount} = render( )
- expect(screen.getByText('Hello World')).toBeInTheDocument()
- const portalNode = screen.getByTestId('my-portal')
- expect(portalNode).toBeInTheDocument()
- unmount()
- expect(portalNode).not.toBeInTheDocument()
-})
+ test('renders div into document', () => {
+ const ref = React.createRef()
+ const {container} = render()
+ expect(container.firstChild).toBe(ref.current)
+ })
-test('returns baseElement which defaults to document.body', () => {
- const {baseElement} = render()
- expect(baseElement).toBe(document.body)
-})
+ test('works great with react portals', () => {
+ class MyPortal extends React.Component {
+ constructor(...args) {
+ super(...args)
+ this.portalNode = document.createElement('div')
+ this.portalNode.dataset.testid = 'my-portal'
+ }
+ componentDidMount() {
+ document.body.appendChild(this.portalNode)
+ }
+ componentWillUnmount() {
+ this.portalNode.parentNode.removeChild(this.portalNode)
+ }
+ render() {
+ return ReactDOM.createPortal(
+ ,
+ this.portalNode,
+ )
+ }
+ }
-test('supports fragments', () => {
- class Test extends React.Component {
- render() {
+ function Greet({greeting, subject}) {
return (
- DocumentFragment
is pretty cool!
+
+ {greeting} {subject}
+
)
}
- }
- const {asFragment} = render( )
- expect(asFragment()).toMatchSnapshot()
-})
+ const {unmount} = render( )
+ expect(screen.getByText('Hello World')).toBeInTheDocument()
+ const portalNode = screen.getByTestId('my-portal')
+ expect(portalNode).toBeInTheDocument()
+ unmount()
+ expect(portalNode).not.toBeInTheDocument()
+ })
-test('renders options.wrapper around node', () => {
- const WrapperComponent = ({children}) => (
- {children}
- )
+ test('returns baseElement which defaults to document.body', () => {
+ const {baseElement} = render()
+ expect(baseElement).toBe(document.body)
+ })
+
+ test('supports fragments', () => {
+ class Test extends React.Component {
+ render() {
+ return (
+
+ DocumentFragment
is pretty cool!
+
+ )
+ }
+ }
- const {container} = render(, {
- wrapper: WrapperComponent,
+ const {asFragment} = render( )
+ expect(asFragment()).toMatchSnapshot()
})
- expect(screen.getByTestId('wrapper')).toBeInTheDocument()
- expect(container.firstChild).toMatchInlineSnapshot(`
+ test('renders options.wrapper around node', () => {
+ const WrapperComponent = ({children}) => (
+ {children}
+ )
+
+ const {container} = render(, {
+ wrapper: WrapperComponent,
+ })
+
+ expect(screen.getByTestId('wrapper')).toBeInTheDocument()
+ expect(container.firstChild).toMatchInlineSnapshot(`
@@ -87,102 +103,138 @@ test('renders options.wrapper around node', () => {
/>
`)
-})
+ })
-test('flushes useEffect cleanup functions sync on unmount()', () => {
- const spy = jest.fn()
- function Component() {
- React.useEffect(() => spy, [])
- return null
- }
- const {unmount} = render( )
- expect(spy).toHaveBeenCalledTimes(0)
+ test('renders options.wrapper around node when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
- unmount()
+ const WrapperComponent = ({children}) => (
+ {children}
+ )
+ const {container} = render(, {
+ wrapper: WrapperComponent,
+ })
- expect(spy).toHaveBeenCalledTimes(1)
-})
+ expect(screen.getByTestId('wrapper')).toBeInTheDocument()
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+
+ `)
+ })
+
+ test('renders twice when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
-test('can be called multiple times on the same container', () => {
- const container = document.createElement('div')
+ const spy = jest.fn()
+ function Component() {
+ spy()
+ return null
+ }
- const {unmount} = render(, {container})
+ render( )
+ expect(spy).toHaveBeenCalledTimes(2)
+ })
- expect(container).toContainHTML('')
+ test('flushes useEffect cleanup functions sync on unmount()', () => {
+ const spy = jest.fn()
+ function Component() {
+ React.useEffect(() => spy, [])
+ return null
+ }
+ const {unmount} = render( )
+ expect(spy).toHaveBeenCalledTimes(0)
- render(, {container})
+ unmount()
- expect(container).toContainHTML('')
+ expect(spy).toHaveBeenCalledTimes(1)
+ })
- unmount()
+ test('can be called multiple times on the same container', () => {
+ const container = document.createElement('div')
- expect(container).toBeEmptyDOMElement()
-})
+ const {unmount} = render(, {container})
-test('hydrate will make the UI interactive', () => {
- function App() {
- const [clicked, handleClick] = React.useReducer(n => n + 1, 0)
+ expect(container).toContainHTML('')
- return (
-
- )
- }
- const ui =
- const container = document.createElement('div')
- document.body.appendChild(container)
- container.innerHTML = ReactDOMServer.renderToString(ui)
+ render(, {container})
- expect(container).toHaveTextContent('clicked:0')
+ expect(container).toContainHTML('')
- render(ui, {container, hydrate: true})
+ unmount()
- fireEvent.click(container.querySelector('button'))
+ expect(container).toBeEmptyDOMElement()
+ })
- expect(container).toHaveTextContent('clicked:1')
-})
+ test('hydrate will make the UI interactive', () => {
+ function App() {
+ const [clicked, handleClick] = React.useReducer(n => n + 1, 0)
-test('hydrate can have a wrapper', () => {
- const wrapperComponentMountEffect = jest.fn()
- function WrapperComponent({children}) {
- React.useEffect(() => {
- wrapperComponentMountEffect()
- })
+ return (
+
+ )
+ }
+ const ui =
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ container.innerHTML = ReactDOMServer.renderToString(ui)
- return children
- }
- const ui =
- const container = document.createElement('div')
- document.body.appendChild(container)
- container.innerHTML = ReactDOMServer.renderToString(ui)
+ expect(container).toHaveTextContent('clicked:0')
- render(ui, {container, hydrate: true, wrapper: WrapperComponent})
+ render(ui, {container, hydrate: true})
- expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(1)
-})
+ fireEvent.click(container.querySelector('button'))
-test('legacyRoot uses legacy ReactDOM.render', () => {
- expect(() => {
- render(, {legacyRoot: true})
- }).toErrorDev(
- [
- "Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
- ],
- {withoutStack: true},
- )
-})
+ expect(container).toHaveTextContent('clicked:1')
+ })
+
+ test('hydrate can have a wrapper', () => {
+ const wrapperComponentMountEffect = jest.fn()
+ function WrapperComponent({children}) {
+ React.useEffect(() => {
+ wrapperComponentMountEffect()
+ })
+
+ return children
+ }
+ const ui =
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ container.innerHTML = ReactDOMServer.renderToString(ui)
-test('legacyRoot uses legacy ReactDOM.hydrate', () => {
- const ui =
- const container = document.createElement('div')
- container.innerHTML = ReactDOMServer.renderToString(ui)
- expect(() => {
- render(ui, {container, hydrate: true, legacyRoot: true})
- }).toErrorDev(
- [
- "Warning: ReactDOM.hydrate is no longer supported in React 18. Use hydrateRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
- ],
- {withoutStack: true},
- )
+ render(ui, {container, hydrate: true, wrapper: WrapperComponent})
+
+ expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(1)
+ })
+
+ test('legacyRoot uses legacy ReactDOM.render', () => {
+ expect(() => {
+ render(, {legacyRoot: true})
+ }).toErrorDev(
+ [
+ "Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
+ ],
+ {withoutStack: true},
+ )
+ })
+
+ test('legacyRoot uses legacy ReactDOM.hydrate', () => {
+ const ui =
+ const container = document.createElement('div')
+ container.innerHTML = ReactDOMServer.renderToString(ui)
+ expect(() => {
+ render(ui, {container, hydrate: true, legacyRoot: true})
+ }).toErrorDev(
+ [
+ "Warning: ReactDOM.hydrate is no longer supported in React 18. Use hydrateRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
+ ],
+ {withoutStack: true},
+ )
+ })
})
diff --git a/src/__tests__/rerender.js b/src/__tests__/rerender.js
index be3c259c..6c48c4dd 100644
--- a/src/__tests__/rerender.js
+++ b/src/__tests__/rerender.js
@@ -1,31 +1,98 @@
import * as React from 'react'
-import {render} from '../'
-
-test('rerender will re-render the element', () => {
- const Greeting = props => {props.message}
- const {container, rerender} = render( )
- expect(container.firstChild).toHaveTextContent('hi')
- rerender( )
- expect(container.firstChild).toHaveTextContent('hey')
-})
+import {render, configure} from '../'
+
+describe('rerender API', () => {
+ let originalConfig
+ beforeEach(() => {
+ // Grab the existing configuration so we can restore
+ // it at the end of the test
+ configure(existingConfig => {
+ originalConfig = existingConfig
+ // Don't change the existing config
+ return {}
+ })
+ })
+
+ afterEach(() => {
+ configure(originalConfig)
+ })
+
+ test('rerender will re-render the element', () => {
+ const Greeting = props => {props.message}
+ const {container, rerender} = render( )
+ expect(container.firstChild).toHaveTextContent('hi')
+ rerender( )
+ expect(container.firstChild).toHaveTextContent('hey')
+ })
+
+ test('hydrate will not update props until next render', () => {
+ const initialInputElement = document.createElement('input')
+ const container = document.createElement('div')
+ container.appendChild(initialInputElement)
+ document.body.appendChild(container)
+
+ const firstValue = 'hello'
+ initialInputElement.value = firstValue
-test('hydrate will not update props until next render', () => {
- const initialInputElement = document.createElement('input')
- const container = document.createElement('div')
- container.appendChild(initialInputElement)
- document.body.appendChild(container)
+ const {rerender} = render( null} />, {
+ container,
+ hydrate: true,
+ })
- const firstValue = 'hello'
- initialInputElement.value = firstValue
+ expect(initialInputElement).toHaveValue(firstValue)
- const {rerender} = render( null} />, {
- container,
- hydrate: true,
+ const secondValue = 'goodbye'
+ rerender( null} />)
+ expect(initialInputElement).toHaveValue(secondValue)
})
- expect(initialInputElement).toHaveValue(firstValue)
+ test('re-renders options.wrapper around node when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
- const secondValue = 'goodbye'
- rerender( null} />)
- expect(initialInputElement).toHaveValue(secondValue)
+ const WrapperComponent = ({children}) => (
+ {children}
+ )
+ const Greeting = props => {props.message}
+ const {container, rerender} = render( , {
+ wrapper: WrapperComponent,
+ })
+
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+ hi
+
+
+ `)
+
+ rerender( )
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+ hey
+
+
+ `)
+ })
+
+ test('re-renders twice when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
+
+ const spy = jest.fn()
+ function Component() {
+ spy()
+ return null
+ }
+
+ const {rerender} = render( )
+ expect(spy).toHaveBeenCalledTimes(2)
+
+ spy.mockClear()
+ rerender( )
+ expect(spy).toHaveBeenCalledTimes(2)
+ })
})
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 00000000..dc8a5035
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,34 @@
+import {
+ getConfig as getConfigDTL,
+ configure as configureDTL,
+} from '@testing-library/dom'
+
+let configForRTL = {
+ reactStrictMode: false,
+}
+
+function getConfig() {
+ return {
+ ...getConfigDTL(),
+ ...configForRTL,
+ }
+}
+
+function configure(newConfig) {
+ if (typeof newConfig === 'function') {
+ // Pass the existing config out to the provided function
+ // and accept a delta in return
+ newConfig = newConfig(getConfig())
+ }
+
+ const {reactStrictMode, ...configForDTL} = newConfig
+
+ configureDTL(configForDTL)
+
+ configForRTL = {
+ ...configForRTL,
+ reactStrictMode,
+ }
+}
+
+export {getConfig, configure}
diff --git a/src/pure.js b/src/pure.js
index 845aede1..3939a11a 100644
--- a/src/pure.js
+++ b/src/pure.js
@@ -11,6 +11,7 @@ import act, {
setReactActEnvironment,
} from './act-compat'
import {fireEvent} from './fire-event'
+import {getConfig, configure} from './config'
function jestFakeTimersAreEnabled() {
/* istanbul ignore else */
@@ -76,6 +77,18 @@ const mountedContainers = new Set()
*/
const mountedRootEntries = []
+function strictModeIfNeeded(innerElement) {
+ return getConfig().reactStrictMode
+ ? React.createElement(React.StrictMode, null, innerElement)
+ : innerElement
+}
+
+function wrapUiIfNeeded(innerElement, wrapperComponent) {
+ return wrapperComponent
+ ? React.createElement(wrapperComponent, null, innerElement)
+ : innerElement
+}
+
function createConcurrentRoot(
container,
{hydrate, ui, wrapper: WrapperComponent},
@@ -85,7 +98,7 @@ function createConcurrentRoot(
act(() => {
root = ReactDOMClient.hydrateRoot(
container,
- WrapperComponent ? React.createElement(WrapperComponent, null, ui) : ui,
+ strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)),
)
})
} else {
@@ -129,16 +142,17 @@ function renderRoot(
ui,
{baseElement, container, hydrate, queries, root, wrapper: WrapperComponent},
) {
- const wrapUiIfNeeded = innerElement =>
- WrapperComponent
- ? React.createElement(WrapperComponent, null, innerElement)
- : innerElement
-
act(() => {
if (hydrate) {
- root.hydrate(wrapUiIfNeeded(ui), container)
+ root.hydrate(
+ strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)),
+ container,
+ )
} else {
- root.render(wrapUiIfNeeded(ui), container)
+ root.render(
+ strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)),
+ container,
+ )
}
})
@@ -157,10 +171,11 @@ function renderRoot(
})
},
rerender: rerenderUi => {
- renderRoot(wrapUiIfNeeded(rerenderUi), {
+ renderRoot(rerenderUi, {
container,
baseElement,
root,
+ wrapper: WrapperComponent,
})
// Intentionally do not return anything to avoid unnecessarily complicating the API.
// folks can use all the same utilities we return in the first place that are bound to the container
@@ -276,6 +291,6 @@ function renderHook(renderCallback, options = {}) {
// just re-export everything from dom-testing-library
export * from '@testing-library/dom'
-export {render, renderHook, cleanup, act, fireEvent}
+export {render, renderHook, cleanup, act, fireEvent, getConfig, configure}
/* eslint func-name-matching:0 */
diff --git a/types/index.d.ts b/types/index.d.ts
index 558edfad..1f1135c5 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -5,12 +5,25 @@ import {
Queries,
BoundFunction,
prettyFormat,
+ Config as ConfigDTL,
} from '@testing-library/dom'
import {Renderer} from 'react-dom'
import {act as reactAct} from 'react-dom/test-utils'
export * from '@testing-library/dom'
+export interface Config extends ConfigDTL {
+ reactStrictMode: boolean
+}
+
+export interface ConfigFn {
+ (existingConfig: Config): Partial
+}
+
+export function configure(configDelta: ConfigFn | Partial): void
+
+export function getConfig(): Config
+
export type RenderResult<
Q extends Queries = typeof queries,
Container extends Element | DocumentFragment = HTMLElement,
diff --git a/types/test.tsx b/types/test.tsx
index c33f07b6..3486a9a8 100644
--- a/types/test.tsx
+++ b/types/test.tsx
@@ -62,6 +62,28 @@ export function testFireEvent() {
fireEvent.click(container)
}
+export function testConfigure() {
+ // test for DTL's config
+ pure.configure({testIdAttribute: 'foobar'})
+ pure.configure(existingConfig => ({
+ testIdAttribute: `modified-${existingConfig.testIdAttribute}`,
+ }))
+
+ // test for RTL's config
+ pure.configure({reactStrictMode: true})
+ pure.configure(existingConfig => ({
+ reactStrictMode: !existingConfig.reactStrictMode,
+ }))
+}
+
+export function testGetConfig() {
+ // test for DTL's config
+ pure.getConfig().testIdAttribute
+
+ // test for RTL's config
+ pure.getConfig().reactStrictMode
+}
+
export function testDebug() {
const {debug, getAllByTestId} = render(
<>