React

AngularJS のチュートリアルを React にリプレイスしてみた①

AngularJS のチュートリアルを React にリプレイスします。

この記事は、AngularJS の公式が提供しているチュートリアル を React にリプレイスすることで、フロントエンドのフレームワークの移行作業を体験することを目的としています。この記事内には以下の要素が存在します。

  • Webpack の導入
  • JavaScript → TypeScript
  • angular2React を利用した段階的なコンポーネントの置き換え
  • Jasmine + Karma → Jest
  • Protractor → Playwright

またレポジトリは以下に存在します。作業のはじめから行いたい場合には 01.start のタグにチェックアウトしてください。

E2E テストを導入する

大規模なリファクタリングを行ううえで、何かが壊れていないことを保証してくれるのが、自動化されたテストです。特に E2E(エンドツーエンド)テストは内部の構造を気にせず、ユーザーの目線からアプリケーションが期待通りに動作することを検証します。そのため、今回のようにフレームワークをリプレイスする場合でも。テストコード自体を修正せずに前後で動作が変わらないことを確認できます。

今回の対象のコードには E2E テストが含まれているのですが、使用されているテスティングフレームワークである Protractor が Angular に依存してしまっています。このままですと React に置き換えた際にテストコードが動かなくなってしまうので、特定のフレームワークに依存しないテストコードに書き換えることとします。

今回はテスティングフレームワークとして Playwright を使用します。以下のコマンドで Playwright をインストールします。

npm init playwright@latest
 
✔ Do you want to use TypeScript or JavaScript? · TypeScript
✔ Where to put your end-to-end tests? · tests
✔ Add a GitHub Actions workflow? (y/N) · false

デモ用のテストファイルが生成されるのですが、削除してしまって問題ありません。

rm -rf tests-examples
rm tests/example.spec.ts

デフォルトの設定では、Chrome・Firefox・webkit の 3 つのブラウザでテストが実行されますが、もともとのテストは Chrome のみで実行されていたので合わせて Chrome のみで十分でしょう。playwright.config.ts ファイルを修正します。

  projects: [
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
      },
    },
-     {
-       name: 'firefox',
-       use: {
-         ...devices['Desktop Firefox'],
-       },
-     },
 
-     {
-       name: 'webkit',
-       use: {
-         ...devices['Desktop Safari'],
-       },
-     },

またテストが失敗したときにスクリーンショットを撮影するように設定しておきましょう。

  use: {
    actionTimeout: 0,
    trace: 'on-first-retry',
+   screenshot: 'only-on-failure'
  },

それでは初めのテストを記述しましょう。基本的に、e2e-tests/scenarios.js の内容になぞって記述していくこととします。初めは 'index.html' にアクセスしたとき、index.html#!/phones にリダイレクトすることを確認するテストです。

tests/intergration.spec.ts ファイルを作成します。

import { test, expect } from '@playwright/test';
 
test.describe('PhoneCat Application', () => {
 
  test('should redirect `index.html` to `index.html#!/phones', async ({ page }) => {
    await page.goto('http://localhost:8000/');
 
    await expect(page).toHaveURL('http://localhost:8000/#!/phones');
  });
})

page.goto() メソッドは引数で指定した URL へ移動します。その後、expect(page).toHaveURL('http://localhost:8000/#!phones') で現在の URL のパスを検証します。

テストを実行する前に開発サーバーを起動する必要があります。

npm run start

E2E テストを実行するためにコマンドを package.json に追加します。

  "scripts": {
    "e2e": "playwright test"
  }

開発サーバーを起動し続けたまま、テストを実行しましょう。

npm run e2e

ここまでうまくいっていれば、テストは成功しているはずです。

> angular-phonecat@0.0.0 e2e
> playwright test

Running 1 test using 1 worker

  1 passed (2s)

To open last HTML report run:

  npx playwright show-report

さらにテストを追加していきましょう。電話一覧のページのテストです。 「should filter the phone list as a user types into the search box」はサーチボックスにテキストを入力したとき、フィルタリングが行われるか確認するテストです。

test.describe('View: Phone list', () => {
    test.beforeEach(async ({ page }) => {
      await page.goto('http://localhost:8000/#!/phones');
    });
 
    test('should filter the phone list as a user types into the search box', async ({ page }) => {
      const phoneList = page.locator('role=listitem');
      const input = page.locator("input");
 
      expect(phoneList).toHaveCount(20);
 
      await input.fill('nexus');
      await page.waitForTimeout(1000);
      expect(phoneList).toHaveCount(1);
 
      await input.fill('motorola');
      await page.waitForTimeout(1000);
      expect(phoneList).toHaveCount(8);
    })
  })

test.beforeEach でこの describe ブロック内のテストの実行前に毎回電話一覧ページへ遷移するようにしています。

始めに pge.locator() メソッドでテストに必要な要素を取得します。locator で要素を取得する際には、テキスト・CSS・ARIA ロール・id・XPath のセレクターを使用できます。listitem ロールからすべての電話一覧の、input 要素からサーチボックスを取得しています。

locator で要素を取得する際のベストプラクティスは、ARIA ロールやラベルのようにのようにユーザー目線のセレクターを使用し、詳細なセレクターを控えることです。例えば class 属性などは CSS のリファクタリングにより、仕様と関係ない理由で変更される可能性がありますが、ユーザー目線の属性が変更されることは稀です。そのため、メンテナンス性の高いテストコードをとなります。

初めに、expect(phoneList).toHaveCount(20) を使用しサーチボックスに何も入力していない場合(初期状態に)には 20 個のリストが存在することを検証しています。

続いて input.fill('nexus') でサーチボックスに「nexus」という文字列を入力します。ここで文字列を入力したことによりリストの数が変化することを確認したいのですが、リストの数の増減に応じてアニメーションが始まるので、完了するまで正しいリストの数を取得できません。そのため、page.waitForTimeout(1000) を入れてアニメーションの完了を待機しています。

「nexus」という文字列によりリストは 1 つにフィルタリングされるはずです。さらに、今後は「motorola」という文字列を入力し 8 つのリストにフィルタリングされることを検証しています。

