ReactコンポーネントのテストにCypress Component Test Runnerが良さそうという話

はじめに

こんにちは、Progateの舘野です。
最近、スクロール位置によってUIの状態が変わるReactコンポーネントのテストをどうしようかと悩む機会がありました。
一般的にはコンポーネントのテストにはReact Testing Libraryを利用すると思いますが、jsdomがレイアウトエンジンを持たないので、要素の寸法や画面上の位置などのレイアウトに関わる情報をテストできない制限があります。
一方で、CypressのようなE2Eテストツールであればブラウザを利用してテストするので、このようなケースでも問題なくテストできますが、実行速度とメンテナンスコストに難があります。

React Testing Library Cypress(E2E)
レイアウトのテストが可能 x
実行速度 速い 遅い
メンテナンスコスト 低い 高い

React Testing Libraryではそもそもテストができないので、実行速度やメンテナンスコストには目を瞑ってCypressのようなE2Eテストツールでテストする以外には選択肢がないかと考えましたが、Cypressについて調べているとCypress Component Test Runnerをv7.0.0でアルファリリースしたという記事がありました。
Cypress Component Test Runnerは、v7以前ではexperimentalComponentTestingフラグを有効にすることで利用できるようになっていたようですが、v7でReact Testing LibraryのようなNode.js環境でのコンポーネントのテストに置き換わるテストツールとしてアルファリリースされたようです。

このComponent Test Runnerは、以下のように今回求めている点をいずれも満たしていると思われますが、実際にReactのコンポーネントを簡易的にテストして試しみることにしました。

React Testing Library Cypress(E2E) Cypress Component Test Runner
レイアウトのテストが可能 x
実行速度 速い 遅い 速い
メンテナンスコスト 低い 高い 低い

Cypress Component Test Runnerでコンポーネントをテストしてみる

Component Test Runnerを試すために、create-react-app(以下CRA)でReactアプリケーションの雛形を用意して、create-cypress-testsでComponent Test Runnerを動かすのに必要なものを揃えます。
その上で、<App />コンポーネントにScrollspy(スクロール位置によってビューポート内に表示されている要素を検知して、どのナビゲーションがアクティブとなるかを示すUI)を実装して、Component Test Runnerでその振る舞いをテストしてみようと思います。

試す環境の用意

テストに使うプロジェクトをCRAで用意します。

$ npx create-react-app cypress-component-test-runner-playground --template typescript

プロジェクトが用意できたらそのプロジェクトのルートディレクトリでcreate-cypress-testsコマンドをnpxで実行します。
これによってComponent Test Runnerで必要になるnpmモジュールのインストールやプラグインの設定、cypress.jsonにComponentテストの設定追加などを行います。
対話形式で、CRAの設定を使うかどうか、コンポーネントのテストにどのディレクトリを利用するかを聞かれるので、それぞれ返答して進めます。

$ npx create-cypress-tests --component-tests --use-npm --ignore-examples

Running cypress 🌲 installation wizard for cypress-component-test-runner-playground@0.1.0

✔ Installing cypress (npm install -D cypress)
⠋ Creating config filesIn order to ignore examples a spec file cypress/integration/spec.ts.
✔ Creating config files

This project is using react. Let's install the right adapter:
✔ Installing @cypress/react (npm install -D @cypress/react)
? It looks like you are using create-react-app.

 Press  Enter  to continue with create-react-app configuration or select another template from the list: create-react-app
? Which folder would you like to use for your component tests? src

Installing required dependencies

✔ Installing @cypress/webpack-dev-server (npm install -D @cypress/webpack-dev-server)

Let's setup everything for component testing with create-react-app:

✅  cypress.json was updated with the following config:

Deprecated as of 10.7.0. highlight(lang, code, ...args) has been deprecated.
Deprecated as of 10.7.0. Please use highlight(code, options) instead.
https://github.com/highlightjs/highlight.js/issues/2277
{
  "componentFolder": "src",
  "testFiles": "**/*.spec.{js,ts,jsx,tsx}"
}

✅  cypress/plugins/index.js was updated with the following config:

const injectDevServer = require('@cypress/react/plugins/react-scripts');

