jsdomを使ってテストしていたらたまに[Symbol(SameObject caches)]と言われてテストが落ちる

なんだか、とりとめもない、解決したけど結局よくわからなかったことを書いておく。これはメモ書きです。


jsdomを使ってDOMの様子を再現するテストを書いていてハマった。テストライブラリはAVAでやっているのでAVAを例に書く。

こんな感じのことを言われた

Expected promise to be rejected, but it was resolved instead

  Resolved with:

  HTMLDivElement {
    [Symbol(SameObject caches)]: {
      childNodes: NodeList {},
    },
  }

本来はDOMが見つからなくてPromiseがrejectされることを期待しているテストケースなのに、DOMがあることになっていてresolveが返ってきてしまっている

背景

AVAのテストはこういう感じにglobalにwindowオブジェクトをブチ込んでいて、

import test from 'ava';
import delay from 'delay';
import jsdom from 'jsdom';
import m from '.';

const dom = new jsdom.JSDOM();
global.window = dom.window;
global.document = dom.window.document;

require('mutationobserver-shim');

global.MutationObserver = window.MutationObserver;

test('FOO1 ?', async t => {
    delay(500).then(() => {
        const el = document.createElement('div');
        el.id = 'foo';
        document.body.appendChild(el);
    });

    const checkEl = await m('#foo');
    t.is(checkEl.id, 'foo');
});

test('FOO2 ?', async t => {
    delay(500).then(() => {
        const el = document.createElement('div');
        el.id = 'foo';
        document.body.appendChild(el);
    });

    const checkEl = await m('#foo');
    t.is(checkEl.id, 'foo');
});

.
.
.

似たようなDOM操作のテストケースが羅列されている状況。

どうやらエラーが起こるテストケースとは別のテストケースで生成したDOMを読んでいるのだと予想した。

実際にlogをみてみる

FOO2テストでコケていると仮定

test('FOO1 ?', async t => {
    delay(500).then(() => {
        const el = document.createElement('div');
        el.id = 'foo';
        document.body.appendChild(el);
    });

    const checkEl = await m('#foo');
    t.is(checkEl.id, 'foo');
});

test('FOO2 ?', async t => {
    delay(500).then(() => {
        const el = document.createElement('div');
        el.id = 'foo';
        document.body.appendChild(el);
    });

    const checkEl = await m('#foo');
    t.is(checkEl.id, 'foo');
});

FOO1で生成した<div id="foo"></div>がFOO2で見えているのではないか?と予想

FOO2でlog

test('FOO2 ?', async t => {
    delay(500).then(() => {
        const el = document.createElement('div');
        el.id = 'foo';
        document.body.appendChild(el);
    });

        console.log(document.querySelector('#foo'))

    const checkEl = await m('#foo');
    t.is(checkEl.id, 'foo');
});

なんとnullが返ってくる! つまりFOO2テストケースではFOO1で生成したDOMは見えてない…

document.bodyをみてみる

console.log(document.querySelector('#foo'))

console.log(document.body)

すると

HTMLBodyElement {
  [Symbol(SameObject caches)]:
   { childNodes:
      NodeList {
        '0': [HTMLDivElement],
        '1': [HTMLParagraphElement],
        '2': [HTMLSpanElement],
        '3': HTMLDivElement {} } } }

なんかいる!Same Object cachesとあるとおりキャッシュっぽい??

対策

AVAのbeforeEachフックで各テスト実行時前にbodyタグ内をリセットしてやる

test.beforeEach(t => {
    // Force resetting the Object caches for each test.
    document.body.remove();
    document.documentElement.appendChild(document.createElement('body'));
});

すると

HTMLBodyElement {}

消えた。

このキャッシュなに?

developer.mozilla.org

取り除かれた子ノードはしばらくは記憶領域上に残りますが、もう DOM の一部ではありません。取り除いたノードは、oldChild オブジェクト参照を通じて、コード中で後で再利用することができます。

Node.removeChildの頁にこういうことが書かれている。

jsdomが内部的にremoveChild()してるのかはちょっとわかってないけど、もしそうならこういうキャッシュがあるらしいし、見えないけど見えるみたいなやつかもしれない。


でもまだ直らなかった

結局のところ、エラーが出ていたFOO2はAVAのt.serial()で同期処理にしてやったらよかった。実際には非同期でPromise待機しつつsetTimeoutっていうかなりややこしい関数をテストしていて、どうやらなにかしら絡み合うものがあるっぽい?AVAは普通にtest()と書くと並列テスト実行なので、どうやらそれの影響っぽかったのでserial()にしてみたら期待通りに通るようになった。

よくわからないまま解決してスッキリはしないけどメモとして書いた。