fkhadra / react-toastify

React notification made easy 🚀 !
https://fkhadra.github.io/react-toastify/introduction
MIT License
12.72k stars 700 forks source link

Offer sync rendering for testing purposes #198

Closed leanne2 closed 5 years ago

leanne2 commented 6 years ago

I'm trying to test my implementations of toasts, namely the rendering (using snapshots but this is not especially relevant). The problem is that if i call the toast function in my test then render the output the toasts are not present in the output. I believe this might be because the library is event driven and so async (though could be wrong). What i would like is access to an API that allows me to test the rendered output of the call to toast synchronously. Because toast exports only a function (not React components) its not clear how this can be achieved with the library currently. Could you consider either a) documenting how this can be achieved with the existing API without hacks such as setTimeout being required in tests, or b) update the API / library to provide some test utils for testing notification rendering synchronously?

Here is my test implementation. Note the snapshots are written using chai-jest-snapshot

Sync tests

// Test spec
it('correctly renders a success notification', () => {
    class App extends Component {
      notify = () => {
        toast("Wow so easy !")
      };
      render(){
        return (
          <div>
            {this.notify()}
            <ToastContainer />
          </div>
        );
      }
    }

    wrapper = mount(<App />);
    expect(toJson(wrapper)).to.matchSnapshot();
  });

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`correctly renders a success notification 1`] = `
<App>
  <div>
    <ToastContainer
      autoClose={5000}
      bodyClassName={null}
      className={null}
      closeButton={
        <CloseButton
          ariaLabel="close"
        />
      }
      closeOnClick={true}
      draggable={true}
      draggablePercent={80}
      hideProgressBar={false}
      newestOnTop={false}
      pauseOnHover={true}
      pauseOnVisibilityChange={true}
      position="top-right"
      progressClassName={null}
      rtl={false}
      style={null}
      toastClassName={null}
      transition={[Function]}
    >
      <div
        className="Toastify"
      />
    </ToastContainer>
  </div>
</App>
`;

Async tests

// Test spec
 it('correctly renders a success notification', () => {
    class App extends Component {
      notify = () => {
        toast("Wow so easy !")
      };
      render(){
        return (
          <div>
            {this.notify()}
            <ToastContainer />
          </div>
        );
      }
    }

    wrapper = mount(<App />);

    // Note async - not feasible real-world solution
    setTimeout(() => {
      wrapper.update();
      expect(toJson(wrapper)).to.matchSnapshot();
    }, 5);
  });

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`correctly renders a warning notification 1`] = `
<App>
  <div>
    <ToastContainer
      autoClose={5000}
      bodyClassName={null}
      className={null}
      closeButton={
        <CloseButton
          ariaLabel="close"
        />
      }
      closeOnClick={true}
      draggable={true}
      draggablePercent={80}
      hideProgressBar={false}
      newestOnTop={false}
      pauseOnHover={true}
      pauseOnVisibilityChange={true}
      position="top-right"
      progressClassName={null}
      rtl={false}
      style={null}
      toastClassName={null}
      transition={[Function]}
    >
      <div
        className="Toastify"
      >
        <TransitionGroup
          childFactory={[Function]}
          className="Toastify__toast-container Toastify__toast-container--top-right"
          component="div"
          key="container-top-right"
          style={Object {}}
        >
          <div
            className="Toastify__toast-container Toastify__toast-container--top-right"
            style={Object {}}
          >
            <Toast
              autoClose={5000}
              bodyClassName={null}
              className={null}
              closeButton={
                <CloseButton
                  ariaLabel="close"
                  closeToast={[Function]}
                  type="default"
                />
              }
              closeOnClick={true}
              closeToast={[Function]}
              draggable={true}
              draggablePercent={80}
              hideProgressBar={false}
              id={1}
              in={true}
              isDocumentHidden={false}
              key=".$toast-1"
              onClose={[Function]}
              onExited={[Function]}
              onOpen={[Function]}
              pauseOnHover={true}
              position="top-right"
              progressClassName={null}
              role="alert"
              rtl={false}
              transition={[Function]}
              type="default"
              updateId={null}
            >
              <Animation
                appear={true}
                in={true}
                onExited={[Function]}
                position="top-right"
                preventExitTransition={false}
                unmountOnExit={true}
              >
                <Transition
                  appear={true}
                  enter={true}
                  exit={true}
                  in={true}
                  mountOnEnter={false}
                  onEnter={[Function]}
                  onEntered={[Function]}
                  onEntering={[Function]}
                  onExit={[Function]}
                  onExited={[Function]}
                  onExiting={[Function]}
                  timeout={
                    Object {
                      "enter": 750,
                      "exit": 750,
                    }
                  }
                  unmountOnExit={true}
                >
                  <div
                    className="Toastify__toast Toastify__toast--default"
                    onClick={[Function]}
                    onMouseDown={[Function]}
                    onMouseEnter={[Function]}
                    onMouseLeave={[Function]}
                    onTouchStart={[Function]}
                    onTransitionEnd={[Function]}
                  >
                    <div
                      className="Toastify__toast-body"
                      role="alert"
                    >
                      Wow so easy !
                    </div>
                    <CloseButton
                      ariaLabel="close"
                      closeToast={[Function]}
                      type="default"
                    >
                      <button
                        aria-label="close"
                        className="Toastify__close-button Toastify__close-button--default"
                        onClick={[Function]}
                        type="button"
                      >
                        ✖
                      </button>
                    </CloseButton>
                    <ProgressBar
                      className={null}
                      closeToast={[Function]}
                      delay={5000}
                      hide={false}
                      isRunning={true}
                      rtl={false}
                      type="default"
                    >
                      <div
                        className="Toastify__progress-bar Toastify__progress-bar--default"
                        onAnimationEnd={[Function]}
                        style={
                          Object {
                            "animationDuration": "5000ms",
                            "animationPlayState": "running",
                            "opacity": 1,
                          }
                        }
                      />
                    </ProgressBar>
                  </div>
                </Transition>
              </Animation>
            </Toast>
          </div>
        </TransitionGroup>
      </div>
    </ToastContainer>
  </div>
</App>
`;
fkhadra commented 6 years ago