module.exports = (on, config) => {
  if (config.testingType === "component") {
    injectDevServer(on, config);
  }

  return config; // IMPORTANT to return a config
};

Find examples of component tests for create-react-app in https://github.com/cypress-io/cypress/tree/develop/npm/react/examples/react-scripts.

Docs for different recipes of bundling tools: https://github.com/cypress-io/cypress/tree/develop/npm/react/docs/recipes.md

══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════


👍  Success! Cypress is installed and ready to run tests.

  npx cypress open
    Opens cypress local development app.

  npx cypress run
    Runs tests in headless mode.

  npx cypress open-ct
    Opens cypress component-testing web app.

  npx cypress run
    Runs component testing in headless mode.

Happy testing with cypress.io 🌲

create-cypress-testsで設定が完了したらnpx cypress open-ctでComponent Test Runnerが起動することを確認します。

$ npx cypress open-ct

CypressのE2Eテストと同じく--browserオプションで利用するブラウザを指定することもできます。

$ npx cypress open-ct --browser firefox

テストがまだ存在しないので、「No specs found」とだけ表示されますが、問題なくComponent Test Runnerが起動できます。

f:id:makotot-riceball:20210510132627p:plain

Component Test Runnerはこのまま起動しておきます。

テスト対象のコンポーネントの準備

Scrollspyは、Render Propsでchildrenを返すだけのComponentをsrc/Scrollspy.tsxに作っておきます。
sectionRefs propで監視対象の要素のRefObjectを受け取れるようにだけしておきます。

import { RefObject } from 'react';

export const Scrollspy = ({
  children,
  sectionRefs,
}: {
  children: () => JSX.Element;
  sectionRefs: RefObject<Element>[];
}) => {
  return children();
};

src/App.tsxをScrollspyを使った内容に書き換えます。

import { useRef } from "react";
import { Scrollspy } from './Scrollspy'

const SIZE = 5;

function App() {
  const sectionRefs = [
    useRef<HTMLDivElement>(null),
    useRef<HTMLDivElement>(null),
    useRef<HTMLDivElement>(null),
    useRef<HTMLDivElement>(null),
    useRef<HTMLDivElement>(null),
  ];

  return (
    <div className="App">
      <Scrollspy sectionRefs={sectionRefs}>
        {() => (
          <div>
            <ul
              data-cy="nav-wrapper"
              style={{
                listStyle: 'none',
                position: 'fixed',
                top: 0,
                right: 0,
                backgroundColor: '#fff',
                padding: '1rem',
              }}>
              {new Array(SIZE).fill(0).map((_, i) => (
                <li key={i}>
                  <a href={`#section-${i}`} data-cy={`nav-item`}>
                    section-{i}
                  </a>
                </li>
              ))}
            </ul>
            <div data-cy="section-wrapper">
              {new Array(SIZE).fill(0).map((_, i) => (
                <div
                  id={`section-${i}`}
                  data-cy={`section-item`}
                  ref={sectionRefs[i]}
                  key={i}
                  style={{
                    display: 'flex',
                    justifyContent: 'center',
                    alignItems: 'center',
                    height: '500px',
                    backgroundColor: `#${i}${i}${i}`,
                    color: '#fff',
                    fontSize: '2rem',
                  }}>
                  {i}
                </div>
              ))}
            </div>
          </div>
        )}
      </Scrollspy>
    </div>
  );
}

export default App;

テストケースがまだ存在しないのでComponent Test Runnerの方には変化がありませんが、npm run startでアプリケーションの状態を確認すると、適当ですが以下のようにナビゲーションとそのナビゲーションに対応する要素がrenderされていることが確認できます。

f:id:makotot-riceball:20210510132832p:plain

アプリケーションの用意はできたので、簡単に3つほどテストケースを用意します。

import { mount } from '@cypress/react';
import App from './App';

