3

I have a project using React for the view layer. To test it, I am using mocha, karma, karma-webpack, etc. For some reason, in React 16+, karma is reporting the afterEach as having run three times for two specs. This happens only in React 16+ and only when process.env.NODE_ENV is development and not production.

In previous explorations of this issue, the cause(s) of spec failures could cascade and pollute subsequent specs. To help identify the underlying cause, this is the simplest example I can find.

I have tried to trace the behavior, but have been stumped by the complexity within and around karma and sockets. Consider the example below, available for the moment at https://github.com/jneander/react-mocha.

Example.js

import React, {Component} from 'react'

export default class Example extends Component {
  render() {
    try {
      return (
        <div>{this.props.foo.bar}</div>
      )
    } catch(e) {
      console.log(e) // for logging purposes
      throw e
    }
  }
}

Example.spec.js

import {expect} from 'chai'
import React from 'react'
import ReactDOM from 'react-dom'

class ExampleWrapper extends React.Component {
  constructor(props) {
    super(props)

    this.state = {
      error: false
    }
  }

  componentDidCatch(error) {
    console.log('there was a problem')
    this.setState({
      error: true
    })
  }

  render() {
    console.log('rendering!')
    if (this.state.error) {
      console.log('- rendering the error version')
      return <div>An error occurred during render</div>
    }

    console.log('- rendering the real version')
    return (
      <Example {...this.props} />
    )
  }
}

import Example from './Example'

describe('Example', () => {
  let $container

  beforeEach(() => {
    console.log('beforeEach')
    $container = document.createElement('div')
    document.body.appendChild($container)
  })

  afterEach(() => {
    console.log('afterEach')
    ReactDOM.unmountComponentAtNode($container)
    $container.remove()
  })

  async function mount(props) {
    return new Promise((resolve, reject) => {
      const done = () => {
        console.log('done rendering')
        resolve()
      }
      ReactDOM.render(<ExampleWrapper {...props} />, $container, done)
    })
  }

  it('fails this spec', async () => {
    console.log('start test 1')
    await mount({})
    expect(true).to.be.true
  })

  it('also fails, but because of the first spec', async () => {
    console.log('start test 2')
    await mount({foo: {}})
    expect(true).to.be.true
  })
})

The spec output is below:

LOG LOG: 'beforeEach'
LOG LOG: 'start test 1'
LOG LOG: 'rendering!'
LOG LOG: '- rendering the real version'

  Example
    ✗ fails this spec
  Error: Uncaught TypeError: Cannot read property 'bar' of undefined (src/Example.spec.js:35380)
      at Object.invokeGuardedCallbackDev (src/Example.spec.js:16547:16)
      at invokeGuardedCallback (src/Example.spec.js:16600:31)
      at replayUnitOfWork (src/Example.spec.js:31930:5)
      at renderRoot (src/Example.spec.js:32733:11)
      at performWorkOnRoot (src/Example.spec.js:33572:7)
      at performWork (src/Example.spec.js:33480:7)
      at performSyncWork (src/Example.spec.js:33452:3)
      at requestWork (src/Example.spec.js:33340:5)
      at scheduleWork (src/Example.spec.js:33134:5)

ERROR LOG: 'The above error occurred in the <Example> component:
    in Example (created by ExampleWrapper)
    in ExampleWrapper

React will try to recreate this component tree from scratch using the error boundary you provided, ExampleWrapper.'
LOG LOG: 'there was a problem'
LOG LOG: 'done rendering'
LOG LOG: 'rendering!'
LOG LOG: '- rendering the error version'
LOG LOG: 'afterEach'
LOG LOG: 'beforeEach'
LOG LOG: 'start test 2'
LOG LOG: 'rendering!'
LOG LOG: '- rendering the real version'
LOG LOG: 'done rendering'
    ✓ also fails, but because of the first spec
    ✓ also fails, but because of the first spec
LOG LOG: 'afterEach'
LOG LOG: 'afterEach'

Chrome 69.0.3497 (Mac OS X 10.13.6): Executed 3 of 2 (1 FAILED) (0.014 secs / NaN secs)
TOTAL: 1 FAILED, 2 SUCCESS


1) fails this spec
     Example
     Error: Uncaught TypeError: Cannot read property 'bar' of undefined (src/Example.spec.js:35380)
    at Object.invokeGuardedCallbackDev (src/Example.spec.js:16547:16)
    at invokeGuardedCallback (src/Example.spec.js:16600:31)
    at replayUnitOfWork (src/Example.spec.js:31930:5)
    at renderRoot (src/Example.spec.js:32733:11)
    at performWorkOnRoot (src/Example.spec.js:33572:7)
    at performWork (src/Example.spec.js:33480:7)
    at performSyncWork (src/Example.spec.js:33452:3)
    at requestWork (src/Example.spec.js:33340:5)
    at scheduleWork (src/Example.spec.js:33134:5)

What is causing the duplicate reports?

Why does this happen in React 16+ and not in React 15?

How can I resolve this?

jneander
  • 1,150
  • 14
  • 34
  • What I am observing with the latest efforts is that Mocha acknowledges that a promise is returned from the test function, but then does not wait for it to actually resolve before continuing on with the rest of the suite. This only happens when rendering with React, not when returning a simple, native Promise with a setTimeout inside. – jneander Sep 17 '18 at 17:01

2 Answers2

0

There may be race conditions because a promise is resolved with ref function. That component ref has been received doesn't mean that initial rendering has been completed.

As the reference states,

If you need a reference to the root ReactComponent instance, the preferred solution is to attach a callback ref to the root element.

A proper way to resolve a promise is to use render callback parameter,

If the optional callback is provided, it will be executed after the component is rendered or updated.

