因果律そのものに対する反逆だ!
「――!」
「その祈りは――そんな祈りが叶うとすれば、それは時間干渉なんてレベルじゃない!君は、本当に神になるつもりかい?」
「神様でも何でもいい」
「今日までデグレと戦ってきたみんなを、希望を信じた開発チームを、私は泣かせたくない。最後まで笑顔でいてほしい」
(なお筆者はまどマギ未見のためこれ以降は普通のテンションでお送りします)
やりたいこと
デグレを防ぐと言えば回帰(リグレッション)テストですが、「過去と未来」とまで言ってしまったからには、実装前の段階からE2Eテストを書き始め、継続してメンテナンスできることを目標にします 1。
流れ的にはだいたい下記のようなイメージになります。
- アイディアだけがある段階から自動テストのスクリプトを書く
- 動くアプリケーションを実装する
- テストが(そこそこ)ちゃんと動く
- 機能追加などの際にもテストが壊れにくい
使うツール
- NodeJS, npm (事前にインストールしておいてください)
- CodeceptJS
準備
事前にNodeJSをインストールしておいてください。
$ mkdir degure-dame-zettai
$ cd degure-dame-zettai
$ npm init -y
$ npm install --save-dev codeceptjs playwright
$ npx codeceptjs init
最後の npx codeceptjs init
ではCodeceptJSの設定を求められますが、すべてデフォルトのままで大丈夫です2。
面倒な人は yes '' | npx codeceptjs init
とすれば全部自動で終わります。すごいね。
今回作るアプリ
こんなクソ記事であまり凝ったものを作ってもしょうがないので 今回はごくかんたんなフォーム入力のサンプルを作ります。「お名前」と書かれたフォームに入力すると、 お前の名前は${xxx}です
と表示するだけのアプリです。
仮実装
テストの仮実装
想定されるユースケースを元にテストを先に書いてしまいます。ユースケースと言っても、出来ることは入力と送信だけですので、テストケースは一つだけで良いでしょう。
-
http://localhost
にアクセスする -
お名前
にtsuemura
と入力する -
送信
をクリックする - ページに
おまえの名前はtsuemuraです
と表示されていることを確認する
これをテストコードに直すと以下のようになります。
Feature('入力フォーム')
Scenario('入力から送信完了まで', ({ I }) => {
I.amOnPage('http://localhost')
I.fillField('お名前', 'tsuemura')
I.click('送信')
I.see('おまえの名前はtsuemuraです')
})
このコードを sample_test.js
として保存し、ターミナルから以下のコマンドを実行しましょう。
$ npx codeceptjs run sample_test.js
……当たり前ですが、Webアプリをまだ作ってないので、テストは失敗しますね!
アプリの仮実装
というわけで、最低限動くものを作ってみます。
<?= $_POST['name'] ? "おまえの名前は${_POST['name']}です" : '' ?>
<form method="POST">
<label for="name" >お名前</label>
<input type="text" id="name" name="name">
<button type="submit">送信</button>
</form>
突然生のPHPコードが出てきて度肝を抜かれますが、仮なので一旦これでいいことにします。上記のコードを index.php
として保存しておいてください。
このコードを実際にWebアプリケーションとして立ち上げるにはPHPとWebサーバが必要になりますが、人類の大半はPHPの実行環境を手元に用意していないと思うので、下記のコマンドでPHPとApacheを立ち上げておくと良いでしょう。
$ docker run -d -p 8080:80 -v "$PWD":/var/www/html php:7.2-apache
実行すると、 http://localhost:8080/index.php
で先程作ったPHPファイルにアクセスできるようになります。
自動テストを動かす
Webアプリ側が(雑ですが)動くようになったので、先程書いたテストを動かしてみましょう。URLのみ、先程立ち上げた http://localhost:8080/index.php
に直しておきます。
Feature('入力フォーム')
Scenario('入力から送信完了まで', ({ I }) => {
I.amOnPage('http://localhost:8080/index.php') // ここだけ直す
I.fillField('お名前', 'tsuemura')
I.click('送信')
I.see('おまえの名前はtsuemuraです')
})
テストコードを修正したら、テストを実行します。
$ npx codeceptjs run sample_test.js
ブラウザが起動し、テストがpassしたら成功です。
ちょっと解説:CodeceptJSのロケータについて
このテストコード、「なんで動くの?」と思う方もいらっしゃるのではないでしょうかと思うので、少し説明しておきます。
通常、Webアプリのテストコードと言えば、 id
や class
などのCSSセレクタを使って要素を特定しますね。
// idがhogeの要素を取得する
document.querySelector('#hoge')
ですが、今回のテストコードにはCSSセレクタは一つも出てきませんでした。
// 「送信」is 何
I.click('送信')
実は、CodeceptJSはCSSセレクタでもXPathでも無い文字列がロケータ(要素探索のキー)として指定されると、表示されている文字列を用いて要素を探索します。例えば、クリックであれば <a>
<button>
などのクリックできそうなタグの中から、指定された文字列を持つ要素を探し、最初に見つかったものを返します。
また、アサーションは要素を指定せず、ページ全体をクロールして目当ての文字列を探しています。
Scenario('入力から送信完了まで', ({ I }) => {
I.amOnPage('http://localhost:8080/index.php')
/* "お名前" という文字列を持つlabelに紐づくinputまたはtextareaを探す */
I.fillField('お名前', 'tsuemura')
/* "送信" という文字列を持つaまたはbuttonを探す */
I.click('送信')
/* ページ全体からこの文字列を含む要素の有無を調べる */
I.see('おまえの名前はtsuemuraです')
})
これらの仕組みにより、Webアプリケーション側の実装が未完了だったとしても、「そこそこ動く」自動テストが作れます。
大規模なリファクタリングを行う
急にPHPに嫌気がさしたのでReactで全面的に作り直します。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>フォームのサンプル</title>
<script crossorigin src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kognise/water.css@latest/dist/light.min.css">
</head>
<body>
<div id="app"></div>
<script crossorigin src="./index.js" type="text/babel"></script>
</body>
</html>
"use strict";
const e = React.createElement;
class Form extends React.Component {
constructor(props) {
super(props);
this.state = { };
}
handleSubmit = e => {
e.preventDefault()
this.setState({
name: e.target.name.value
})
}
render() {
const text = this.state.name ? `お前の名前は${this.state.name}です` : ''
return (
<div>
<p>{text}</p>
<form onSubmit={this.handleSubmit}>
<label htmlFor="name">お名前</label>
<input id="name" name="name" />
<button type="submit">送信</button>
</form>
</div>
)
}
}
const domContainer = document.querySelector("#app");
ReactDOM.render(e(Form), domContainer);
上記のコードを、それぞれ index.html
index.js
として保存しておいてください。先ほどPHPのアプリケーションを動かすために起動したDockerコンテナで、これらのファイルも配信することが出来ます。 http://localhost:8080/index.html
にアクセスすると、先ほどのPHPとほぼ変わらないものが立ち上がります。
さて、この状態でテストを実行するとどうなるでしょうか。テストコードのURLを index.html
に変更してテストを回してみましょう。
Feature('入力フォーム')
Scenario('入力から送信完了まで', ({ I }) => {
I.amOnPage('http://localhost:8080/index.html') // ここだけ直す
I.fillField('お名前', 'tsuemura')
I.click('送信')
I.see('おまえの名前はtsuemuraです')
})
# 実行する
$ npx codeceptjs run sample_test.js
「お名前」というラベルを持つテキストフィールドが見つからず、テストが失敗してしまいました。
これはReactを使っているとかは全く関係なく、作り変える過程で <label>
と <input>
の紐付けを消してしまったからです。
<!-- labelに指定した "for" 属性によってinputとの紐付けが出来ている -->
<label for="name">お名前</label>
<input id="name" name="name" />
<!-- labelとinputとの紐付けが出来ていない -->
<label>お名前</label>
<input name="name" />
Webアプリ側を直すことも可能ですが、ここは一つテストコード側で工夫してみましょう。input要素の探索戦略を変えてみます。ここでは、**「特定の文字列を持つlabel要素の直後にあるinput要素」**という条件で探索してみましょう。
const locateInput = text => (
locate('input').after(
locate('label').withText(text)
))
これをテストコード側で利用してみます。
Feature('入力フォーム')
/* input要素の探索方法を定義 */
const locateInput = text => (
locate('input').after(
locate('label').withText(text)
))
Scenario('入力から送信完了まで', ({ I }) => {
I.amOnPage('http://localhost:8080/index.html')
I.fillField(locateInput('お名前'), 'tsuemura') // 定義したlocateInputを使う
I.click('送信')
I.see('おまえの名前はtsuemuraです')
})
# 実行する
$ npx codeceptjs run sample_test.js
無事にテストが成功するようになりました。
要素内の文字列とページの構造を使ってテストを記述することで、テストコードから実装依存の部分を減らすことができます。
今回の場合、仕様自体が単純で、やったこともほぼ元のPHPコードからReactコードへの載せ替えだったのであまりメリットを感じられないかもしれませんが、 システム移行プロジェクトなどでは実装が大きく変わったときにも動作する テストコードのメリットを感じやすいと思います。
機能を追加する
さて、このアプリに機能を追加し、最大3人まで入力が出来るようにしましょう。
なぜかは分かりません。分からなくてもやらなければいけないときがあるのです。人生とはそういうものです3。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>フォームのサンプル</title>
<script crossorigin src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kognise/water.css@latest/dist/light.min.css">
</head>
<body>
<!-- タグを3つに増やした -->
<div id="form1"></div>
<div id="form2"></div>
<div id="form3"></div>
<script crossorigin src="./index.js" type="text/babel"></script>
</body>
</html>
"use strict";
const e = React.createElement;
class Form extends React.Component {
constructor(props) {
super(props);
this.state = { };
}
handleSubmit = e => {
e.preventDefault()
this.setState({
name: e.target.name.value
})
}
render() {
const header = `${this.props.id}人目` /* 何人目かわからないと不便だからね */
const text = this.state.name ? `おまえの名前は${this.state.name}です` : ''
return (
<div>
<h3>{header}</h3>
<p>{text}</p>
<form onSubmit={this.handleSubmit}>
<label>お名前</label>
<input name="name"/>
<button type="submit">送信</button>
</form>
</div>
)
}
}
/* 増やしたタグそれぞれにアタッチしていく */
ReactDOM.render(e(Form, { id: 1 }), document.querySelector("#form1"));
ReactDOM.render(e(Form, { id: 2 }), document.querySelector("#form2"));
ReactDOM.render(e(Form, { id: 3 }), document.querySelector("#form3"));
コピペにより効率よく3倍に増やすことができました。
テストコードも同様に3倍に増やしていきます。
Feature('入力フォーム')
const locateInput = text => (
locate('input').after(
locate('label').withText(text)
))
Scenario('入力から送信完了まで', ({ I }) => {
I.amOnPage("http://localhost:8080/index.html");
// 1人目
I.fillField(locateInput("お名前"), "hoge");
I.click("送信");
I.see("おまえの名前はhogeです");
// 2人目
I.fillField(locateInput("お名前"), "fuga");
I.click("送信");
I.see("おまえの名前はfugaです");
// 3人目
I.fillField(locateInput("お名前"), "poge");
I.click("送信");
I.see("おまえの名前はpogeです");
})
勘のいい人はお気づきでしょうが、このテストはうまく動きません。
どのフォームに入力するかを指定していない為、全て1人目のところに入力してしまいます。
特定のフォームの中でのみ操作を行うために、 within
を使って操作のスコープを限定します。
まずは、フォームを指し示すロケータを作ります。
const locateForm = text => (
locate('form').withText(text)
)
次に、シナリオを次のように修正します。
within
の引数として locateForm('1人目')
locateForm('2人目')
のように操作対象のフォームを指定しているので、指定した文字列を持つ <form>
要素の中から お名前
送信
などの文字列を含む要素を探索するようになっているはずです。
...
Scenario('入力から送信完了まで', ({ I }) => {
I.amOnPage("http://localhost:8080/index.html");
within(locateForm('1人目'), () => {
I.fillField(locateInput("お名前"), "hoge");
I.click("送信");
I.see("おまえの名前はhogeです");
})
within(locateForm('2人目'), () => {
I.fillField(locateInput("お名前"), "fuga");
I.click("送信");
I.see("おまえの名前はfugaです");
})
within(locateForm('3人目'), () => {
I.fillField(locateInput("お名前"), "poge");
I.click("送信");
I.see("おまえの名前はpogeです");
})
})
# 実行する
$ npx codeceptjs run sample_test.js
上手く行きましたね!
先ほどとは異なり、1人目、2人目、3人目にそれぞれ入力することができました。
元のコードに書かれていた // 1人目
// 2人目
のような余計なコメント行も消すことが出来て読みやすくなりました。
おわりに
いかがでしたか?これで希望を信じた開発チームも最後まで笑顔でいられそうですね!
まどマギは定年までには見ようと思います!それでは、良いテストライフを!
-
BDD(Behavior Driven Development: 振る舞い駆動開発) や ATDD(Acceptance Test Driven Development: 受け入れテスト駆動開発) と呼ばれるものです。 ↩
-
デフォルトの場合、自動操作のドライバには Playwright が使われます。 ↩
-
諸説あり ↩