describe('App', () => {
  beforeEach(() => {
    mount(<App />);
    cy.scrollTo(0, 0);
  });

  it('childrenがrenderされること', () => {
    cy.get('[data-cy=nav-wrapper]').children().should('have.length', 5);
    cy.get('[data-cy=section-wrapper]').children().should('have.length', 5);
  });
  it('viewport内の要素に対応するナビゲーションがアクティブになること', () => {
    cy.get('[data-cy=section-item]').eq(0).should('have.class', 'active');
    cy.get('[data-cy=nav-item]').eq(0).should('have.class', 'active');
  });
  it('viewport内に収まる要素が変わったら、対応するナビゲーションがアクティブな状態になること', () => {
    cy.get('[data-cy=section-item]').eq(0).should('have.class', 'active');
    cy.get('[data-cy=nav-item]').eq(0).should('have.class', 'active');

    cy.get('[data-cy=section-item').eq(1).scrollIntoView();

    cy.get('[data-cy=section-item]').eq(1).should('have.class', 'active');
    cy.get('[data-cy=nav-item]').eq(1).should('have.class', 'active');
  });
});

テスト対象のコンポーネントだけをmountに渡してテストするので、無関係と思われる修正によってテストが通らなくなることはE2Eテストに比べると少なく済みそうです。

ここでComponent Test Runnerを確認すると、Scrollspyの振る舞いが実装されていないので以下のように「childrenがrenderされること」だけがテストをパスします。

f:id:makotot-riceball:20210510132951p:plain

テストが通るように実装

Scrollspyの振る舞いをざっと実装して、アクティブと見做せる要素のインデックスをcurrentElementIndexInViewportとしてRender Propsで返すようにします。

-import { RefObject } from 'react';
+import { RefObject, useCallback, useEffect, useMemo, useState } from 'react';
+import throttle from 'lodash/throttle';
+
+const useScrollspy = ({
+  sectionRefs,
+}: {
+  sectionRefs: RefObject<Element>[];
+}) => {
+  const isScrolledToBottom = useCallback(() => {
+    return (
+      document.documentElement.scrollTop + window.innerHeight >=
+      document.body.scrollHeight
+    );
+  }, []);
+  const isElementInViewport = useCallback((element: Element) => {
+    const root = {
+      height: window.innerHeight,
+      scrollTop: document.documentElement.scrollTop,
+      scrollBottom: document.documentElement.scrollTop + window.innerHeight,
+    };
+    const elementRect = element.getBoundingClientRect();
+    const elementScrollTop = root.scrollTop + elementRect.top;
+    const elementScrollBottom = elementScrollTop + elementRect.height;
+
+    return [
+      elementScrollTop < root.scrollBottom,
+      elementScrollBottom > root.scrollTop,
+    ].every((v) => v);
+  }, []);
+
+  const getElementsStatusInViewport = useCallback(() => {
+    return sectionRefs.map((sectionRef) => {
+      if (sectionRef.current) {
+        return isElementInViewport(sectionRef.current);
+      }
+      return false;
+    });
+  }, [isElementInViewport, sectionRefs]);
+
+  const [elementsStatusInViewport, updateElementsStatusInViewport] = useState<
+    boolean[]
+  >([]);
+
+  const currentElementIndexInViewport = useMemo(
+    () => elementsStatusInViewport.findIndex((status) => status),
+    [elementsStatusInViewport]
+  );
+
+  const spy = useCallback(
+    throttle(() => {
+      const newElementsStatusInViewport = isScrolledToBottom()
+        ? [...new Array(sectionRefs.length - 1).fill(false).map((v) => v), true]
+        : getElementsStatusInViewport();
+      updateElementsStatusInViewport(newElementsStatusInViewport);
+    }),
+    [getElementsStatusInViewport, isScrolledToBottom, sectionRefs]
+  );
+
+  useEffect(() => {
+    spy();
+    window.addEventListener('scroll', spy);
+
+    return () => {
+      window.removeEventListener('scroll', spy);
+    };
+  }, [spy]);
+
+  return {
+    currentElementIndexInViewport,
+  };
+};

 export const Scrollspy = ({
   children,
   sectionRefs,
 }: {
-  children: () => JSX.Element;
+  children: ({
+    currentElementIndexInViewport,
+  }: {
+    currentElementIndexInViewport: number;
+  }) => JSX.Element;
   sectionRefs: RefObject<Element>[];
 }) => {
-  return children();
+  const {
+    currentElementIndexInViewport,
+  } = useScrollspy({
+    sectionRefs,
+  });
+
+  return children({
+    currentElementIndexInViewport,
+  });
 };