次に、セレクトボックスによりリストが並べ替えらることをテストします。このテストも同様に describe('View: Phone list) ブロック内に記述します。

  test.describe('View: Phone list', () => {
    // ...
 
    test('should be possible to control phone order via the drop-down menu', async ({ page }) => {
      const dropdown = page.locator('select');
      const input = page.locator("input");
      const phoneList = page.locator('role=listitem').locator('role=link');
 
      const getNames = async () => {
        const names = await phoneList.allInnerTexts();
        return names.filter((name) => !!name);
      }
 
      await input.fill('tablet');
      await page.waitForTimeout(1000);
 
      expect(await getNames()).toEqual([
        'Motorola XOOM\u2122 with Wi-Fi',
        'MOTOROLA XOOM\u2122'
      ])
 
      await dropdown.selectOption('name');
      await page.waitForTimeout(1000);
 
      expect(await getNames()).toEqual([
        'MOTOROLA XOOM\u2122',
        'Motorola XOOM\u2122 with Wi-Fi'
      ])
    })
  })

まずはセレクトボックスとサーチボックス要素を取得します。page.locator('role=listitem').locator('role=link') のように locator を連鎖することでリスト要素の中のリンク要素(電話の名前)を取得しています。

getNames 関数では、allInnerTexts() メソッドにより電話の名前の一覧を取得しています。その文字列も一緒に取得してしまうため filter で弾いています。

検証する要素を短くするために、サーチボックスに「tablet」と入力してリスト一覧をフィルタリングしておきます。expect(await getNames()).toEqual() で初期値は新しい順に並んでいることを検証しています。

続いて dropdown.selectOption('name') でセレクトボックスの要素を選択しています。フィルタリング時と同様にアニメーションが始めるので、完了するまで待機しています。

セレクトボックスで name を選択した場合、アルファベット順で並べ替えされるはずですので、そのことを再度 expect(await getNames()).toEqual() で検証しています。

最後に、電話ごとに正しいリンクが設定されているかどうか検証するテストです。

  test.describe('View: Phone list', () => {
    // ...
 
    test('should render phone specific links', async ({ page }) => {
      const input = page.locator("input");
 
      await input.fill('nexus');
      await page.waitForTimeout(1000);
 
      const firstPhoneLink = page.locator('role=listitem').locator('role=link').first();
 
      await firstPhoneLink.click();
 
      expect(page).toHaveURL('http://localhost:8000/#!/phones/nexus-s');
    })
  })

サーチボックスに「nexus」と入力した後、page.locator('role=listitem').locator('role=link').first() で 1 番目のリスト要素を取得します。

リンク要素をクリックした後、 expect(page).toHaveURL() 現在の URL が期待しているものであるか検証しています。

次は電話の詳細ページのテストで。まとめて実装していきましょう。

test.describe('View: Phone detail', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:8000/#!/phones/nexus-s');
  })
 
  test('should display the `nexus-s` page', async ({ page }) => {
    expect(page.locator('role=heading').first()).toHaveText('Nexus S');
  })
 
  test('should display the first phone image as the main phone image', async ({ page }) => {
    const mainImage = page.locator('img.selected');
    expect(mainImage).toHaveAttribute('src', 'img/phones/nexus-s.0.jpg');
  })
 
  test('should swap main image if a thumbnail is clicked', async ({ page }) => {
    const mainImage = page.locator('img.selected');
    const thumbnails = page.locator('role=listitem').locator('role=img');
 
    await thumbnails.nth(2).click();
    expect(mainImage).toHaveAttribute('src', 'img/phones/nexus-s.2.jpg');
 
    await thumbnails.first().click();
    expect(mainImage).toHaveAttribute('src', 'img/phones/nexus-s.0.jpg');
  })
})

電話一覧ページと同様に test.beforeEach で毎回詳細ページへ遷移するようにしています。

1 つ目のテストは「nexus-s」のページが正しく描画されているかどうか、ヘディング要素のテキストで検証しています。

2 つ目テストではメインイメージに 1 番初めの画像が使用されているかどうかを検証しています。

3 つ目のテストはサムネイル画像をクリックしたとき、メインイメージの画像がクリックしたサムネイル画像に変更されるかどうかの検証です。

ここまでの流れでなんとなく何をやっているかつかめれば大丈夫です。もともとあった e2e-tests/scenarios.js とも比較してみてください。

E2E テストを書き直しましたので、もともとファイルは削除してしまってよいでしょう。

rm -rf e2e-tests
npm uninstall protractor

ここまでのコミットは以下となります。

Webpack を導入する

React を導入する前段階として、まずは Webpack を導入してファイルをバンドルできるようにしましょう。

まずは必要なパッケージをインストールします。

npm install --save-dev webpack webpack-cli webpack-dev-server babel-loader @babel/core @babel/preset-env @babel/preset-react ts-loader raw-loader html-webpack-plugin style-loader css-loader copy-webpack-plugin

TypeScript を使うため、TypeScript に必要なパッケージもインストールします。

npm install --save-dev @tsconfig/create-react-app typescript @types/react @types/react-dom @types/jquery

tsconfig ファイルも作成します。tsconfig/bases から extends することで手軽に React 用の設定を行えます。

{
  "extends": "@tsconfig/create-react-app/tsconfig.json",
  "compilerOptions": {
    "noEmit": false,
  }
}

続いて Webpack の設定ファイルである webpack.config.js を作成します。

/* eslint-disable @typescript-eslint/no-var-requires */
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const CopyPlugin = require("copy-webpack-plugin");
const webpack = require("webpack");
 
module.exports = {
  mode: "development",
  devtool: "source-map",
  entry: "./app/main.ts",
  output: {
    path: path.join(__dirname, "dist"),
    filename: "main.js",
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".json"],
  },
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        use: [
          {
            loader: "babel-loader",
          },
          {
            loader: "ts-loader",
            options: {
              configFile: path.resolve(__dirname, "tsconfig.json"),
            },
          },
        ],
      },
      {
        test: /\.js$/,
        use: [
          {
            loader: "babel-loader",
          },
        ],
        exclude: /node_modules/,
      },
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"],
      },
      {
        test: /\.html$/,
        use: ["raw-loader"],
      },
    ],
  },
  devServer: {
    static: {
      directory: path.join(__dirname, "dist"),
    },
    port: 8000,
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "./app/index.html",
    }),
    new webpack.ProvidePlugin({
      $: "jquery",
      jQuery: "jquery",
      "window.jQuery": "jquery",
    }),
    new CopyPlugin({
      patterns: [
        {
          from: "./",
          to: "./phones",
          context: "./app/phones",
        },
        {
          from: "./",
          to: "./img",
          context: "./app/img",
        },
      ],
    }),
  ],
};

簡単に、各プロパティの設定を説明します。

mode

改行やインデントを取り除いた本番用のビルドとするか、開発用のビルドとするか設定します。今回は本番用のビルドは行わないので development としています。

devtool

開発用の source-map をどのように出力するかどうかを設定します。

entry

アプリケーションのバンドル処理を開始するポイントです。

output

コンパイルされたファイルをどのディレクトリに書き出すかを webpack に伝えます。

resolve

モジュールがどのように解決されるかどうかを設定します。resolve.extensions で拡張子を指定した場合、import 時に拡張子を省略できます。

module

このオプションはモジュールのタイプごとにどのように扱われるかどうかを設定します。

module.rules[].test でターゲットとなる拡張子を指定し、その拡張子のファイルに対して module.rules[].use で使用する loader を指定します。

例えば .ts.,tsx 拡張子には babel-loaderts-loader を使用することを宣言しています。babel-loaderBabel により JavaScript をトランスパイルし、最新の構文をブラウザで動く形式に変換します。ts-loarder は TypeScript を JavaScript にトランスパイルします。