Hello @leanne2,

Jest offer a way to test async/callback. In your case you gonna need to use timer-mock. Internally the event manager use setTimeout.

could you try the following:

jest.useFakeTimers();

it('correctly renders a success notification', () => {
    class App extends Component {
      notify = () => {
        toast("Wow so easy !")
      };
      render(){
        return (
          <div>
            {this.notify()}
            <ToastContainer />
          </div>
        );
      }
    }

    wrapper = mount(<App />);
    jest.runAllTimers();
    expect(toJson(wrapper)).to.matchSnapshot();
  });

You can even write the test as follow:

jest.useFakeTimers();

it('correctly renders a success notification', () => {
    class App extends Component {
      render(){
        return (
          <div>
            <ToastContainer />
          </div>
        );
      }
    }

    wrapper = mount(<App />);
  toast("Wow so easy !");
    jest.runAllTimers();
    expect(toJson(wrapper)).to.matchSnapshot();
  });

Tell me if it's helped you.

leanne2 commented 6 years ago

@fkhadra Thanks for your response. I am not using the Jest framework, I'm using Mocha. But sinon's fakeTimer seems to be the same util as Jest's fake timers. If I use a fakeTimer, as shown below, the snapshot does correctly generate, but only on a first clean test run. By this i mean, if i run up my tests once then exit, the toast is rendered and the snapshot outputs the full node tree as expected. However, the problem is if i run my tests in watch mode, the first test correctly generates the shapshot, but subsequent re-runs triggered by unrelated code changes causes the test to fail because as before the toast does not get rendered. So this is not a feasible solution on its own. I also noticed that the integer value returned by the call to toast increments on each test run. This suggests some clean up is not happening even though I am unmounting the component that renders the ToastContainer at the end of each test. I have used Mocha / Sinon extensively to write tests for code using fakeTimers and have not seen this problem before so don't believe the issue is with the test framework.

Here is the updated test spec which works correctly only on each clean test run:

it('correctly renders a success notification', () => {
    const clock = sinon.useFakeTimers();
    class App extends Component {
      notify = () => {
        const t = toast("Wow so easy !");
        console.log('######## t', t); // Increments on each test run: 1, 2, 3, so some cleanup is needed
      };
      render(){
        return (
          <div>
            {this.notify()}
            <ToastContainer />
          </div>
        );
      }
    }
    wrapper = mount(<App />);
    clock.tick(5);
    wrapper.update();
    expect(toJson(wrapper)).to.matchSnapshot();
    clock.restore();
    wrapper.unmount();
  });
fkhadra commented 6 years ago

Hello @leanne2 ,

I also noticed that the integer value returned by the call to toast increments on each test run. This suggests some clean up is not happening even though I am unmounting the component that renders the ToastContainer at the end of each test.

You're right. I'll work on fix to to reset the id on umount.

Could you share your package.json with the relevant packages for testing. I want to setup the same test env as yours.

Thanks a lot.

leanne2 commented 6 years ago

@fkhadra Update: If I set my test up using the second route you suggest then interestingly the snapshot persists correctly between test runs, however the snapshots now fail because, as noted above, the id of the toast increments on each run.

Here is updated test setup:

