4

What I'm trying to do

I am trying to write tests for a piece of code that tries to

  1. run immediately if the document is ready, or otherwise;
  2. run when the document has finished loading.

It looks something like this:

// this is inside a function that returns a Promise
// and it resolves when `foo()` is called
if (document.readyState === "complete") {
  foo()
} else {
  window.addEventListener("load", () => foo());
}

Assuming myFunction() returns a Promise that is only resolved after foo() is called. Ideally, I'd want to write a test like this:

// put the document in "loading" mode
document.readyState = "loading"

// use a flag to keep track of whether promise has resolved
let complete = false
myFunction().then(() => complete = true)

// check to see that promise has not resolved yet
expect(complete).toBe(false)

// trigger load event and then check to see that the promise is complete
document.triggerLoadEvent()
expect(complete).toBe(true)

What I've tried

I know that Jest has a JSDOM environment, so I'm able to test the first case pretty easily. However, I haven't been able to figure out how to test the line:

window.addEventListener("load", () => foo());

I know that there's a jest.spyOn method. But that only tells me that a certain method has been called. It doesn't allow me to simulate the state in which the document has not yet finished loading, and then subsequently trigger the readyState to be "complete".

skyboyer
  • 22,209
  • 7
  • 57
  • 64
adrianmcli
  • 1,956
  • 3
  • 21
  • 49
  • are you asking how to mock `addEventListener()` to be able trigger it in controlled way? take a look into https://stackoverflow.com/questions/41732903/stubbing-window-functions-in-jest – skyboyer Dec 06 '18 at 21:34
  • if you are asking in general how to test that then, well, I'd have two tests. for both cases I'd mock `foo()` so I could ensure if it has been called. and in first test case I'd mock `document.status = 'complete'` and checked if `foo()` has been called. for 2nd case I'd simulate `status = 'loading'`, called function under test, ensured `foo()` has not been called yet, then run `load` event handler directly to ensure `foo()` has been run after. – skyboyer Dec 06 '18 at 21:37

1 Answers1

0

This is a great question, it's actually not as straight forward as some people would make it seem. The issue is that document.readyState property is read only - in both modern browsers as well as js-dom, the mock dom object used by jest.

To work around this take the advice in this post and use Object.defineProperty to overwrite document.readyState. To fully cover my implementation I'm also mocking document.addEventListener, here's an example of how my code leverages the approach :

// MyFunc.ts
export async function myFunc(something): Promise<boolean> {
  return new Promise((resolve) => {
    if (['complete', 'loaded', 'interactive'].includes(document.readyState)) {
      resolve(_helper(something)); // in reality this _helper func is accessing the dom
    } else {
      document.addEventListener('DOMContentLoaded', () =>
        resolve(_helper(something)),
      );
    }
  });
}
// MyFunc.test.ts
import { myFunc } from '../MyFunc';

describe('myFunc', () => {
  const addEventListener = document.addEventListener;
  let mockReadySate = 'complete';
  let eventListenerMock;

  beforeAll(() => {
    // use beforeAll because you can't define twice
    Object.defineProperty(document, 'readyState', {
      get() {
        return mockReadySate;
      },
    });
  });

  beforeEach(() => {
    // create a new mock before each test to reset call count
    eventListenerMock = jest
      .fn()
      .mockImplementation((event, callback) => callback());
    document.addEventListener = eventListenerMock;
  });

  afterEach(() => {
    jest.restoreAllMocks();
    // manually restoring mock so that document.addEventlistener isn't
    // a reference to jest.fn
    document.addEventListener = addEventListener;
  });

  it('myFunc should return correct value if DOMContentLoaded hasnt been fired yet', async () => {
    mockReadySate = 'loading';

    const res = await myFunc('bla');
    expect(res).toEqual(true);

    const res1 = await myFunc('bla bla');
    expect(res1).toEqual(false);

    // in this case document.addEventListener has been called twice for DOMContentLoaded
    expect(eventListenerMock).toHaveBeenCalledTimes(2);
  });

  it('myFunc should return correct value if document.readyState is already complete', async () => {
    mockReadySate = 'complete';

    const res = await myFunc('bla');
    expect(res).toEqual(true);

    const res1 = await myFunc('bla bla');
    expect(res1).toEqual(false);

    // in this case document.addEventListener has been called twice for DOMContentLoaded
    expect(eventListenerMock).toHaveBeenCalledTimes(0);
  });
});

There are a bunch of good Stackoverflow questions related to this, for more context check out :

nameofname
  • 91
  • 5