.css 拡張子に対しては css-loaderstyle-loader を使用します。css-loader は CSS を文字列として JavaScript ファイルに import するための loader です。style-loader は JavaScript 内の CSS 文字列を DOM に挿入するために使用されます。

.html 拡張子は AngularJS の template を読み込むために raw-loader を使用しています。

devServer

webpack-dev-server を使用するための設定です。webpack-dev-server は開発用サーバーを立ち上げ、にホットリロードなどを提供してくれます。

static.directory にサーバーの起点となるディレクトリを指定します。この値の output と同様で構いません。port にはポート番号を指定します。

plugins

Webpack のプラグインを設定します。HtmlWebpackPlugin は Webpack が index.html ファイルを生成する代わりにテンプレート用の HTML ファイルを指定します。

ProvidePlugin はあるモジュールを import する代わりに自動的にロードするために使用されます。AngularJS はグローバルに存在する window.jQuery に依存しているため、このプラグインを使用する必要があります。

CopyPlugin は画像や JSON ファイルなどの静的コンテンツをバンドルせずにそのままコピーするために使用します。

続いて、babel-loader 用の設定ファイルである .babelrc を作成します。

{
  "presets": ["@babel/preset-env", "@babel/preset-react"]
}

@babel/preset-envbabel プラグインのプリセットです。必要なプラグインを自動で選択して動く状態にしてくれます。@babel/preset-react は React 用のプリセットです。

package.jsonscripts を修正して、開発環境の起動に Webpack を使用するようにします。

  "scripts": {
-     "postinstall": "npm run copy-libs",
    "update-deps": "npm update",
-    "postupdate-deps": "npm run copy-libs",
-    "copy-libs": "cpx \"node_modules/{angular,angular-*,bootstrap/dist,jquery/dist}/**/*\" app/lib -C",
-    "prestart": "npm install",
-    "start": "http-server ./app -a localhost -p 8000 -c-1",
+    "start": "http-server ./dist -a localhost -p 8000 -c-1",
-    "pretest": "npm install",
    "test": "karma start karma.conf.js",
    "test-single-run": "npm test -- --single-run",
    "e2e": "playwright test",
+    "build": "webpack",
+    "dev": "webpack-dev-server"
  }

さらに、バンドルファイルを git 管理下に含めないように .gitignore を修正します。

+ dist

webpack のエントリーファイルを作成しましょう。app/main.ts ファイルを作成して、必要なモジュールをインポートします。

import "jquery"
import 'angular'
import 'angular-resource'
import 'angular-route'
import 'angular-animate/angular-animate'
 
import 'bootstrap/dist/css/bootstrap.css'
import './app.css'
import sh'./app.animations.css'
 
import './app.module'
import './app.config'
import './app.animations'
import './phone-list/phone-list.module'
import './phone-list/phone-list.component'
import './phone-detail/phone-detail.module'
import './phone-detail/phone-detail.component'
import './core/core.module'
import './core/checkmark/checkmark.filter'
import './core/phone/phone.module'
import './core/phone/phone.service'

app/main.ts でモジュールを読み込むように修正しましたので、app/index.html において <script><linl> タグで個別に読み込む必要はありません。すべて削除しましょう。

-  <link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.css" />
-  <link rel="stylesheet" href="app.css" />
-  <link rel="stylesheet" href="app.animations.css" />
 
-  <script src="lib/jquery/dist/jquery.js"></script>
-  <script src="lib/angular/angular.js"></script>
-  <script src="lib/angular-animate/angular-animate.js"></script>
-  <script src="lib/angular-resource/angular-resource.js"></script>
-  <script src="lib/angular-route/angular-route.js"></script>
-  <script src="app.module.js"></script>
-  <script src="app.config.js"></script>
-  <script src="app.animations.js"></script>
-  <script src="core/core.module.js"></script>
-  <script src="core/checkmark/checkmark.filter.js"></script>
-  <script src="core/phone/phone.module.js"></script>
-  <script src="core/phone/phone.service.js"></script>
-  <script src="phone-list/phone-list.module.js"></script>
-  <script src="phone-list/phone-list.component.js"></script>
-  <script src="phone-detail/phone-detail.module.js"></script>
-  <script src="phone-detail/phone-detail.component.js"></script>

app/lib ディレクトリももはや必要ないので削除しておきましょう。

rm -rf app/lib

最後に、アプリケーションコードを修正する必要があります。AngularJS のコンポーネントでは templateUrl により外部の HTML ファイルを HTTP リクエストで読み込んでいます。Webpack では 1 つのファイルにバンドルする必要があるので、templateUrl を指定している箇所をインポートするように変更します。

以下の 2 つのファイルを変更します。

  • app/phone-list/phone-list.component.js
  • app/phone-detail/phone-detail.component.js
  'use strict';
+ import template from './phone-list.template.html';
 
  // Register `phoneList` component, along with its associated controller and template
  angular.
    module('phoneList').
    component('phoneList', {
-     templateUrl: 'phone-list/phone-list.template.html',
+     template,
      controller: ['Phone',
        function PhoneListController(Phone) {
          this.phones = Phone.query();
          this.orderProp = 'age';
        }
      ]
    });
  'use strict';
+ import template from './phone-detail.template.html';
  // Register `phoneDetail` component, along with its associated controller and template
  angular.
    module('phoneDetail').
    component('phoneDetail', {
-     templateUrl: 'phone-detail/phone-detail.template.html',
+     template,
      controller: ['$routeParams', 'Phone',
        function PhoneDetailController($routeParams, Phone) {
          var self = this;
          self.phone = Phone.get({phoneId: $routeParams.phoneId}, function(phone) {
            self.setImage(phone.images[0]);
          });
 
          self.setImage = function setImage(imageUrl) {
            self.mainImageUrl = imageUrl;
          };
        }
      ]
    });

ここまで完了したら、以下のコマンドで開発サーバーを起動してみましょう。問題がなければ、以前と変わらない画面が表示されているはずです。

npm run dev

Webpack の導入でなにか壊してしまっていないか確認するために、前回作成して E2E テストを実行しましょう。開発サーバーを立ち上げたままテストを実行します。

npm run e2e

テストが無事通っていれば、安心ですね。このように、なにか変更を加えるたびに E2E テストを実行して安全に進めていきましょう。

ここまでのコミットは以下のとおりです。

リンターの導入

続いて、リンターとして ESLintPrettier を導入します。以下のコマンドで ESLint の初期設定を実行します。

npx eslint --init

対話形式で設定内容を聞かれるので、以下のように回答しました。

? How would you like to use ESLint?
  To check syntax only
❯ To check syntax and find problems
  To check syntax, find problems, and enforce code style
? What type of modules does your project use?
❯ JavaScript modules (import/export)
  CommonJS (require/exports)
  None of these
? Which framework does your project use?
❯ React
  Vue.js
  None of these
? Does your project use TypeScript? › Yes
? Where does your code run?(Press <space> to select, <a> to toggle all, <i> to invert selection)
✔ Browser
✔ Node
? What format do you want your config file to be in?
❯ JavaScript
  YAML
  JSON