With this setup the snapshot renders consistently between each run, but the snapshot fails as the id increments on each run:

  it('correctly renders a success notification', () => {
    const clock = sinon.useFakeTimers();
    class App extends Component {
      render(){
        return (
          <div>
            <ToastContainer />
          </div>
        );
      }
    }
    wrapper = mount(<App />);
    toast("Wow so easy !");
    clock.tick(5);
    wrapper.update();
    expect(toJson(wrapper)).to.matchSnapshot();
    clock.restore();
    wrapper.unmount();
  });
# snaphot failure output
 AssertionError: expected value to match snapshot correctly renders a success notification 1
      + expected - actual
                     closeToast={[Function]}
                     draggable={true}
                     draggablePercent={80}
                     hideProgressBar={false}
      -              id={4}
      +              id={1}
                     in={true}
                     isDocumentHidden={false}
      -              key=".$toast-4"
      +              key=".$toast-1"
                     onClose={[Function]}
                     onExited={[Function]}
                     onOpen={[Function]}
                     pauseOnHover={true}
leanne2 commented 6 years ago

@fkhadra Great thanks yes will do that below. Note I am now calling toast outside of the React app, as you suggested though the <ToastContainer /> is still rendered inside React, so will the unmount reset the id of the toast?

leanne2 commented 6 years ago

Test setup

package.json:

{
  "scripts": {
    "test:unit:client": "BABEL_ENV=test mocha --opts configuration/mocha/mocha.client.opts client/**/*.test.js",
    "tdd": "npm run test:unit:client -- --watch"
  },
  "dependencies": {
    "react": "^16.2.0",
    "react-dom": "^16.2.0",
    "react-toastify": "^4.0.0"
  },
  "devDependencies": {
    "babel-polyfill": "^6.26.0",
    "babel-register": "^6.26.0",
    "chai": "^4.1.2",
    "chai-enzyme": "^1.0.0-beta.1",
    "chai-jest-snapshot": "^2.0.0",
    "dirty-chai": "^2.0.1",
    "enzyme": "^3.3.0",
    "enzyme-adapter-react-16": "^1.1.1",
    "enzyme-to-json": "^3.3.4",
    "jsdom": "^11.11.0",
    "mocha": "^5.2.0",
    "sinon": "^6.0.0"
  }
}

configuration/mocha/mocha.client.opts:

--colors
--check-leaks
--require babel-register
--require babel-polyfill
--globals SOCKET_URI
--require configuration/mocha/setup.dom.js
configuration/mocha/setup.test.js

configuration/mocha/setup.dom.js:

const { JSDOM } = require('jsdom');

global.window = new JSDOM('').window;
global.document = global.window.document;

Object.keys(global.document.defaultView).forEach((property) => {
  if (typeof global[property] === 'undefined') {
    global[property] = global.document.defaultView[property];
  }
});

global.navigator = {
  userAgent: 'node.js',
};

global.HTMLElement = global.window.HTMLElement;

global.requestAnimationFrame = (callback) => {
  setTimeout(callback, 0);
};

configuration/mocha/setup.test.js:

import chai from 'chai';
import chaiEnzyme from 'chai-enzyme';
import dirtyChai from 'dirty-chai';
import chaiJestSnapshot from 'chai-jest-snapshot';
import sinon from 'sinon';
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

global.chai = chai;
global.expect = chai.expect;
global.sinon = sinon;

Enzyme.configure({ adapter: new Adapter() });

chai.use(chaiEnzyme());

// jest-snapshot integration
chai.use(chaiJestSnapshot);
chai.use(dirtyChai);

/* global before beforeEach */
before(function globalBefore() {
  chaiJestSnapshot.resetSnapshotRegistry();
});

beforeEach(function globalBeforeEach() {
  const { title, file } = this.currentTest;
  chaiJestSnapshot.setFilename(file.replace('test.js', 'snap'));
  chaiJestSnapshot.setTestName(title);
});

notification/notification.test.js:

import React, { Component } from 'react';
import { ToastContainer, toast } from 'react-toastify';
import { mount } from 'enzyme';
import toJson from 'enzyme-to-json';

describe('@Component - notify', () => {
  let wrapper;

  it('correctly renders a success notification', () => {
    const clock = sinon.useFakeTimers();
    class App extends Component {
      render(){
        return (
          <div>
            <ToastContainer />
          </div>
        );
      }
    }
    wrapper = mount(<App />);
    toast("Wow so easy !");
    clock.tick(5);
    wrapper.update();
    expect(toJson(wrapper)).to.matchSnapshot();
    clock.restore();
    wrapper.unmount();
  });
});

If i have missed anything or you have any issues setting this up give me a shout

fkhadra commented 6 years ago

Indeed, the unmount should reset the id of the toast, at least this is what I plan to do 😁.

Also, notice that with the following implementation, your are calling toast before the ToastContainer is mounted(first route):