この時点ではScrollspyコンポーネントからcurrentElementIndexInViewportを返すようにしただけなので、テストの結果には変化ありません。

<App />の方でcurrentElementIndexInViewportと同じインデックスのナビゲーションとそのナビゲーションに対応する要素に対してそれぞれactiveクラスを付与するようにします。

   return (
     <div className="App">
       <Scrollspy sectionRefs={sectionRefs}>
-        {() => (
+        {({ currentElementIndexInViewport }) => (
           <div>
             <ul
               data-cy="nav-wrapper"
...
               }}>
               {new Array(SIZE).fill(0).map((_, i) => (
                 <li key={i}>
-                  <a href={`#section-${i}`} data-cy={`nav-item`}>
+                  <a
+                    href={`#section-${i}`}
+                    data-cy={`nav-item`}
+                    className={
+                      currentElementIndexInViewport === i ? 'active' : ''
+                    }
+                    style={{
+                      color:
+                        currentElementIndexInViewport === i ? '#f00' : '#222',
+                    }}>
                     section-{i}
                   </a>
                 </li>
...
                 <div
                   id={`section-${i}`}
                   data-cy={`section-item`}
                   ref={sectionRefs[i]}
                   key={i}
+                  className={
+                    currentElementIndexInViewport === i ? 'active' : ''
+                  }
                   style={{
                     display: 'flex',
                     justifyContent: 'center',

全てのテストケースがパスするようになったことを確認できます。Component Test Runnerのブラウザの画面をスクロール操作して、Scrollspyの動作が意図通りであることも確認できます。

f:id:makotot-riceball:20210510134229p:plain

Scrollspyのコンポーネントは、getBoundingClientRectなどを利用して今現在どの要素がビューポートに収まるアクティブな要素かを判別するようにしていますが、jsdomベースのReact Testing Libraryなどであれば、レイアウトに関する正確な情報を得られないのでこのようなテストを行うことは難しいかと思います。
その一方で、Component Test Runnerでは本物のブラウザを利用してテストしているので、レイアウトに関する情報も問題なくテストできます。

また、タイムアウトにデフォルトで4000ミリ秒が設定されているので、テストが通らない場合に全てのテストケースを実行するのに8秒ほどかかっていることが画面上部で確認できますが、全てのテストケースにパスするようになると0.2秒程度で完了するようになったことが分かります。
CypressのE2Eテストランナーで同じ内容のテストを実行してみると0.8秒ほどでテスト完了するので実行速度に実感するほどの開きはないですが、テストの量が増えていけばこの差が明確に開いていくと思います。

加えて、ブラウザでテストしていることで通常の開発時と同じようにDevToolsを利用してデバッグできます。
React Testing Libraryであれば、テストが通らない原因の調査が必要な場合screen.debug()を利用するかNode debuggerでデバッグすることになるかと思いますが、ブラウザ上でテストを実行できるComponent Test Runnerであれば、Command LogとDevToolsの組み合わせで原因特定が非常に容易になる感触があります。

f:id:makotot-riceball:20210510135219p:plain

まとめ

レイアウトに関わるコンポーネントのテストについて、個人的に感じていた課題感が解決されるかどうかを試す目的でComponent Test Runnerでテストをしてみましたが、実際に試してみるとレイアウトに関わるかどうかに限らず全てのコンポーネントのテストに非常に良さそうだなと感じました。
レイアウトのテストが可能な点や実行速度の点についてはもちろんですが、テストの実行結果をブラウザ上で確認できて、テストが失敗する原因についてブラウザ上でデバッグして調査できる点についても、これまでのコンポーネントのテストツールとは全く違う体験で、コンポーネントのテストツールとE2Eのテストツールの良いところを併せ持っているようでした。
まだアルファリリースされたばかりなので整備されてない部分もあるようには感じますが、今後も注目していきたいと思います。