Local ESLint installation not found.
The config that you've selected requires the following dependencies:
 
eslint-plugin-react@latest @typescript-eslint/eslint-plugin@latest @typescript-eslint/parser@latest eslint@latest
? Would you like to install them now? › Yes
? Which package manager do you want to use? … 
❯ npm
  yarn
  pnpm

パッケージのインストールが完了したら、.eslintrc.js ファイルが生成されています。デフォルトの設定に加えて eslint-plugin-react-hooks を導入することをおすすめします。このプラグインは React の Hooks のルール に沿っているかどうか確認してくれます。

npm install eslint-plugin-react-hooks --save-dev

.eslintrc.js ファイルを修正してこのプラグインを使用するようにします。

{
  "extends": [
    // ...
    "plugin:react-hooks/recommended"
  ]
}

AngularJS・Jasmine を使用しているので、グローバル変数に対して警告が出てしまいます。以下の設定を追加しましょう。

  "env": {
    "browser": true,
    "es2021": true,
    "node": true,
+   "jasmine": true
  },
+ "globals": {
+   "angular": true,
+   "module": true,
+   "inject": true
+ },

また現状のルールですとリントを成功させることができないので、少しルールを緩和します。

"rules": {
  "@typescript-eslint/no-this-alias": "off",
},

続けて Prittier のパッケージをインストールします。

npm i --save-dev prettier eslint-config-prettier

インストール後、eslintrc.js ファイルの extended"prettier" を追加します。

  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:react-hooks/recommended",
    "plugin:@typescript-eslint/recommended",
+   "prettier"
  ],

.prettierrc ファイルを作成して、Prittier の設定を行いましょう。

{
  "semi": true,
  "tabWidth": 2,
  "printWidth": 100,
  "singleQuote": true,
  "trailingComma": "none",
  "jsxBracketSameLine": true
}

最後に、package.json にリントコマンドを追加しましょう。

"scripts": {
  "lint": "eslint app --ext .js,jsx,.ts,.tsx",
  "lint:fix": "eslint app --ext .js,jsx,.ts,.tsx --fix",
  "format": "prettier --write --ignore-path .gitignore './**/*.{js,jsx,ts,tsx}'"
}

最後にリントとフォーマットを実行して既存のコードのフォーマットを修正しましょう。

npm run lint:fix
npm run format

前回と同じく E2E テストを実行して誤って何かを壊していないか確認しましょう。

npm run 2e2

ここまでのコミットは以下のとおりです。

React の導入

いよいよ React を導入します。react と関連するパッケージをインストールします。

npm install --save react2angular react@17.0.1 react-dom@17.0.2

react2angular というライブラリを使用することで React のコンポーネントを AngularJS のコンポーネントに変換して画面に埋め込むことができます。そのため、React への以降を段階的に進めていくことができます。

PhoneItmes コンポーネントの作成

まずは始めに、電話一覧の部分を React コンポーネントへ変換してみましょう。

スクリーンショット 2022-07-30 15.22.09

ソースコードでは以下の部分となります。

<ul class="phones">
  <li ng-repeat="phone in $ctrl.phones | filter:$ctrl.query | orderBy:$ctrl.orderProp"
      class="thumbnail phone-list-item">
    <a href="#!/phones/{{phone.id}}" class="thumb">
      <img ng-src="{{phone.imageUrl}}" alt="{{phone.name}}" />
    </a>
    <a href="#!/phones/{{phone.id}}">{{phone.name}}</a>
    <p>{{phone.snippet}}</p>
  </li>
</ul>

始めに Phone の型定義を作成しましょう。app/phone-list/types.ts ファイルを作成します。

export type Phone = {
  age: number;
  id: string;
  imageUrl: string;
  name: string;
  snippet: string;
};

app/phone-list/PhoneItems.tsx ファイルを作成して React コンポーネントを実装します。

import angular from 'angular';
import React from 'react';
import { react2angular } from 'react2angular';
import { Phone } from './types';
 
type Props = {
  phones: Phone[];
  query?: string;
  orderProp: 'name' | 'age';
};
 
const PhoneItems: React.FC<Props> = ({ phones, query, orderProp }) => {
  const filteredPhones = phones.filter((phone) => {
    if (!query) {
      return true;
    }
    return Object.values(phone).some((value) => {
      return String(value).toLowerCase().includes(query.toLowerCase());
    });
  });
  const sortedPhones = filteredPhones.sort((a, b) => {
    if (a[orderProp] < b[orderProp]) {
      return -1;
    }
    if (a[orderProp] > b[orderProp]) {
      return 1;
    }
    return 0;
  });
 
  return (
    <ul className="phones">
      {sortedPhones.map((phone) => (
        <li key={phone.id} className="thumbnail phone-list-item">
          <a href={`#!/phones/${phone.id}`} className="thumb">
            <img src={phone.imageUrl} alt={phone.name} />
          </a>
          <a href={`#!/phones/${phone.id}`}>{phone.name}</a>
          <p>{phone.snippet}</p>
        </li>
      ))}
    </ul>
  );
};
 
export default PhoneItems;
 
angular
  .module('phoneList')
  .component('phoneItems', react2angular(PhoneItems, ['phones', 'query', 'orderProp']));

Props で React コンポーネントの props の型を定義します。react2Angular を利用することで AngularJS の bindings を props として受け取ることができます。

filteredPhones では AngularJS の filter フィルター(| filter:$ctrl.query)の挙動を再現しています。filter フィルターは配列内のオブジェクトを部分一致検索し、合致するものを返します。

