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
ここまでうまくいっていれば、テストは成功しているはずです。
> [email protected] 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-loader
と ts-loader
を使用することを宣言しています。babel-loader
は Babel により JavaScript をトランスパイルし、最新の構文をブラウザで動く形式に変換します。ts-loarder
は TypeScript を JavaScript にトランスパイルします。
.css
拡張子に対しては css-loader
と style-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-env
は babel
プラグインのプリセットです。必要なプラグインを自動で選択して動く状態にしてくれます。@babel/preset-react
は React 用のプリセットです。
package.json
の scripts
を修正して、開発環境の起動に 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 テストを実行して安全に進めていきましょう。
ここまでのコミットは以下のとおりです。
リンターの導入
続いて、リンターとして ESLint と Prettier を導入します。以下のコマンドで 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 [email protected] [email protected]
react2angular というライブラリを使用することで React のコンポーネントを AngularJS のコンポーネントに変換して画面に埋め込むことができます。そのため、React への以降を段階的に進めていくことができます。
PhoneItmes コンポーネントの作成
まずは始めに、電話一覧の部分を 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
の型定義を作成しましょう。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-src
→src
class
→className
- 変数の展開
{{ }}
→{}
特に、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 で変更を検知できません。そのため、データ取得後は新しいオブジェクトを再代入する方法に修正しています。
ここまで完了したら、開発サーバーで確認してみましょう。一見問題ないように見えますが、アニメーションが行われなくなってしまっています。
アニメーションの実装
これは、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'
}}>
再度開発サーバーで確認してみましょう。今度は適切にアニメーションが付与されているはずです。
しかしながら、要素の並べ替えを行った際のアニメーションが適用されておりません。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>
);
}
これで、並べ替え時のアニメーションも適用されるようになりました。確認してみましょう。
いいですね!いつもどおり、E2E テストを実行しておきましょう。
npm run 2e2
ここまでのコミットログは以下のとおりです。
コンポーネントのテスト
新たに作成した React コンポーネントについてもテストコードを実装するようにしましょう。
Jset のセットアップ
コンポーネントのテストには Jest と Testing 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",
}
}
既存のテストコードを修正する
既存の単体テストコードでは Jasmine と Karma を使用されていますので、これを 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/react
の render
関数によりコンポーネントが描画されます。要素を取得するために 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
を定義しています。Phone
は phone-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 のコンポーネントと同じ名前で作成したので、ここは修正する必要はありません。開発サーバーで確認してみましょう。問題がなけらば、前と変わらないことが確認できるはずです。
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」と入力した後、waitFor
で expect
をラップして、期待した描画の更新があるまで待機する必要があります。
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');
});
});
ここまでのコミットログは以下のとおりです。