Does Jest reset the JSDOM document after every suite or test?
Asked Answered
Q

4

54

I'm testing a couple of components that reach outside of their DOM structure when mounting and unmounting to provide specific interaction capability that wouldn't be possible otherwise.

I'm using Jest and the default JSDOM initialization to create a browser-like environment within node. I couldn't find anything in the documentation to suggest that Jest reset JSDOM after every test execution, and there's no explicit documentation on how to do that manually if that is not the case.

My question is, does Jest reset the JSDOM instance after every test, suite or does it keep a single instance of JSDOM across all test runs? If so, how can I control it?

Quodlibet answered 15/3, 2017 at 9:0 Comment(1)
More about this issue, and some ideas to deal with it: github.com/facebook/jest/issues/1224Proboscis
K
86

To correct the (misleading) accepted answer and explicitly underline that very important bit of information from one of the previous comments:

No. Jest does not clean the JSDOM document after each test run! It only clears the DOM after all tests inside an entire file are completed.

That means that you have to manually cleanup your resources created during a test, after every single test run. Otherwise it will cause shared state, which results in very subtle errors that can be incredibly hard to track.

The following is a very simple, yet effective method to cleanup the JSDOM after each single test inside a jest test suite:

describe('my test suite', () => {
  afterEach(() => {
    document.getElementsByTagName('html')[0].innerHTML = ''; 
  });

  // your tests here ...
});
Kind answered 11/6, 2018 at 14:48 Comment(4)
FWIW, I'm using this to clean up JSDOM document after each run: document.body.innerHTML = ''; document.head.innerHTML = ''. Works well for simple cases.Shaylynn
What if some tests add classes or other attributes to the html element? Is there any simple way to clean those up?Proboscis
show me your jsdom.ts or jsdom.js file...where you created the instanceRecumbent
I've come to realise this when I mocked some functions from the document object, only to have those mocks persist into other tests even when I want them removed. I've did jest.clearAllMocks(); const dom = new JSDOM(); global.document = document = dom.window.document; global.window = window = dom.window; inside of my beforeEach with no luck :(Stpeter
G
6

Rico Pfaus is right, though I found that resetting the innerHTML the way he suggests was too destructive and caused tests to fail. Instead, I found selecting a specific element (by class or id) I want to remove from the document more effective.

describe('my test suite', () => {
    afterEach(() => {
        document.querySelector(SOME CLASS OR ID).innerHTML = ''
    })
})
Gilligan answered 6/12, 2018 at 12:40 Comment(1)
Props to this answer.Hydrotherapeutics
L
5

This is still an issue for many people — and it's the top answer in Google — so I wanted to provide some context from the future ;)

does it keep a single instance of JSDOM across all test runs

Yes, the jsdom instance remains the same across all test runs within the same file

If so, how can I control it?

Long story short: you'll need to manage DOM cleanup yourself.

There is a helpful Github issue on facebook/jest that provides more context and solutions. Here's a summary:

  1. if you want a new jsdom instance then separate your tests into separate files. This is not ideal for obvious reasons...
  2. you can set .innerHTML = '' on the HTML element as mentioned in the accepted answer. That will resolve most issues but the window object will remain the same. Window properties (like event listeners) can persist in subsequent tests and cause unexpected errors.
  3. cleanup the jsdom instance between tests. The jsdom cleanup function doesn't do anything magic — it's basically resetting global properties. Here's an example directly from the Github issue:

const sideEffects = {
  document: {
    addEventListener: {
      fn: document.addEventListener,
      refs: [],
    },
    keys: Object.keys(document),
  },
  window: {
    addEventListener: {
      fn: window.addEventListener,
      refs: [],
    },
    keys: Object.keys(window),
  },
};

// Lifecycle Hooks
// -----------------------------------------------------------------------------
beforeAll(async () => {
  // Spy addEventListener
  ['document', 'window'].forEach(obj => {
    const fn = sideEffects[obj].addEventListener.fn;
    const refs = sideEffects[obj].addEventListener.refs;

    function addEventListenerSpy(type, listener, options) {
      // Store listener reference so it can be removed during reset
      refs.push({ type, listener, options });
      // Call original window.addEventListener
      fn(type, listener, options);
    }

    // Add to default key array to prevent removal during reset
    sideEffects[obj].keys.push('addEventListener');

    // Replace addEventListener with mock
    global[obj].addEventListener = addEventListenerSpy;
  });
});

// Reset JSDOM. This attempts to remove side effects from tests, however it does
// not reset all changes made to globals like the window and document
// objects. Tests requiring a full JSDOM reset should be stored in separate
// files, which is only way to do a complete JSDOM reset with Jest.
beforeEach(async () => {
  const rootElm = document.documentElement;

  // Remove attributes on root element
  [...rootElm.attributes].forEach(attr => rootElm.removeAttribute(attr.name));

  // Remove elements (faster than setting innerHTML)
  while (rootElm.firstChild) {
    rootElm.removeChild(rootElm.firstChild);
  }

  // Remove global listeners and keys
  ['document', 'window'].forEach(obj => {
    const refs = sideEffects[obj].addEventListener.refs;

    // Listeners
    while (refs.length) {
      const { type, listener, options } = refs.pop();
      global[obj].removeEventListener(type, listener, options);
    }

    // Keys
    Object.keys(global[obj])
      .filter(key => !sideEffects[obj].keys.includes(key))
      .forEach(key => {
        delete global[obj][key];
      });
  });

  // Restore base elements
  rootElm.innerHTML = '<head></head><body></body>';
});

For those interested, this is the soft-reset I'm using in "jest.setup-tests.js" which does the following:

  • Removes event listeners added to document and window during tests
  • Removes keys added to document and window object during tests
  • Remove attributes on <html> element
  • Removes all DOM elements
  • Resets document.documentElement HTML to <head></head><body></body>

— @jhildenbiddle

Lamphere answered 14/7, 2022 at 17:22 Comment(0)
C
0

Yes, if you are using react-testing-library and jest, then react-testing-library will run its cleanup function afterEach test. This is because the docs at https://testing-library.com/docs/react-testing-library/api/#cleanup say:

This is called automatically if your testing framework (such as mocha, Jest or Jasmine) injects a global afterEach() function into the testing environment. If not, you will need to call cleanup() after each test.

However, in my case i was using bun's test runner instead of jest, which does not inject a global afterEach function, so i had to call cleanup in afterEach myself:

import { afterEach, test } from "bun:test";
import { cleanup, render } from '@testing-library/react'

afterEach(cleanup)

test("render first time", () => {
  render(<p>Hello</p>)
});

test("render second time", () => {
  // tests are idempotent, because cleanup is run afterEach
  render(<p>Hello</p>)
});
Cirilla answered 21/6 at 10:54 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.