Skip to content

feat(cleanup): automatically cleanup if afterEach is detected #430

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 9, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"files": [
"dist",
"typings",
"cleanup-after-each.js"
"cleanup-after-each.js",
"pure.js"
],
"keywords": [
"testing",
Expand All @@ -42,12 +43,12 @@
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.5.5",
"@testing-library/dom": "^5.6.1"
"@testing-library/dom": "^6.0.0"
},
"devDependencies": {
"@reach/router": "^1.2.1",
"@testing-library/jest-dom": "^4.0.0",
"@types/react": "^16.8.25",
"@types/react": "^16.9.1",
"@types/react-dom": "^16.8.5",
"kcd-scripts": "^1.5.2",
"react": "^16.9.0",
Expand Down
2 changes: 2 additions & 0 deletions pure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// makes it so people can import from '@testing-library/react/pure'
module.exports = require('./dist/pure')
18 changes: 18 additions & 0 deletions src/__tests__/auto-cleanup-skip.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react'

let render
beforeAll(() => {
process.env.RTL_SKIP_AUTO_CLEANUP = 'true'
const rtl = require('../')
render = rtl.render
})

// This one verifies that if RTL_SKIP_AUTO_CLEANUP is set
// then we DON'T auto-wire up the afterEach for folks
test('first', () => {
render(<div>hi</div>)
})

test('second', () => {
expect(document.body.innerHTML).toEqual('<div><div>hi</div></div>')
})
13 changes: 13 additions & 0 deletions src/__tests__/auto-cleanup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react'
import {render} from '../'

// This just verifies that by importing RTL in an
// environment which supports afterEach (like jest)
// we'll get automatic cleanup between tests.
test('first', () => {
render(<div>hi</div>)
})

test('second', () => {
expect(document.body.innerHTML).toEqual('')
})
163 changes: 13 additions & 150 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,153 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom'
import {
getQueriesForElement,
prettyDOM,
fireEvent as dtlFireEvent,
configure as configureDTL,
} from '@testing-library/dom'
import act, {asyncAct} from './act-compat'

configureDTL({
asyncWrapper: async cb => {
let result
await asyncAct(async () => {
result = await cb()
})
return result
},
})

const mountedContainers = new Set()

function render(
ui,
{
container,
baseElement = container,
queries,
hydrate = false,
wrapper: WrapperComponent,
} = {},
) {
if (!baseElement) {
// default to document.body instead of documentElement to avoid output of potentially-large
// head elements (such as JSS style blocks) in debug output
baseElement = document.body
}
if (!container) {
container = baseElement.appendChild(document.createElement('div'))
}

// we'll add it to the mounted containers regardless of whether it's actually
// added to document.body so the cleanup method works regardless of whether
// they're passing us a custom container or not.
mountedContainers.add(container)

const wrapUiIfNeeded = innerElement =>
WrapperComponent
? React.createElement(WrapperComponent, null, innerElement)
: innerElement

act(() => {
if (hydrate) {
ReactDOM.hydrate(wrapUiIfNeeded(ui), container)
} else {
ReactDOM.render(wrapUiIfNeeded(ui), container)
}
})

return {
container,
baseElement,
// eslint-disable-next-line no-console
debug: (el = baseElement) => console.log(prettyDOM(el)),
unmount: () => ReactDOM.unmountComponentAtNode(container),
rerender: rerenderUi => {
render(wrapUiIfNeeded(rerenderUi), {container, baseElement})
// 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
},
asFragment: () => {
/* istanbul ignore if (jsdom limitation) */
if (typeof document.createRange === 'function') {
return document
.createRange()
.createContextualFragment(container.innerHTML)
}

const template = document.createElement('template')
template.innerHTML = container.innerHTML
return template.content
},
...getQueriesForElement(baseElement, queries),
}
}

function cleanup() {
mountedContainers.forEach(cleanupAtContainer)
}

// maybe one day we'll expose this (perhaps even as a utility returned by render).
// but let's wait until someone asks for it.
function cleanupAtContainer(container) {
ReactDOM.unmountComponentAtNode(container)
if (container.parentNode === document.body) {
document.body.removeChild(container)
}
mountedContainers.delete(container)
}

// react-testing-library's version of fireEvent will call
// dom-testing-library's version of fireEvent wrapped inside
// an "act" call so that after all event callbacks have been
// been called, the resulting useEffect callbacks will also
// be called.
function fireEvent(...args) {
let returnValue
act(() => {
returnValue = dtlFireEvent(...args)
import {asyncAct} from './act-compat'
import {cleanup} from './pure'

// if we're running in a test runner that supports afterEach
// then we'll automatically run cleanup afterEach test
// this ensures that tests run in isolation from each other
// if you don't like this then either import the `pure` module
// or set the RTL_SKIP_AUTO_CLEANUP env variable to 'true'.
if (typeof afterEach === 'function' && !process.env.RTL_SKIP_AUTO_CLEANUP) {
afterEach(async () => {
await asyncAct(async () => {})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider - if ther are any hanging promises after a test is finished, that probably means the user hasn't asserted on the actual stable state of the UI. So this would hide a probable bug.

Copy link
Contributor

@threepointone threepointone Aug 9, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this is a similar concern with the async act() in cleanup-after-each, ouch. at least there it's explicit tho.

Copy link
Member Author

@kentcdodds kentcdodds Aug 9, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if there are any hanging promises after a test is finished, that probably means the user hasn't asserted on the actual stable state of the UI.

I'm not sure I understand how this change impacts that at all. This change is doing exactly the same thing that cleanup-after-each is doing. It's just wiring it up automatically.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this shouldn't be an act(), but it should flush promises anyway. So if there are any stray async updates, the warning will fire, and the dev will account for it in their test.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand how this change impacts that at all. This change is doing exactly the same thing that cleanup-after-each is doing. It's just wiring it up automatically.

I'm suggesting it should be removed from that too.

cleanup()
})
return returnValue
}

Object.keys(dtlFireEvent).forEach(key => {
fireEvent[key] = (...args) => {
let returnValue
act(() => {
returnValue = dtlFireEvent[key](...args)
})
return returnValue
}
})

// React event system tracks native mouseOver/mouseOut events for
// running onMouseEnter/onMouseLeave handlers
// @link https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/events/EnterLeaveEventPlugin.js#L24-L31
fireEvent.mouseEnter = fireEvent.mouseOver
fireEvent.mouseLeave = fireEvent.mouseOut

fireEvent.select = (node, init) => {
// React tracks this event only on focused inputs
node.focus()

// React creates this event when one of the following native events happens
// - contextMenu
// - mouseUp
// - dragEnd
// - keyUp
// - keyDown
// so we can use any here
// @link https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/events/SelectEventPlugin.js#L203-L224
fireEvent.keyUp(node, init)
}

// just re-export everything from dom-testing-library
export * from '@testing-library/dom'
export {render, cleanup, fireEvent, act}

// NOTE: we're not going to export asyncAct because that's our own compatibility
// thing for people using [email protected]. Anyone else doesn't need it and
// people should just upgrade anyway.

/* eslint func-name-matching:0 */
export * from './pure'
153 changes: 153 additions & 0 deletions src/pure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import React from 'react'
import ReactDOM from 'react-dom'
import {
getQueriesForElement,
prettyDOM,
fireEvent as dtlFireEvent,
configure as configureDTL,
} from '@testing-library/dom'
import act, {asyncAct} from './act-compat'

configureDTL({
asyncWrapper: async cb => {
let result
await asyncAct(async () => {
result = await cb()
})
return result
},
})

const mountedContainers = new Set()

function render(
ui,
{
container,
baseElement = container,
queries,
hydrate = false,
wrapper: WrapperComponent,
} = {},
) {
if (!baseElement) {
// default to document.body instead of documentElement to avoid output of potentially-large
// head elements (such as JSS style blocks) in debug output
baseElement = document.body
}
if (!container) {
container = baseElement.appendChild(document.createElement('div'))
}

// we'll add it to the mounted containers regardless of whether it's actually
// added to document.body so the cleanup method works regardless of whether
// they're passing us a custom container or not.
mountedContainers.add(container)

const wrapUiIfNeeded = innerElement =>
WrapperComponent
? React.createElement(WrapperComponent, null, innerElement)
: innerElement

act(() => {
if (hydrate) {
ReactDOM.hydrate(wrapUiIfNeeded(ui), container)
} else {
ReactDOM.render(wrapUiIfNeeded(ui), container)
}
})

return {
container,
baseElement,
// eslint-disable-next-line no-console
debug: (el = baseElement) => console.log(prettyDOM(el)),
unmount: () => ReactDOM.unmountComponentAtNode(container),
rerender: rerenderUi => {
render(wrapUiIfNeeded(rerenderUi), {container, baseElement})
// 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
},
asFragment: () => {
/* istanbul ignore if (jsdom limitation) */
if (typeof document.createRange === 'function') {
return document
.createRange()
.createContextualFragment(container.innerHTML)
}

const template = document.createElement('template')
template.innerHTML = container.innerHTML
return template.content
},
...getQueriesForElement(baseElement, queries),
}
}

function cleanup() {
mountedContainers.forEach(cleanupAtContainer)
}

// maybe one day we'll expose this (perhaps even as a utility returned by render).
// but let's wait until someone asks for it.
function cleanupAtContainer(container) {
ReactDOM.unmountComponentAtNode(container)
if (container.parentNode === document.body) {
document.body.removeChild(container)
}
mountedContainers.delete(container)
}

// react-testing-library's version of fireEvent will call
// dom-testing-library's version of fireEvent wrapped inside
// an "act" call so that after all event callbacks have been
// been called, the resulting useEffect callbacks will also
// be called.
function fireEvent(...args) {
let returnValue
act(() => {
returnValue = dtlFireEvent(...args)
})
return returnValue
}

Object.keys(dtlFireEvent).forEach(key => {
fireEvent[key] = (...args) => {
let returnValue
act(() => {
returnValue = dtlFireEvent[key](...args)
})
return returnValue
}
})

// React event system tracks native mouseOver/mouseOut events for
// running onMouseEnter/onMouseLeave handlers
// @link https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/events/EnterLeaveEventPlugin.js#L24-L31
fireEvent.mouseEnter = fireEvent.mouseOver
fireEvent.mouseLeave = fireEvent.mouseOut

fireEvent.select = (node, init) => {
// React tracks this event only on focused inputs
node.focus()

// React creates this event when one of the following native events happens
// - contextMenu
// - mouseUp
// - dragEnd
// - keyUp
// - keyDown
// so we can use any here
// @link https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/events/SelectEventPlugin.js#L203-L224
fireEvent.keyUp(node, init)
}

// just re-export everything from dom-testing-library
export * from '@testing-library/dom'
export {render, cleanup, fireEvent, act}

// NOTE: we're not going to export asyncAct because that's our own compatibility
// thing for people using [email protected]. Anyone else doesn't need it and
// people should just upgrade anyway.

/* eslint func-name-matching:0 */
5 changes: 0 additions & 5 deletions tests/setup-env.js
Original file line number Diff line number Diff line change
@@ -1,6 +1 @@
import '@testing-library/jest-dom/extend-expect'

afterEach(() => {
// have to do a dynamic import so we don't mess up jest mocking for old-act.js
require('../src').cleanup()
})