30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

React Test Renderer の createNodeMock で ref 属性のモックを作る

React Test Renderercreateメソッドは、オプションでcreateNodeMockという機能を使うことができる。
ref属性を使っているコンポーネントのテストに便利なのだが、初見では理解しづらい機能だったので、整理しておく。

React Test Rendererについてはこちらを参照。
numb86-tech.hatenablog.com

ref属性の基本については、こちらを参照。
numb86-tech.hatenablog.com

この記事の動作確認には React のv16.10.2を使用しており、テスティングフレームワークには Jest を、アサーションライブラリには power-assert を、それぞれ使っている。

HTML 要素に設定した ref 属性が機能しない

テスト対象のコンポーネントとして、以下のSampleコンポーネントを用意した。

const Sample = () => {
  const [state, setState] = useState('');
  const ref = useRef();
  useEffect(() => {
    setState(ref.current.textContent);
  }, []);

  return (
    <div>
      <span data-test="original" ref={ref}>
        text1
      </span>
      <span data-test="copy">{state}</span>
    </div>
  );
};

ひとつ目のspan要素のref属性にrefを渡しているため、マウントされた時点でref.currentにはこのspan要素が格納されている。
そのため、useEffect実行時点でref.current.textContenttext1という文字列を指すようになっており、stateにもこの文字列が入る。
その結果、ふたつ目のspan要素のtextContentも、text1になる。

このコンポーネントのテストコードが、以下。

describe('Sample', () => {
  it('original のテキストと copy のテキストが一致する', () => {
    let testRenderer;
    act(() => {
      testRenderer = create(<Sample />);
    });

    const testInstance = testRenderer.root;

    assert.strictEqual(
      testInstance.findByProps({'data-test': 'copy'}).props.children,
      testInstance.findByProps({'data-test': 'original'}).props.children
    );
  });
});

ロジックは何も間違っていない。
だがこのテストは、以下のエラーを出して失敗してしまう。

TypeError: Cannot read property 'textContent' of null

つまり、ref.currentの値がnullになってしまっている。
useRefに引数を渡さなかった場合のref.currentの初期値はundefinedなので、その後、useEffectが実行されるまでの間に、span要素ではなくnullが格納されていることになる。
これは恐らく、React Test Rendererは React コンポーネントを DOM ではなく JavaScript オブジェクトに変換するので、span要素の DOM が存在せず、それによってnullが格納されたのではないかと思う。

ともかく、ref属性を使ったコンポーネントはReact Test Rendererでは上手く動かない、ということが分かった。

ref のモックを作る

refに限らないが、プロダクション環境での動作とテスト環境での動作が異なってしまう場合、モックを使って対応することが多い。
テスト環境では正しく動かない、あるいは動かすのが難しい部分をモックに置き換えて、他の部分が正しく動作するかをテストする。

今回のSampleにおいては、refのモックを作り、マウント時にref.current.textContentの内容が DOM に反映されているかどうかをテストすることにした。

そのため、refのモックは、以下のようなオブジェクトになる。

{
  current: /* ひとつ目の span 要素 */
}

これを用意して、それをSample内で使っているRefオブジェクトと置き換えることが出来れば、上手くテストを書けるはず。
そしてcreateNodeMockを使うことで、refのモックを作ることができる。

createNodeMock の使い方

createNodeMockというメソッドを持ったオブジェクトをcreateメソッドの第二引数に渡す、がcreateNodeMockの使い方なのだが、コードを見たほうが早い。

create(<Sample />, {
  createNodeMock: element => element,
});

createNodeMockメソッドの引数には、ref属性を設定した要素が入る。
そして、このメソッドの返り値が、ref属性に渡したRefオブジェクトのcurrentプロパティに設定される。
そのため、useEffect時点でのrefの中身は、このようになる。

{
  current: {
    type: 'span',
    props: { 'data-test': 'original', children: 'text1' }
  }
}

だがこのままでは、プロダクション環境でのrefの構造とは異なっておりtextContentを取得できないため、createNodeMockの返り値を変える。

create(<Sample />, {
  createNodeMock: element => ({
    ...element,
    textContent: element.props.children,
  }),
});

これでtextContentを取得できるようになる。

実際のコードは以下のようになる。

import React, {useState, useRef, useEffect} from 'react';
import {create, act} from 'react-test-renderer';
import assert from 'assert';

const Sample = () => {
  const [state, setState] = useState('');
  const ref = useRef();
  useEffect(() => {
    setState(ref.current.textContent);
  }, []);

  return (
    <div>
      <span data-test="original" ref={ref}>
        text1
      </span>
      <span data-test="copy">{state}</span>
    </div>
  );
};

describe('Sample', () => {
  it('original のテキストと copy のテキストが一致する', () => {
    let testRenderer;
    act(() => {
      testRenderer = create(<Sample />, {
        createNodeMock: element => ({
          ...element,
          textContent: element.props.children,
        }),
      });
    });

    const testInstance = testRenderer.root;

    assert.strictEqual(
      testInstance.findByProps({'data-test': 'copy'}).props.children,
      testInstance.findByProps({'data-test': 'original'}).props.children
    );
  });
});

これで無事にSampleのテストを書くことができた。

callback ref

ref属性には、Refオブジェクトではなく関数を渡すこともできる。
その場合、Refオブジェクトを渡したときにはref.currentに格納されていた値が関数の引数として渡されるが、createNodeMockはこのパターンでも問題なく動く。
つまり、createNodeMockメソッドの返り値が、ref属性に設定した関数の引数になる。

そのため、Sampleを以下のように書き換えても、先程のテストはパスする。

const Sample = () => {
  const [state, setState] = useState('');

  const callbackRef = ref => {
    if (ref !== null) {
      setState(ref.textContent);
    }
  };

  return (
    <div>
      <span data-test="original" ref={callbackRef}>
        text1
      </span>
      <span data-test="copy">{state}</span>
    </div>
  );
};

ref 属性を使った要素が複数ある場合

複数の要素でref属性を使っていた場合、それぞれに対してcreateNodeMockメソッドが実行される。
そのため、それぞれのRefオブジェクト、もしくはコールバック関数に、対応した値が渡される。

import React, {useState, useRef, useEffect} from 'react';
import {create, act} from 'react-test-renderer';
import assert from 'assert';

const Sample = () => {
  const [state, setState] = useState('');

  const leftRef = useRef();
  const rightRef = useRef();

  useEffect(() => {
    setState(leftRef.current.textContent + rightRef.current.textContent);
  }, []);

  return (
    <div>
      <span data-test="left" ref={leftRef}>
        foo
      </span>
      +
      <span data-test="right" ref={rightRef}>
        bar
      </span>
      =<span data-test="result">{state}</span>
    </div>
  );
};

describe('Sample', () => {
  it('left + right = result', () => {
    let testRenderer;
    act(() => {
      testRenderer = create(<Sample />, {
        createNodeMock: element => ({
          ...element,
          textContent: element.props.children,
        }),
      });
    });

    const testInstance = testRenderer.root;
    const leftText = testInstance.findByProps({'data-test': 'left'}).props
      .children;
    const rightText = testInstance.findByProps({'data-test': 'right'}).props
      .children;
    const resultText = testInstance.findByProps({'data-test': 'result'}).props
      .children;

    assert.strictEqual(resultText, `${leftText}${rightText}`);
  });
});

参考資料