sortedPhones は AngularJS の orderBy フィルター(| orderBy:$ctrl.orderProp")の再現です。orderBy フィルターは配列の内容を指定されたプロパティをキーにソートします。

レンダリング部分では以下の修正が必要です。

  • ng-repeat でリストレンダリングする代わり sortedPhones.map()
  • ng-srcsrc
  • classclassName
  • 変数の展開 {{ }}{}

特に、class から className の変換は頻出するので converter を利用して機械的に行うのが良いでしょう。

作成した React コンポーネントは react2angular で AngularJS のコンポーネントに変換して登録します。渡す予定の Props は第 2 引数で明示する必要があります。

angular
  .module('phoneList')
  .component('phoneItems', react2angular(PhoneItems, ['phones', 'query', 'orderProp']));

main.ts で作成したファイルをインポートして、作成した React コンポーネントを使用できるようにします。

import './phone-list/PhoneItems';

phone-list.template.html を修正して、もとのコードの代わりに React コンポーネントを使うように修正しましょう。

- <ul class="phones">
-   <li ng-repeat="phone in $ctrl.phones | filter:$ctrl.query | orderBy:$ctrl.orderProp"
-     class="thumbnail phone-list-item">
-     <a href="#!/phones/{{phone.id}}" class="thumb">
-       <img ng-src="{{phone.imageUrl}}" alt="{{phone.name}}" />
-     </a>
-     <a href="#!/phones/{{phone.id}}">{{phone.name}}</a>
-     <p>{{phone.snippet}}</p>
-   </li>
- </ul>
+ <phone-items phones="$ctrl.phones" query="$ctrl.query" order-prop="$ctrl.orderProp"></phone-items>

さらにもう一点、app/phone-list/phone-list-component も修正する必要があります。

  'use strict';
  import template from './phone-list.template.html';
 
  // Register `phoneList` component, along with its associated controller and template
  angular.module('phoneList').component('phoneList', {
    template,
    controller: [
      'Phone',
      function PhoneListController(Phone) {
-       this.phones = Phone.query()
+        this.phones = [];
+        Phone.query().$promise.then((phones) => {
+          this.phones = phones;
+        });
        this.orderProp = 'age';
      }
    ]
  });

this.phoens = Phone.query()$resource サービス を使用しているのですが、resouce オブジェクトの各メソッド(query, get)は変数に空の参照を返し、サーバーから応答を得たタイミングで参照先に実データが格納されるという実装となっています。

サーバーからデータ取得後も参照は変わらないため、React で変更を検知できません。そのため、データ取得後は新しいオブジェクトを再代入する方法に修正しています。

ここまで完了したら、開発サーバーで確認してみましょう。一見問題ないように見えますが、アニメーションが行われなくなってしまっています。

phone-list-no-animation

アニメーションの実装

これは、AngularJS から React コンポーネントに変換したことにより ngAnimate モジュールによるアニメーションが有効でなくなってしまったためです。アニメーションを再現するために react-transition-group を使用します。このライブラリは ngAnimate ライブラリに影響を受けているので、移行がしやすいです。

npm install react-transition-group
npm i --save-dev @types/react-transition-group

app/phone-list/PhoneItem.tsx を以下のように修正します。

import { TransitionGroup, CSSTransition } from 'react-transition-group';
 
const PhoneItems: React.FC<Props> = ({ phones, query, orderProp }) => {
return (
    <TransitionGroup component="ul" className="phones">
      {sortedPhones.map((phone) => (
        <CSSTransition
          key={phone.id}
          timeout={500}
          classNames={{
            appear: 'ng-appear',
            appearActive: 'ng-appear-active',
            appearDone: 'ng-appear-done',
            enter: 'ng-enter',
            enterActive: 'ng-enter-active',
            enterDone: 'ng-enter-done',
            exit: 'ng-leave',
            exitActive: 'ng-leave-active',
            exitDone: 'ng-leave-done'
          }}>
          <li className="thumbnail phone-list-item">
            <a href={`#!/phones/${phone.id}`} className="thumb">
              <img src={phone.imageUrl} alt={phone.name} />
            </a>
            <a href={`#!/phones/${phone.id}`}>{phone.name}</a>
            <p>{phone.snippet}</p>
          </li>
        </CSSTransition>
      ))}
    </TransitionGroup>
  );
}

<ul> タグの代わりに TransitionGroup を使うように変更しています。<TransitionGroup><Transition><CSSTransition> のようなトランジショングループのリストを管理し、リストのアイテムの追加や削除によりトランジションが発生するようにします。

リストアイテム内では CSSTransiton を使用しています。このコンポーネントは ng-animate と同様にクラスを付与することで CSS トランジションでアニメーションを制御します。

また classNames プロパティにより ng-animate と同様のクラス名が付与されるようにしています。

classNames={{
  appear: 'ng-appear',
  appearActive: 'ng-appear-active',
  appearDone: 'ng-appear-done',
  enter: 'ng-enter',
  enterActive: 'ng-enter-active',
  enterDone: 'ng-enter-done',
  exit: 'ng-leave',
  exitActive: 'ng-leave-active',
  exitDone: 'ng-leave-done'
}}>

再度開発サーバーで確認してみましょう。今度は適切にアニメーションが付与されているはずです。

phone-list-animation

しかしながら、要素の並べ替えを行った際のアニメーションが適用されておりません。ng-animate ではリストのアイテムの位置が変わるときに ng-move クラスが付与されていたのですが、 TransitionGroup ではそのようなクラスが提供されていないためです。

並べ替えのアニメーションを適用するために react-flip-toolkit と呼ばれるライブラリを使用します。FLIP とは、First、Last、Invert、Play の頭文字からなる造語で、複雑なアニメーションをスムーズに実行する手順のことです。

npm i react-flip-toolkit

まずはすべてのアニメーション対象の要素を <Flipper> コンポーネントでラップします。<Flipper> コンポーネントは flipKey prop を受け取り、このキーの値が変更されるたびにアニメーションが発生します。ここでは、orderProp をキーに指定して、並び順対象のプロパティが変更されるたびにアニメーションが発生するようにします。

次にアニメーションされるべき要素を <Flipped> コンポーネントでラップしますこのコンポーネントは一意となる flipId prop を受け取ります。

import { Flipper, Flipped } from 'react-flip-toolkit';
 
const PhoneItems: React.FC<Props> = ({ phones, query, orderProp }) => {
 
  return (
    <Flipper flipKey={orderProp}>
      <TransitionGroup component="ul" className="phones">
        {sortedPhones.map((phone) => (
          <CSSTransition
            key={phone.id}
            timeout={500}
            classNames={{
              appear: 'ng-appear',
              appearActive: 'ng-appear-active',
              appearDone: 'ng-appear-done',
              enter: 'ng-enter',
              enterActive: 'ng-enter-active',
              enterDone: 'ng-enter-done',
              exit: 'ng-leave',
              exitActive: 'ng-leave-active',
              exitDone: 'ng-leave-done'
            }}>
            <Flipped flipId={phone.id}>
              <li className="thumbnail phone-list-item">
                <a href={`#!/phones/${phone.id}`} className="thumb">
                  <img src={phone.imageUrl} alt={phone.name} />
                </a>
                <a href={`#!/phones/${phone.id}`}>{phone.name}</a>
                <p>{phone.snippet}</p>
              </li>
            </Flipped>
          </CSSTransition>
        ))}
      </TransitionGroup>
    </Flipper>
  );
}

これで、並べ替え時のアニメーションも適用されるようになりました。確認してみましょう。

phone-list-reorder-animation

いいですね!いつもどおり、E2E テストを実行しておきましょう。

npm run 2e2

ここまでのコミットログは以下のとおりです。

コンポーネントのテスト

新たに作成した React コンポーネントについてもテストコードを実装するようにしましょう。

Jset のセットアップ

コンポーネントのテストには JestTesting Library を使用します。React コンポーネントをテストするためのライブラリをインストールします。

npm install --save-dev jest @types/jest ts-jest babel-jest @testing-library/react@12 @testing-library/user-event@12 testing-library/jest-dom jest-environment-jsdom

jest.config.js ファイルを作成して Jest の設定を記述します。

module.exports = {
  testEnvironment: 'jest-environment-jsdom',
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest',
    '^.+\\.(js|jsx)$': 'babel-jest'
  },
  testPathIgnorePatterns: ['/node_modules/', '/tests/'],
  moduleNameMapper: {
    '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|html)$':
      '<rootDir>/__mocks__/fileMock.js',
    '\\.(css|less)$': '<rootDir>/__mocks__/styleMock.js'
  },
  setupFilesAfterEnv: ['<rootDir>/jest-setup.js']
};
  • testEnviroment:jest の実行環境を指定します。
  • transform:jest の実行時に babel や TypeScript でコードを変換します。
  • tesetPathIgnorePatterns:テストの実行を除外するディレクトリを指定します。/tests/ は E2E テストのためのディレクトリなので除外しています。
  • moduleNameMapper:CSS やその他静的アセットファイルを jest で import できないのでモックします。静的アセットの管理
  • setupFilesAfterEnv:テスト実行前のセットアップに必要な記述を行ったファイルのパスを指定します。

