いろいろコロナの影響が出てきましたが、いかがお過ごしでしょうか。会社でも東京勤務は基本的に在宅となりました。

今まで、ReactとかのIntegration testにはenzymeを使っていましたが、Preactに切り替えた際に課題が多発したため、なんとかして解決してみました。

Preact + Enzymeの課題

Enzymeは元々jsdomとセットで利用していたのですが、 React -> Preact に切り替えたとき、仕様の違いによってテストが動かなかったです。

主な違いとしては、

といったあたりです。特に既存のtestでshallow renderingを多用しており、大抵は動くのですが、 React.createPortal を使っているところが全滅でした。

testing-library

React.createPortal および Preactの createPortal ですが、いずれも libraryの管理範囲外のDOMにrenderingする というcomponentを構成します。

React.jsの場合、EnzymeのAdapter側でかなり色々やって対応しているようですが、Preactの場合、 Componentを完全にrenderingする というそもそもの仕様から、対応が無理っぽいです。ちゃんと見ていませんが、third partyがlibraryの内部実装に依存している、というのはあまりよろしくないように感じます。

また、よく考えると、jsdomとはいえ実DOMにrenderingするということは、もう それはIntegration Testじゃないのか? という意見を見て、 ( ゚д゚)ハッ! となりました。そんな折に見つけたのが、testing-libraryです。

公式によると、testing-libraryは次のようなソリューションを提供するlibraryです。

The Solution

The Testing Library family of libraries is a very light-weight solution for testing without all the implementation details. The main utilities it provides involve querying for nodes similarly to how users would find them. In this way, testing-library helps ensure your tests give you confidence in your UI code.

Enzymeなどのtesting libraryが、component library(React/Vue/Angularなど)のinstance、component instanceという実装を直接さわるという機能を提供していることに対するcounter partという感じでしょうか。確かに、最終的には全部DOMとしてrenderingされないと、ユーザーからアクセスできません。eventの発行も、propsのevent handlerを直接発行するというのはユーザーは行えないはずです。

testing-library は、各component libraryに対しても同様のAPIを提供することで、どのcomponent libraryを利用しているかの影響を減らし、実世界と同様のoperationでテストすることを可能にします。

使ってみよう、testing-library

testing-libraryは、Jestと一緒に利用することで、jsdomのsetupとかをしなくてもテストを書けるようになっています。

$ yarn add @testing-library/preact preact jest jest-environment-jsdom

簡単なテストケースを書いてみます。

import {h} from "preact";
import {render, fireEvent} from "@testing-library/preact";

const Component = ({onInput}) => (
    <div data-testid="container">
      <input data-testid="input" onInput={(e) => onInput(e.target.value)}>
    </div>
);

test("render component", async () => {
  const queries = render(<Component onInput={(v) => console.log(v)} />);

  const element = await queries.findByTestId("input");
  expect(element).toBeDefined();

  fireEvent.input(element, {target: {value: "foo"}})
})

render からは、queriesと呼ばれる関数群が返されます。このqueriesは、 @testing-library/preact からもexportされていますが、それとの違いは containerとなるDOM要素を指定する必要があるかどうか です。 queries関数の種類は、公式ページに定義が書いてあります。

testing-libraryでは、classやidというような属性でqueryすることを推奨せず、 data-testid という属性を利用することを推奨しています。(optionで利用する属性を変更できます) data属性は、元々プログラムから利用することを念頭に置かれているため、test用途でも当然使えます。また、class名の変更やDOMの構造に影響されづらいこともあり、テストが壊れづらいというのも利点です。

portalを使う場合のテスト

ReactやPreactでは、モーダルダイアログのようなものをそれぞれのAPIでコントロールするため、portalという仕組みを提供しています。しかしモーダルダイアログは、その仕様上React/Preactの管理外のDOMを必要とします。また、portalを利用してrenderingされたものは、管理外のDOMに対してrenderingされるため、enzymeとかでもテストがしづらいです。

import {h} from "preact";
import {createPortal} from "preact/compat";
import {render, fireEvent, findByTestId} from "@testing-library/preact";

const Component = ({onInput, element}) =>
      createPortal(
          <div data-testid="container">
            <input data-testid="input" onInput={(e) => onInput(e.target.value)}>
          </div>,
        element
      );


test("render component", async () => {
  const element = document.createElement('div');
  render(<Component onInput={(v) => console.log(v)} element={element} />);

  const element = await findByTestId(element, "input");
  expect(element).toBeDefined();

  fireEvent.input(element, {target: {value: "foo"}})
})

createPortalを利用したcomponentをrenderでDOMに対してrenderingした場合、 render から返ってくるqueryではなく、 @testing-library/* からexportされているqueryを使う必要があります。しかし、全体を通して特定のAPIに影響されていないことが見て取れると思います。

componentのtestを良くしていこう

testing-libraryを使うと、propsの onXxx を実行して〜というのはイレギュラーである、というのがよくわかります。かなり深いcomponentにあるinputを取り出すのはいいのか?という意見もあると思いますし、個人的にも最初はいまいちピンときませんでした。ただ、結局inputのonInputとかと繋がっていないと意味がない、ということを考えると、 Custom componentを一つでも含んでいるComponentのテストは、Integration Testなんだ と考えるに至りました。

無論、現在Enzymeを使っていて問題になっていない、とかtesting-libraryと意見の相違がある、というのであれば、無理して使う必要はないと思います。ただ、なんかcomponentのpropsを取得したりすることに違和感を感じる方は、一回触ってみてはいかがでしょうか。