It should be:

async function mount(props) {
  return new Promise(resolve => {
    ReactDOM.render(<Example {...props} />, $container, resolve)
  })
}

The problem doesn't occur in second test, it occurs in first test regardless of whether there is second test and is not specific to React 16.5. It is specific to how React development mode works.

Here's a simplified demo that excludes Mocha factor. Expected errors are console.warn output but two Error: Cannot read property 'bar' of undefined errors are console.error which are being output by React itself. ReactDOM.render runs component render function twice and outputs an error from first test asynchronously.

The same demo with production build of React shows a single Error: Cannot read property 'bar' of undefined error synchronously as it would be expected. Failed render doesn't make ReactDOM render to reject, an error can be caught by error boundary component if needed:

class EB extends Component {
  componentDidCatch(err) {
    this.props.onCatch(err);
  }

  render() {
    return this.props.children;
  }
}

async function mount(props) {
  return new Promise((resolve, reject) => {
    ReactDOM.render(<EB onCatch={reject}><Example {...props} /></EB>, $container, resolve)
  })
}

It's a good practice to not rely on React DOM renderer in unit tests. Enzyme serves this purpose and allows to test components synchronously in isolation, shallow wrapper in particular.

Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • The use of `enzyme` is a long, separate conversation. It is not an option, as it does not sufficiently exercise code under test. For consistency with real user conditions, ReactDOM is preferred. That aside, moving the promise resolve callback to the ReactDOM.render callback does seem to resolve the issue. I thought I had tried that before with no change, but must have be mistaken. I tried a lot of different things with this little nugget. – jneander Sep 07 '18 at 16:46
  • Followup: with that change made, the `afterEach` callback runs three times, causing the spec count to be inaccurate. Other issues might be present, but masked. Any idea as to why that might be happening with this particular example? – jneander Sep 07 '18 at 16:53
  • @jneander The consistency with real user conditions should usually be tested in blackbox e2e tests, apart from app's React nature. While unit tests are to test units in isolation and narrow down possible problems. That's why Enzyme usually works. Consider asking another question if you're concerned with Enzyme, your problems may be solvable in conventional way. Any way, using production build of React will be closer to real conditions, and it may be desirable to catch render errors. I updated the answer with error boundary example. – Estus Flask Sep 07 '18 at 16:59
  • I don't see any reasons for afterEach problem in this case, at least from the code that was shown. Is it known that beforeEach runs 2 times and afterEach 3 times? How did you test that? – Estus Flask Sep 07 '18 at 17:02
  • Good ol' `console.log` statements in both the `beforeEach` and `afterEach`. `beforeEach` is hit twice. `afterEach` is hit thrice. As a result, mocha seems tricked into thinking there are three tests instead of just two. – jneander Sep 07 '18 at 17:06
  • It's hard to believe that such thing can happen with Mocha, I'm not aware of any conditions under which afterEach could run without actual test. A more probable scenario is that a suite runs twice but fails early for some reason, or something like that. Make sure you run `console.log` first because if a block fails, counts will be imprecise. – Estus Flask Sep 07 '18 at 17:10
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/179650/discussion-between-jneander-and-estus). – jneander Sep 07 '18 at 17:17
  • I have no explanations for that. Can you provide a way to replicate the probem, a repo, etc? – Estus Flask Sep 07 '18 at 17:28
  • I uploaded this code to https://github.com/jneander/react-mocha, but will not keep that repo around after we can get to the bottom of this. Have a look and see if you can make heads or tails of it. – jneander Sep 07 '18 at 20:02
  • Sure. I see. You used React dev build. This doesn't happen with prod build. I'm not sure what happens there, it's unclear whether the problem takes place in Karma or Mocha. Likely has something to do with how React handles errors. It sets up global `error` event listener, this is likely a crosscutting concern. Use production build, and I'd suggest to stick to Enzyme for leaner unit tests because it helps to avoid nonsense situations like this one. – Estus Flask Sep 07 '18 at 21:17
  • `enzyme` is a dealbreaker, as it prevents exercising some behavior by completely faking it. Using ReactDOM gets us as close to production behavior as we can get. The issue present here appears only after React 15.6.2 and only with `process.env.NODE_ENV === 'development'`. Being able to work around this behavior without the obfuscation which comes with the production code would be preferred. – jneander Sep 07 '18 at 21:38
  • By the way, your help is very much appreciated. The production/development distinction would likely never have occurred to me. – jneander Sep 07 '18 at 21:39
  • As I mentioned, usually all tests that test real behaviour are performed as E2E blackbox tests (Protractor/Testcafe, not Mocha) so this problem doesn't exist for for 99% devs, me included. Yes, these are React 16 changes, seem to have something to do with fibers. I wasn't aware of the difference too, but here it's obvious. – Estus Flask Sep 07 '18 at 21:48
  • I would recommend to isolate the issue (could possibly be recreated with Mocha alone with JSDOM) and report it to React repo. Both React and testing framework seem to do something hacky with error handling, but since React isn't expected to be hacky I'd blame it on it. Btw the repo had errors, it puts requirements on global Karma and Chrome, also there were problems with Karma config that prevented it from running. And there's no globals.js. Had to disable source maps and switched to Puppeteer. – Estus Flask Sep 07 '18 at 21:50
0

It seems that React 16+ surfaces an uncaught error during render, even when using componentDidCatch in a wrapper. This means that Mocha will fail the test with the uncaught error, then continue with the next test, after which the second render of the component will complete and resolve the promise, running an assertion. This runs within the test currently in progress, causing the double success seen in this example.

An issue has been opened with the React repo on Github.

jneander
  • 1,150
  • 14
  • 34