moduleNameMapper で指定したモックファイルも作成しましょう。

// __mocks__/styleMock.js
module.exports = {}
// __mocks__/fileMock.js
module.exports = 'test-file-stub';

続いて setupFilesAfterEnv で指定した、テスト実行前のセットアップの記述を行ったファイルです。

// jest.setup.js
import '@testing-library/jest-dom';
 
global.jasmine = true;

ここでは、DOM テストのために便利なカスタムマッチャーである testing-libary/jest-dom をインポートしています。

もう 1 つ、global.jasmine = true; という記述は見慣れないほうも多いでしょう。これは angular-mocks を使用するために必要な記述です。angular-mocks の実装では、以下のように Jasmine または Mocha がフレームワークに使われている場合のみに必要なロジックを読み込むようになっています。

(function(jasmineOrMocha) {
 
  if (!jasmineOrMocha) {
    return;
  }
 
...
 
})(window.jasmine || window.mocha);

この問題を解決するために、global.jasmine = true の記述を入れています。

最後に、package.json のテストコマンドで jest を利用するように修正しましょう。

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
  }
}

既存のテストコードを修正する

既存の単体テストコードでは JasmineKarma を使用されていますので、これを Jest で動くように修正しましょう。Jasmine の API(describe,it)は Jest とよく似ているので、大きな修正は必要ありません。

まずは、app/core/checkmark/checkmark.filter.spec.js を修正します。修正する箇所は 2 つです。

1 つ目は、karma.conf.js 依存ファイルをまとめて読み込んでいたのを各ファイルで import するように修正します。

- 'use strict';
+ import 'angular';
+ import 'angular-resource';
+ import 'angular-mocks';
 
+ import '../core.module';
+ import '../phone/phone.module';
+ import './checkmark.filter';