render(){
        return (
          <div>
            {this.notify() /* same as doing toast('hello')*/}
            <ToastContainer />
          </div>
        );
      }

In that case, when the container is not mounted the notification are stacked and rendered as soon as the container is mounted. This explain the inconsistency for your test regardless the toastId issue.

fkhadra commented 6 years ago

Hey @leanne2 ,

The v4.2 clean all the reference and reset the id when the ToastContainer is umounted.

leanne2 commented 6 years ago

Thanks for the update. Unfortunately there is still the same issue in my test suite. If i run my tests up they all pass first runs. However in both single-run and watch mode subsequent re runs cause test failures due to the id and toast key incrementing from 1 - 2 between test runs for example:

1) @Component - Notification
       correctly renders a success notification:

      AssertionError: expected value to match snapshot correctly renders a success notification 1
      + expected - actual

                     closeToast={[Function]}
                     draggable={false}
                     draggablePercent={0}
                     hideProgressBar={true}
      -              id={2}
      +              id={1}
                     in={true}
      -              key=".$toast-2"
      +              key=".$toast-1"
                     onClose={[Function]}
                     onExited={[Function]}
                     onOpen={[Function]}
                     pauseOnFocusLoss={true}

      at Context.<anonymous> (src/components/Notification/Notification.test.js:57:32)

The tests that fail and the number that fail changes on each run but the failure reason on each is always the same. This points to a race condition. Is your teardown doing something async? My test setup is as below. You can see I am unmounting the <ToastContainer /> after each test run:

describe(@Component - Notification', () => {
  let wrapper;
  let clock;
  const sandbox = sinon.createSandbox();

  class App extends Component {
    render() {
      return (
        <div>
          <ToastContainer />
        </div>
      );
    }
  }

  beforeEach(() => {
    clock = sandbox.useFakeTimers();
    wrapper = mount(<App />);
  });

  afterEach(() => {
    sandbox.restore();
    wrapper.unmount();
  });

  it('renders string children nested in a paragraph', () => {
    notifySuccess('Success!');
    clock.tick(5);
    wrapper.update();
    expect(toJson(wrapper)).to.matchSnapshot();
  });

  it('renders node children without a paragraph', () => {
    notifySuccess(<div>Success!</div>);
    clock.tick(5);
    wrapper.update();
    expect(toJson(wrapper)).to.matchSnapshot();
  });

  it('passes status prop to notification body', () => {
    notifySuccess(<strong>Success!</strong>);
    clock.tick(5);
    wrapper.update();
    expect(toJson(wrapper)).to.matchSnapshot();
  });

  it('correctly renders a success notification', () => {
    notifySuccess('Success!');
    clock.tick(5);
    wrapper.update();
    expect(toJson(wrapper)).to.matchSnapshot();
  });

  it('correctly renders an info notification', () => {
    notifyInfo('Some information');
    clock.tick(5);
    wrapper.update();
    expect(toJson(wrapper)).to.matchSnapshot();
  });

  it('correctly renders a warning notification', () => {
    notifyWarning('Something not quite right!');
    clock.tick(5);
    wrapper.update();
    expect(toJson(wrapper)).to.matchSnapshot();
  });

  it('correctly renders a failure notification', () => {
    notifyFailure('There was an error!');
    clock.tick(5);
    wrapper.update();
    expect(toJson(wrapper)).to.matchSnapshot();
  });
});

The functions e.g. notifySuccess simply delegate to calling a pre-configured toast() call. So i am still unable to run tests against the toasts. I cannot share my repo but the steps are as follows:

fkhadra commented 6 years ago

@leanne2 I reopened the issue. Thanks for the update

rohan-naik commented 4 years ago

Hello, I've made a common component sort of thing for Rendering the toast. This is what it returns.

<>
     {toast.error(displayToast,{
           hideProgressBar: hideProgressBar,
          position: position,
          closeOnClick:closeOnClick,
          draggable:draggable
     })}
     <ToastContainer/>
</>

I'm trying to write test cases for the above snippet where I just run the method that triggers the Toast

e.g:- Toast() will trigger the above function.

I'm testing it using ReactTestUtils and jest .

The test that I have written is :

describe('Notification', () => {
    it('should render Default Notification', () => {
        const result = Toast({displayContent:{text:'Without ProgressBar'},notifyProps:{ hideProgressBar:true },type:'default'})
        const tree = renderer.create(result);
        console.log(tree.toJSON());
    })
})

and when I try console log the tree.toJSON() this is what it returns

[
      'qcwt2sybdd',
      {
        type: 'div',
        props: { className: 'Toastify', id: undefined },
        children: null
      }
    ]

How do I test what is being rendered in the toast? Or what is the text that toast renders?