Chrome 上で Heroku CI を使用して React アプリケーションをテストする

Web アプリケーションを構築する際、各コンポーネントの単体テストは非常に重要です。しかし、エンドツーエンドの検証を実施することで、複数のコンポーネントが連携した最終的なユーザーエクスペリエンスが期待通りであることを保証できます。ブラウザ上でローカル環境にて Web アプリケーションの動作をテストすることは有益な場合もありますが、アプリケーションが複雑化するにつれ、このアプローチは効率的でも信頼性の高いものでもなくなります。

理想的には、ブラウザ内でのエンドツーエンドテストは自動化され、CI（継続的インテグレーション）パイプラインに統合されているべきでしょう。コードを変更してコミットするたびにテストが実行され、テストが成功すれば、最終ユーザーが体験するアプリケーションが期待通りに動作しているという自信を持つことができるからです。

Heroku CI を使用すると、ヘッドレス Chrome でエンドツーエンドテストを実行できます。 "[Chrome for Testing Heroku Buildpack](https://elements.heroku.com/buildpacks/heroku/heroku-buildpack-chrome-for-testing)" を使用すると、Google Chrome（以下 chrome と呼称）と chromedriver をHeroku アプリケーションにインストールできます。この Heroku Buildpack の詳細はこちらの投稿 (※訳註 : [英語ブログ](https://blog.heroku.com/improved-browser-testing-on-heroku-with-chrome)です）でご確認いただけます。

この記事では、この Heroku Buildpack を使用して、Heroku CI を活用した React アプリケーションの基本的なエンドツーエンドテストを行うためのシンプルな手順を説明します。
(※本記事は、2024/11/6 に公開された [Testing a React App in Chrome with Heroku CI](https://blog.heroku.com/testing-react-app-chrome-heroku-ci) の日本語訳です。)

#  今回説明に用いる React アプリケーションの紹介
今回はシンプルなウォークスルーのために、リンクとフォームだけで構成される単一ページのとてもシンプルな構成の React アプリケーションを作成してみました。フォームには、テキスト入力欄と送信ボタンがあります。ユーザーがテキスト入力欄に名前を入力し、フォームを送信すると、その名前を含む簡単な挨拶文が表示されます。

アプリケーションは次のようになります。

![1726781358-2-greeting-app.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1529732/ff7c776d-834d-d32c-f357-4e6ce05558d4.png)

非常にシンプルですね。しかし、この記事で注目したいのは、アプリケーションのエンドユーザーエクスペリエンスを検証するエンドツーエンドテストの部分です。このアプリケーションを検証するために、[Jest](https://jestjs.io/)（みんな大好き JavaScript テストフレームワーク）と [Puppeteer](https://pptr.dev/)（ChromeまたはFirefoxでヘッドレスブラウザテストを実行するためのライブラリ）を使ってみましょう。

このアプリケーションの簡単なソースコードやテストをダウンロードしたい場合は、下記の GitHub リポジトリをご覧ください。

このページのコードは `src/App.js` にあります。

https://github.com/heroku-examples/chrome-for-testing-example

```react:App.js
import React, { useState } from 'react';
import { Container, Box, TextField, Button, Typography, Link } from '@mui/material';

function App() {
  const [name, setName] = useState('');
  const [greeting, setGreeting] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    setGreeting(`Nice to meet you, ${name}!`);
  };

  return (
    <Container maxWidth="sm" style={{ marginTop: '50px' }}>
      <Box textAlign="center">
        <Typography variant="h4" gutterBottom>
          Welcome to the Greeting App
        </Typography>
        <Link href="https://pptr.dev/" rel="noopener">
          Puppeteer Documentation
        </Link>
        <Box component="form" onSubmit={handleSubmit} mt={3}>
          <TextField
            name="name"
            label="What is your name?"
            variant="outlined"
            fullWidth
            value={name}
            onChange={(e) => setName(e.target.value)}
            margin="normal"
          />
          <Button variant="contained" color="primary" type="submit" fullWidth>
            Say hello to me
          </Button>
        </Box>
        {greeting && (
          <Typography id="greeting" variant="h5" mt={3}>
            {greeting}
          </Typography>
        )}
      </Box>
    </Container>
  );
}

export default App;
```

# ローカル環境でのブラウザ内エンドツーエンドテストの実行
テストは `src/tests/puppeteer.test.js` というファイルに記述されています。このファイルの内容は次のようになります。

```react:puppeteer.test.js
const ROOT_URL = 'http://localhost:8080';

describe('Page tests', () => {
  const inputSelector = 'input[name="name"]';
  const submitButtonSelector = 'button[type="submit"]';
  const greetingSelector = 'h5#greeting';
  const name = 'John Doe';

  beforeEach(async () => {
    await page.goto(ROOT_URL);
  });

  describe('Puppeteer link', () => {
    it('should navigate to Puppeteer documentation page', async () => {
      await page.click('a[href="https://pptr.dev/"]');
      await expect(page.title()).resolves.toMatch('Puppeteer | Puppeteer');
    });
  });

  describe('Text input', () => {
    it('should display the entered text in the text input', async () => {
      await page.type(inputSelector, name);

      // Verify the input value
      const inputValue = await page.$eval(inputSelector, el => el.value);
      expect(inputValue).toBe(name);
    });
  });

  describe('Form submission', () => {
    it('should display the "Hello, X" message after form submission', async () => {
      const expectedGreeting = `Hello, ${name}.`;
      await page.type(inputSelector, name);
      await page.click(submitButtonSelector);

      await page.waitForSelector(greetingSelector);
      const greetingText = await page.$eval(greetingSelector, el => el.textContent);
      expect(greetingText).toBe(expectedGreeting);
    });
  });
});
```

このテストコードについてのポイントは以下の通りです。

* Puppeteer に対して、React アプリケーションが `http://localhost:8080` で動作していることを想定させています。一連のテスト内の各テストで、Puppeteer のページをその URL にアクセスさせます。
* ページ上部のリンクをテストし、クリックすると正しい外部ページ（今回の場合は Puppeteer のドキュメントページ）にリダイレクトすることを確認します。
* テキスト入力欄をテストし、入力した値がフィールドの値として保持されていることを確認します。
* フォームの送信をテストし、テキスト入力欄に値を入力した状態でフォームを送信すると、正しい挨拶メッセージが表示されることを確認します。

テストはシンプルですが、ヘッドレスブラウザテストの基本的な動作を説明するのに十分でしょう。

## package.json に少し変更を加えてみる
このアプリケーションは [Create React App](https://create-react-app.dev/) を使用して作成しました。しかし、開発およびテストプロセスをスムーズにするため、`package.json` ファイルにいくつか変更を加えました。まず、`start` スクリプトを次のように変更しています。

```json
"start": "PORT=8080 BROWSER=none react-scripts start"
```

このスクリプトでは、React アプリケーションを動作させるポート（`8080`）を指定しています。また、`BROWSER=none` を設定し、このスクリプトを実行するたびにブラウザがアプリケーションを開くことを防止しています。ヘッドレステストを CI パイプラインで実行することを目的としているため、ブラウザの自動起動は不要です。

また、`jest` を実行するだけの `test` スクリプトも用意しています。

```json
"test": "jest"
```
## サーバーの起動とテストの実行
サーバーを起動してテストを実行しましょう。まずターミナルを立ち上げ、サーバーを起動します：

```terminal
~/project$ npm run start

Compiled successfully!

You can now view project in the browser.

  Local:            http://localhost:8080
  On Your Network:  http://192.168.86.203:8080

Note that the development build is not optimized.
To create a production build, use npm run build.

webpack compiled successfully
```

React アプリケーションが `http://localhost:8080` 上で動作している状態で、もう一つターミナルを立ち上げ（こちらのターミナルを Window (2) と呼称）、エンドツーエンドテストを実行します：

```terminal
~/project$ npm run test

 FAIL  src/tests/puppeteer.test.js
  Page tests
    Puppeteer link
      ✓ should navigate to Puppeteer documentation page (473 ms)
    Text input
      ✓ should display the entered text in the text input (268 ms)
    Form submission
      ✕ should display the "Hello, X" message after form submission (139 ms)

  ● Page tests › Form submission › should display the "Hello, X" message after form submission

    expect(received).toBe(expected) // Object.is equality

    Expected: "Hello, John Doe."
    Received: "Nice to meet you, John Doe!"

      36 |       await page.waitForSelector(greetingSelector);
      37 |       const greetingText = await page.$eval(greetingSelector, el => el.textContent);
    > 38 |       expect(greetingText).toBe(expectedGreeting);
         |                            ^
      39 |     });
      40 |   });
      41 | });

      at Object.toBe (src/tests/puppeteer.test.js:38:28)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 2 passed, 3 total
Snapshots:   0 total
Time:        1.385 s, estimated 2 s
Ran all test suites.
```

失敗したテストが 1 つあるようです。どうやら挨拶メッセージが間違っているようです。`App.js` 内のコードを修正し、テストを実施したターミナル上で再度テストを実行してみましょう。

```terminal
~/project$ npm run test

> project@0.1.0 test
> jest

 PASS  src/tests/puppeteer.test.js
  Page tests
    Puppeteer link
      ✓ should navigate to Puppeteer documentation page (567 ms)
    Text input
      ✓ should display the entered text in the text input (260 ms)
    Form submission
      ✓ should display the "Hello, X" message after form submission (153 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        1.425 s, estimated 2 s
Ran all test suites.

```

## サーバー起動とテスト実行を結合する
コードを修正し、テストがすべてパスしました。しかし、サーバーの起動とテストの実行は1つのプロセスにまとめるべきです。特に、これをCIパイプラインで実行することを想定している場合はなおさらです。この2つのステップをシリアライズするために、start-server-and-testパッケージを使用します。このパッケージを使用することで、単一のスクリプトコマンドを使ってサーバーを起動し、URLが準備完了になるのを待ち、それからテストを実行することができます。テスト実行が終了すると、サーバーは自動で停止します。

このパッケージをインストールし、`package.json` のスクリプトに新しい行を追加します：

```json:package.json
"test:ci": "start-server-and-test start http://localhost:8080 test"
```

これで、`npm run test:ciを` 実行すると、`start-server-and-test` パッケージが最初に `start` スクリプトを実行してサーバーを起動し、`http://localhost:8080` が利用可能になるのを待機してから、テストスクリプトを実行してみましょう。

以下は、このコマンドをひとつのターミナルウィンドウで実行する様子です：

```terminal
~/project$ npm run test:ci

> project@0.1.0 test:ci
> start-server-and-test start http://localhost:8080 test
1: starting server using command "npm run start"
and when url "[ 'http://localhost:8080' ]" is responding with HTTP status code 200 running tests using command "npm run test"


> project@0.1.0 start
> PORT=8080 BROWSER=none react-scripts start
Starting the development server...
Compiled successfully!
You can now view project in the browser.
 Local:            http://localhost:8080
 On Your Network:  http://172.16.35.18:8080
Note that the development build is not optimized.
To create a production build, use npm run build.
webpack compiled successfully

> project@0.1.0 test
> jest
 PASS  src/tests/puppeteer.test.js
 Page tests
   Puppeteer link
     ✓ should navigate to Puppeteer documentation page (1461 ms)
   Text input
     ✓ should display the entered text in the text input (725 ms)
   Form submission
     ✓ should display the "Hello, X" message after form submission (441 ms)
Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        4.66 s
Ran all test suites.
```

これで、簡略化されたテストプロセスが 1 つのコマンドで実行できるようになり、Heroku CI を使用したヘッドレスブラウザテストの準備が整いました。

# Heroku CIでテストを実行する

Heroku CI でテストプロセスを設定して実行するには、いくつかの簡単なステップを行うだけです。

## app.json ファイルを追加する
コードリポジトリにファイルを追加する必要があります。このファイルはプロジェクトのルートフォルダに配置され、内容は次のようになります：

```json:app.json
{
  "environments": {
    "test": {
      "buildpacks": [
        { "url": "heroku-community/chrome-for-testing" },
        { "url": "heroku/nodejs" }
      ],
      "scripts": {
        "test": "npm run test:ci"
      }
    }
  }
}
```

このファイルでは、プロジェクトに必要なビルドパックを指定します。ここでは、[Chrome for Testing ビルドパック](https://elements.heroku.com/buildpacks/heroku/heroku-buildpack-chrome-for-testing) と [Node.js ビルドパック](https://elements.heroku.com/buildpacks/heroku/heroku-buildpack-nodejs) を追加しています。その後、Herokuがテストスクリプトを実行する際に行うべき内容を指定します。この場合、`package.json` で定義した `test:ci` スクリプトを実行するよう Heroku に指示します。

## Heroku パイプラインを作成する
Heroku ダッシュボードで **New** → **Create new pipeline** をクリックします。

![1726781407-3-create-new-pipeline.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1529732/686c69e0-f094-25da-b998-f62fcddf1de4.png)

パイプラインに名前を付け、関連付ける GitHub リポジトリを検索して選択します。demo リポジトリをフォークし、そのフォークをパイプラインに使用することもできます。

![1726781418-4-create-pipeline.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1529732/57f606db-98a0-9fcb-2ca1-8fc0c9328edc.png)

GitHub リポジトリを見つけたら、**Connect** をクリックし、**Create pipeline** をクリックします。

## パイプラインにアプリケーションを追加する
次に、パイプラインにアプリケーションを追加します。このアプリはパイプラインの *Staging*フェーズに追加します。
![1726781428-5-add-app-to-pipeline.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1529732/8dfb3eb9-3ba9-ac5e-ab59-6e3225ecb144.png)

このアプリケーションでは、すでにパイプラインに接続した GitHub リポジトリを使用します。アプリケーションの名前と地域を選択し、**Create app** をクリックします。

![1726781448-7-create-app.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1529732/8848e66b-dda9-d4fd-c458-9a25f6d03f6a.png)

これで、Heroku アプリケーションがパイプラインに追加され、Heroku CI を操作する準備が整いました。

![1726781456-8-staging-app.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1529732/21a31988-df86-24b2-2251-a35797b59323.png)

## Heroku CI を有効化する
パイプラインページのナビゲーションで **Tests** をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1529732/46e4cdfd-b50f-080f-0711-1a22490bab1f.png)

次に、**Enable Heroku CI** をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1529732/2ae6d68e-6e04-18a7-fede-a5124dc326b8.png)

これで、Heroku CI が稼働するようになりました。

以下が完了しています：

* Heroku パイプラインを作成しました。
* GitHub リポジトリを接続しました。
* Heroku アプリを作成しました。
* Heroku CI を有効化しました。
* `app.json` ファイルを作成し、Chrome for Testing と Node.js ビルドパックの必要性を指定し、`test` スクリプト実行時の手順を Heroku に指定しました。

これですべての準備が整いました。それでは、テストを実行してみましょう！

## テストを実行（手動トリガー）
Heroku パイプラインの **Tests** ページで、**New Test** → **Start Test Run** をクリックして、テストスイートを手動でトリガーします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1529732/8cb8e837-afb6-984c-a155-c4c7fc4fd0d6.png)

Heroku はこのテスト実行中に、Chrome for Testing ビルドパックの必要性を検出し、Chrome とその依存関係のインストールを開始する様子がすぐに確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1529732/ea2e3ee1-3872-780a-6161-f25558a8df8f.png)

Heroku がアプリケーションの依存関係をインストールし、プロジェクトをビルドした後、`npm run test:ci` を実行します。このコマンドは、`start-server-and-test` を使用して React アプリケーションを起動し、Jest/Puppeteer テストを実行します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1529732/26990102-88ab-d066-8090-b256f52222f1.png)

成功！
エンドツーエンドテストが、Chrome for Testing Heroku Buildpack を介したヘッドレス Chrome を使用して実行されました。

Heroku CI パイプラインにエンドツーエンドテストを統合することで、GitHub リポジトリへのプッシュごとに一連のテストが実行されます。エンドツーエンドテストが失敗した場合には即座にフィードバックを受け取ることができ、さらにレビューアプリの利用やステージングアプリをプロダクションに昇格させるなど、パイプラインをさらに構成することも可能です。

# まとめ
Web アプリケーションにおけるエンドツーエンドテストが複雑化するにつれ、自動的に実行されるヘッドレスブラウザテストがますます重要になってきます。手動でテストを実行する方法は信頼性が低く、スケーラブルでもありません。チームのすべての開発者にとって、エンドツーエンドテストスイートを実行できる単一かつ中央集約的な場所が必要です。これらのテストを Heroku CI で自動化することが最適な方法であり、Chrome for Testing Buildpack により、テスト機能がさらに強化されました。

Heroku アプリケーションの開発にて、Heroku CI を活用したい場合はぜひ今すぐ[こちらから](https://signup.heroku.com/)サインアップしてみてください。

---

**[Julián Duque](https://blog.heroku.com/authors/julian-duque)**
* Principal Developer Advocate at Heroku
* X (Twitter) : https://twitter.com/julian_duque
* LinkedIn : https://linkedin.com/in/juliandavidduque
* GitHub : https://github.com/julianduque