2 つ目の修正では、AngularJS のモジュールをモックするための記述を module('core') から angular.mock.module('core') に修正します。

  describe('checkmark', function () {
-   beforeEach(module('core'));
+   beroreEach(angular.mock.module('core'));

ここまで修正したらテストを実行して確認してみましょう。

npm run test checkmark

テストが PASS していれば問題ないはずです。

 PASS  app/core/checkmark/checkmark.filter.spec.js
  checkmark
    ✓ should convert boolean values to unicode checkmark or cross (16 ms)
 
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.982 s
Ran all test suites matching /checkmark/i.

この調子で app/core/phone/phone.service.spec.js も修正していきましょう。依存ファイルを import するのと、angular.mock.module() に修正するのは共通です。

- 'use strict';
+ import 'angular';
+ import 'angular-resource';
+ import 'angular-mocks';
 
+ import '../phone/phone.module';
+ import './phone.service';
 
  describe('Phone', function () {
    var $httpBackend;
    var Phone;
    var phonesData = [{ name: 'Phone X' }, { name: 'Phone Y' }, { name: 'Phone Z' }];
 
    // Load the module that contains the `Phone` service before each test
-   beforeEach(module('core.phone'));
+   beforeEach(angular.mock.module('core.phone'));

さらにもう一点修正が必要な箇所があります。jasmine.addCustomEqualityTester(angular.equals) のように Jasmine のカスタムマッチャーを定義している箇所があるので、Jest のカスタムマッチャーを定義する記述に修正ます。

  // Add a custom equality tester before each test
  beforeEach(function () {
-   jasmine.addCustomEqualityTester(angular.equals);
+   expect.extend({
+     toEqual: (actual, expected) => {
+       return {
+         pass: angular.equals(actual, expected),
+         message: `Expected ${actual} to equal ${expected}`
+       };
+     }
+    });
  })

テストが正しくとおるかどうか確認しましょう。

npm run test phone.service

最後に、app/phone-list/phone-list.component.spec.js の修正と app/phone-detail/phone-detail.component.spec.js の修正をまとめていってしまいましょう。ここまで出てきたことと同じ修正を行えば大丈夫です。

  // app/phone-list/phone-list.component.spec.js
- 'use strict';
 
+ import 'angular';
+ import 'angular-resource';
+ import 'angular-route';
+ import 'angular-mocks';
 
+ import '../core/phone/phone.module';
+ import '../core/phone/phone.service';
+ import './phone-list.module';
+ import './phone-list.component';
 
  describe('phoneList', function () {
    // Load the module that contains the `phoneList` component before each test
-   beforeEach(module('phoneList'));
+   beforeEach(angular.mock.module('phoneList'));
 
    it('should create a `phones` property with 2 phones fetched with `$http`', function () {
-      jasmine.addCustomEqualityTester(angular.equals);
+      expect.extend({
+        toEqual: (actual, expected) => {
+          return {
+            pass: angular.equals(actual, expected),
+            message: `Expected ${actual} to equal ${expected}`
+          };
+        }
+       });
  // app/phone-detail/phone-detail.component.spec.js
- 'use strict';
 
+ import 'angular';
+ import 'angular-resource';
+ import 'angular-route';
+ import 'angular-mocks';
 
+ import '../core/phone/phone.module';
+ import '../core/phone/phone.service';
+ import './phone-detail.module';
+ import './phone-detail.component';
 
  describe('phoneDetail', function () {
    // Load the module that contains the `phoneList` component before each test
-   beforeEach(module('phoneDetail'));
+   beforeEach(angular.mock.module('phoneDetail'));
 
    it('should fetch the phone details', function () {
-      jasmine.addCustomEqualityTester(angular.equals);
+      expect.extend({
+        toEqual: (actual, expected) => {
+          return {
+            pass: angular.equals(actual, expected),
+            message: `Expected ${actual} to equal ${expected}`
+          };
+        }
+       });

すべてのテストを実行して確認しましょう。

npm run test
 
 PASS  app/core/checkmark/checkmark.filter.spec.js
 PASS  app/phone-list/phone-list.component.spec.js
 PASS  app/core/phone/phone.service.spec.js
 PASS  app/phone-detail/phone-detail.component.spec.js
 
Test Suites: 4 passed, 4 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        4.355 s
Ran all test suites.

すべてのテストが PASS していれば、正しく Jest に移行できていますね!Jasmine、Karma 関連のパッケージやファイルは削除してしまってよいでしょう。

rm karma.conf.js
 
npm uninstall ---save-dev karma karma-chrome-launcher karma-firefox-launcher karma-jasmine jasmine-core

PhoneItems コンポーネントのテスト

下準備も済みましたので、React のコンポーネントのテストを実装しましょう。正しくデータがフィルタリング、ソートされて描画されるかどうかをテストします。app/phone-list/PhoneItems.spec.tsx ファイルを作成しましょう。

import React from 'react';
import { render, screen } from '@testing-library/react';
import 'angular';
import '../phone-list/phone-list.module';
import PhoneItems from './PhoneItems';
import phones from '../phones/phones.json';
 
describe('PhoneItems', () => {
  it('should render phone items', () => {
    render(<PhoneItems phones={phones} orderProp="age" />);
 
    const phoneList = screen.getAllByRole('listitem');
 
    expect(phoneList).toHaveLength(20);
    expect(phoneList[0]).toHaveTextContent('Motorola XOOM™ with Wi-Fi');
  });
 
  it('should render phone items with query', () => {
    render(<PhoneItems phones={phones} query="motorola" orderProp="age" />);
 
    expect(screen.getAllByRole('listitem')).toHaveLength(8);
  });
 
  it('should render phone items with query and orderProp', () => {
    render(<PhoneItems phones={phones} query="motorola" orderProp="name" />);
 
    const phoneList = screen.getAllByRole('listitem');
 
    expect(phoneList).toHaveLength(8);
    expect(phoneList[0]).toHaveTextContent('DROID™ 2 Global by Motorola');
  });
});

app/phone-list/PhoneItems.tsx ファイル内では AngularJS もモジュールにコンポーネントを登録しているので、angular../phone-list/phone-list.module を import する必要があることに注意してください。

1 つ目のテストは query prop が存在しない場合(=フィルタリングされない場合)すべてのアイテムが描画されることを検証しています。@testing-library/reactrender 関数によりコンポーネントが描画されます。要素を取得するために screen.getAllByRole('listitem') を使用しています。Testing Library はこのようにユーザー目線のロールやラベルを使用して要素を取得するのが特徴です。

クエリを指定していないのでリストアイテムの要素数は 20、並び順は「age」なので最初の要素は「Motorola XOOM™ with Wi-Fi」であるはずです。

2 つ目のテストでは query props として「motorola」を渡して正しくフィルタリングされるかどうかを検証しています。

3 つ目のテストは orderProp に「name」を渡して並び順がアルファベット順となっているかどうかを検証します。

それではテストを実行してみましょう。

npm run test
 PASS  app/core/phone/phone.service.spec.js
 PASS  app/phone-list/phone-list.component.spec.js
 PASS  app/core/checkmark/checkmark.filter.spec.js
 PASS  app/phone-detail/phone-detail.component.spec.js
 PASS  app/phone-list/PhoneItems.spec.tsx (8.924 s)
 
Test Suites: 5 passed, 5 total
Tests:       8 passed, 8 total
Snapshots:   0 total
Time:        10.809 s
Ran all test suites.

問題なくテストが PASS していますね!アプリケーションのコードは変更していないですが、念のため E2E テストも実行しておくと安全です。

npm run e2e

ここまでのコミットは以下のとおりです。

電話一覧画面を React コンポーネントにする

それでは、電話一覧画面を React コンポーネントに移行しましょう。AngularJS の $resource は引き続き利用するので、型定義ファイルをインストールしておきます。

npm install --save @types/angular-resource

PhoneList コンポーネントの作成

app/phone-list/PhoneList.tsx ファイルを作成します。

import angular from 'angular';
import React, { useEffect, useState } from 'react';
import { react2angular } from 'react2angular';
import PhoneItems from './PhoneItems';
import { Phone } from './types';
 
type Props = {
  Phone: ng.resource.IResourceClass<Phone>;
};
 
const PhoneList: React.FC<Props> = ({ Phone }) => {
  const [phones, setPhones] = useState<Phone[]>([]);
  const [query, setQuery] = useState('');
  const [orderProp, setOrderProp] = useState<'name' | 'age'>('name');
  useEffect(() => {
    Phone.query().$promise.then((result) => {
      setPhones(result);
    });
  }, [Phone, setPhones]);
 
  return (
    <div className="container-fluid">
      <div className="row">
        <div className="col-md-2">
          <p>
            Search: <input value={query} onChange={(e) => setQuery(e.target.value)} />
          </p>
 
          <p>
            Sort by:{' '}
            <select
              value={orderProp}
              onChange={(e) => setOrderProp(e.target.value as 'name' | 'age')}>
              <option value="name">Alphabetical</option>
              <option value="age">Newest</option>
            </select>
          </p>
        </div>
        <div className="col-md-10">
          <PhoneItems phones={phones} query={query} orderProp={orderProp} />
        </div>
      </div>
    </div>
  );
};
 
export default PhoneList;
 
angular.module('phoneList').component('phoneList', react2angular(PhoneList, [], ['Phone']));

まずは、Props の型として Phone を定義しています。Phonephone-list.component.js でコントローラーに DI されていた AngularJS の Resource クラスです。後ほど出てきますが、react2angular では AngularJS のサービスなどを簡単に引き渡すことができます。

type Props = {
  Phone: ng.resource.IResourceClass<Phone>;
};

続いて Phone resource を利用して API からデータを取得します。コントローラー内で this.hoge に代入していたデータは useState に置き換えることになります。

const [phones, setPhones] = useState<Phone[]>([]);
 
useEffect(() => {
  let igonre = false;
  Phone.query().$promise.then((result) => {
    if (!igonre) {
      setPhones(result);
    }
  });
 
  return () => {
    igonre = true;
  };
}, [Phone, setPhones]);

フォーム入力では、ng-model による双方向バインディングを useState に置き換えます。

const [query, setQuery] = useState('');
const [orderProp, setOrderProp] = useState<'name' | 'age'>('age');
 
return (
        <p>
          Search: <input value={query} onChange={(e) => setQuery(e.target.value)} />
        </p>
 
        <p>
          Sort by:{' '}
          <select
            value={orderProp}
            onChange={(e) => setOrderProp(e.target.value as 'name' | 'age')}>
            <option value="name">Alphabetical</option>
            <option value="age">Newest</option>
          </select>
        </p>

<PhoneItems /> コンポーネントは React の中ですので、いつもどおり使用できますね。

<PhoneItems phones={phones} query={query} orderProp={orderProp} />

最後に angular2react でコンポーネントを AngularJS のコンポーネントに変換します。Props のところで述べたとおり、angular2react では第 3 引数に AngularJS のサービス名を指定することで、Dependency Injection を行え、React では props として受け取ることができます。

angular.module('phoneList').component('phoneList', react2angular(PhoneList, [], ['Phone']));

main.ts で作成したコンポーネントを読み込むように修正します。./phone-list/phone-list.component./phone-list/PhoneItems はもう必要ないので削除してしまいましょう。

- import './phone-list/phone-list.component';
- import './phone-list/PhoneItems';
+ import './phone-list/PhoneList';

不要となるファイルも削除します。

rm app/phone-list/phone-list.compoment.js
rm app/phone-list/phone-list.compoment.spec.js
rm app/phone-list/phone-list.template.html

app/app.config.js<phone-list> コンポーネントが使用されています。AngularJS のコンポーネントと同じ名前で作成したので、ここは修正する必要はありません。開発サーバーで確認してみましょう。問題がなけらば、前と変わらないことが確認できるはずです。

スクリーンショット 2022-07-31 20.43.04

E2E テストでも確認してみましょう。

npm run e2e

PhoneList コンポーネントのテスト

コンポーネントのテストも作成していきましょう。AngularJS の PhoneList コンポーネントのテストである app/phone-list/phone.list.component.spec.js ではバックエンドの API をモックするために $httpBackend を使用していました。PhoneList コンポーネントでは Phone resource を注入しているので同様の方法でバックエンドをモックします。

app/phone-list/phoneList.spec.tsx ファイルを作成します。

import React from 'react';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import angular from 'angular';
import 'angular-resource';
import 'angular-mocks';
import '../phone-list/phone-list.module';
import '../core/phone/phone.module';
import PhoneList from './PhoneList';
import phones from '../phones/phones.json';
import { Phone } from './types';
 
describe('PhoneList', () => {
  let Phone: ng.resource.IResourceClass<Phone>;
  let $httpBackend: ng.IHttpBackendService;
 
  beforeEach(() => {
    angular.mock.module('phoneList');
    angular.mock.inject(($resource, _$httpBackend_) => {
      Phone = $resource(
        'phones/:phoneId.json',
        {},
        {
          query: {
            method: 'GET',
            params: { phoneId: 'phones' },
            isArray: true
          }
        }
      );
 
      $httpBackend = _$httpBackend_;
      $httpBackend.expectGET('phones/phones.json').respond(phones);
    });
  });
 
  it('should render phone items', async () => {
    render(<PhoneList Phone={Phone} />);
 
    act(() => {
      $httpBackend.flush();
    });
    const phoneList = await screen.findAllByRole('listitem');
    expect(phoneList).toHaveLength(20);
    expect(phoneList[0]).toHaveTextContent('Motorola XOOM™ with Wi-Fi');
  });
 
  it('should filter phone items', async () => {
    render(<PhoneList Phone={Phone} />);
 
    act(() => {
      $httpBackend.flush();
    });
    const input = screen.getByRole('textbox');
    userEvent.type(input, 'motorola');
    await waitFor(() => {
      expect(screen.getAllByRole('listitem')).toHaveLength(8);
    });
  });
 
  it('should sort phone items', async () => {
    render(<PhoneList Phone={Phone} />);
 
    act(() => {
      $httpBackend.flush();
    });
    expect((await screen.findAllByRole('listitem'))[0]).toHaveTextContent(
      'Motorola XOOM™ with Wi-Fi'
    );
 
    const select = screen.getByRole('combobox');
    userEvent.selectOptions(select, 'name');
    await waitFor(() => {
      expect(screen.getAllByRole('listitem')[0]).toHaveTextContent('DROID™ 2 Global by Motorola');
    });
  });
});

PhhoneList コンポーネントは props として Phone resource を渡す必要があるので、anguar-mocks を利用し beforeEach 内で作成しています。API をモックするために $httpBackend も用意しています。

$httpBackend.expectGET メソッドで phones/phones.json パスに対する GET リクエストを待ち受けます。.respond メソッドでは返却する値を指定します。

describe('PhoneList', () => {
  let Phone: ng.resource.IResourceClass<Phone>;
  let $httpBackend: ng.IHttpBackendService;
 
  beforeEach(() => {
    angular.mock.module('phoneList');
    angular.mock.inject(($resource, _$httpBackend_) => {
      Phone = $resource(
        'phones/:phoneId.json',
        {},
        {
          query: {
            method: 'GET',
            params: { phoneId: 'phones' },
            isArray: true
          }
        }
      );
 
      $httpBackend = _$httpBackend_;
      $httpBackend.expectGET('phones/phones.json').respond(phones);
    });
  });

1 つ目のテストでは電話一覧を正常に取得でき、並べ替えの初期値が「新しい順」であることを検証しています。もともとのテストでは expect(ctrl.phones).toEqual([{ name: 'Nexus S' }, { name: 'Motorola DROID' }])expect(ctrl.orderProp).toBe('age') のようにコントローラー内に変数を直接参照して検証していました。

しかし、このように実装の詳細をテストするのは好ましくありません。例えば、内部の構造をリファクタリングして、外部の仕様が変更されていないに場合でもテストが fail していまうため、壊れやすテストとなってしまいます。ソートの値にどのような値を設定した結果、どのように描画されるかのように、外部から見た振る舞いをテストすると壊れにくいテストコードになります。

  it('should render phone items', async () => {
    render(<PhoneList Phone={Phone} />);
 
    act(() => {
      $httpBackend.flush();
    });
    const phoneList = await screen.findAllByRole('listitem');
    expect(phoneList).toHaveLength(20);
    expect(phoneList[0]).toHaveTextContent('Motorola XOOM™ with Wi-Fi');
  });

$httpBackend でモックしたレスポンスを返却するためには $httpBackend.flush() メソッドを呼び出します。また描画内容を更新する関数を呼び出す場合には、act でラッピングします。

2 つ目のテストはサーチボックスにテキストを入力したとき要素がフィルタリングされるかどうかを検証します。フォームの入力や、クリックなどのイベントを発生させる場合には @testing-library/user-event を使用します。このパッケージは、よりユーザーの実際の操作に近い書き方でイベントを発生させることができます。

userEvent.type でサーチボックスに「motorola」と入力した後、waitForexpect をラップして、期待した描画の更新があるまで待機する必要があります。

  it('should filter phone items', async () => {
    render(<PhoneList Phone={Phone} />);
 
    act(() => {
      $httpBackend.flush();
    });
    const input = screen.getByRole('textbox');
    userEvent.type(input, 'motorola');
    await waitFor(() => {
      expect(screen.getAllByRole('listitem')).toHaveLength(8);
    });
  });

最後 3 爪のテストは、セレクトボックスを操作して並べ替えされるかテストしています。セレクトボックスの操作は userEvent.selectOptions を呼び出します。

  it('should sort phone items', async () => {
    render(<PhoneList Phone={Phone} />);
 
    act(() => {
      $httpBackend.flush();
    });
    expect((await screen.findAllByRole('listitem'))[0]).toHaveTextContent(
      'Motorola XOOM™ with Wi-Fi'
    );
 
    const select = screen.getByRole('combobox');
    userEvent.selectOptions(select, 'name');
    await waitFor(() => {
      expect(screen.getAllByRole('listitem')[0]).toHaveTextContent('DROID™ 2 Global by Motorola');
    });
  });

ここまでのコミットログは以下のとおりです。


Contributors

> GitHub で修正を提案する
この記事をシェアする
はてなブックマークに追加

関連記事