はじめに
どうも。テスト駆動開発初心者です。
っていう言い方もどうかと思うのですが、ユニットテストもロクに、というか、ほぼやったことなく、テスト駆動開発なんて本当にできるもんなのかいね? という懐疑心を持っていたほどの初心者です。
しかし、やはりトレンドというか、いやもうだいぶ主流になってきたユニットテストとテスト駆動開発。このビッグウェーブに乗らないわけにはいかないじゃないですか!(←単に言いたかっただけ)
そこで例のKent Beckの名著「テスト駆動開発」本をゲットし、知識獲得に向けて邁進することとしました。
朝の通勤時間帯にもみくちゃにされながらもボチボチと読み進め、ようやくテスト駆動開発というものの有効性が知識として理解できたところであります。
とはいえ、この本に書かれたことのすべてを理解できたわけでも、頭に入ったわけでもなく、エッセンスや基本を理解できた程度、だと思います。
あとは、実践あるのみ!
ということで本記事、私が実際に進めていったユニットテストの初歩の初歩をトレースしてみたいと思います。
あくまでもトレースしているだけであって、これが基本とか正しいというわけではありません。
なお、この記事で扱っているものは、以下の通りです。必ずしもこのバージョンでなくてもよいですが、後述するように、PHPUnitはバージョンによってサポートするPHPのバージョンが異なるので要注意です。PHP5系をまだ使っている人は、PHPUnit5系しか使えません。またPHP7.0対応はPHPUnit5もしくは6のみ対応になります。現在最新版となるPHPUnit7はPHP7.1以降をサポートします。
詳しくは公式サイトでサポートバージョン(Supported Versions)を確認してください。サポート終了年月日(End Of Life)も書かれています。
- PHP 7.0
- PHPUnit 6.5.7 (PHP7.0~7.3対応)
感想
すみません、とりあえず先に感想です。
この記事で行なっているユニットテストの実施ステップは、非常にうっとうしいと思われる方も多いかと思います。
なので、この先の記事を読まれない方や、途中で読むのをやめる方などもおられるかもしれませんので、ここで私の初テスト駆動開発の感想を書いておきます(末尾にもちょっと書いてあります)。
最初は私も、少しうっとうしいかなと思いながらテスト駆動開発をやってみました。
しかし、実際には細かくステップを踏んでいったことで、着実に目標に到達する実感が得られたので、やってみて良かったというのが正直な感想です。
この開発スタイルは、さきのKent Beckの名著で実際に紹介されている方法から得たものです。
テストが先にありき(テストファースト)なので、実際のテスト対象コードが存在しない状態からテストを書き、テストでエラーになることをあえて確認して次のステップに進む、といった方法で進めています。
もしかしたら、実際にテスト駆動開発を行う場合には、ここまでの「やりすぎ感」あふれる方法から、若干うっとうしい部分を取り除いたやり方になっていくかもしれません(私はまだ始めたばかりなので、実際にそうなるのかは分かりません)。
ですが、やはりこの方法によって、分かっていたつもりのステップを踏み外すこともなく、あるいは次のステップをゆっくり思考できることで、安心感と着実性が生まれたのは間違いないと確信しています。初心者ならば、なおさらなのだと思いました。
ここでは簡単な、テストすら不要なコードで試していますが、実際の開発シーンでは当然ながらもっともっと複雑なコードが生成されていくわけで、細部を端折って進めた場合、多くの不具合・バグが生まれ、テストをしてもまったくテストが通らなくなるような状況にもなりかねないのでは…と思いました。
特にリファクタリングを必ず迎えることになる実際の開発シーンでは、こうした着実性がなければ、怖くてコードがいじれなくなると思います。
ここで実際に私が体験したことが、世の中で実際に行われれているテスト駆動開発と比較して正しいかどうかは分かりませんが、個人的には、この粒度は間違ってないなと思いました。
準備
ここではcomposerを使ってPHPUnitをインストールすることにします。
composerがすでにインストールされている人は、飛ばしてかまいません。
なお、PHPがインストールされている前提で話を進めます。yum install phpやapt install php、macOSならHomebrewやmacportsなどで適宜インストールしてください。
Windows10の方はWindows Subsystem for Linux(WSL)を使うとよいかもしれません。Ubuntuなのでapt installでいけます。Windows版のインストーラもあるようですが、私は試したことがないので方法も手順もわかりません。
composerのインストール
まずは、公式サイトのDownload Composerページにアクセスします。
ここに「Command-line installation」という項目があるので、その下にある枠内のphpコマンド4行分を、それぞれ1行ずつコピペ実行していきます。コピーした行はターミナルにペーストして、コマンド実行すればいいというわけです。
$ php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
$ php -r "if (hash_file('SHA384', 'composer-setup.php') === '544e09ee996cdf60ece3804abc52599c22b1f40f4323403c44d44fdfdd586475ca9813a858088ffbc1f233e9b180f061') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
$ php composer-setup.php
$ php -r "unlink('composer-setup.php');"
$ chmod +x composer.phar
$ sudo mv composer.phar /usr/local/bin/composer
基本、これでインストールが完了します。
バージョンを確認して、問題なく起動できればPHPUnitのインストールに進めます(以下の例の日時は、インストール時期によって異なると思いますので気にしなくてよいです)。
$ composer --version
Composer version 1.6.3 2018-01-31 16:28:17
phpunitのインストール
さて、ここではPHP7.0対応となるPHPUnit6系をインストールするので、6.*.*のうち最新版を確認したいなと思います。
composer showを使って、以下のようにパッケージのバージョンが確認できるようです。なおcomposerでのPHPUnitのパッケージ名は「phpunit/phpunit」になります。私はcomposerはあまり詳しくないので詳細は割愛します。おそらく「パッケージカテゴリ/パッケージ名」ということなのだと推察します。
末尾の6.*.*は、6系の最新版を探すために、メジャーバージョン以外をワイルドカードに設定したバージョン番号指定です。
$ composer show -a phpunit/phpunit '6.*.*'
name : phpunit/phpunit
descrip. : The PHP Unit Testing framework.
keywords : testing, phpunit, xunit
versions : 6.5.x-dev, 6.5.7, 6.5.6, 6.5.5, 6.5.4, 6.5.3, 6.5.2, 6.5.1, 6.5.0, 6.4.x-dev, 6.4.4, 6.4.3, 6.4.2, 6.4.1, 6.4.0, 6.3.x-dev, 6.3.1, 6.3.0, 6.2.x-dev, 6.2.4, 6.2.3, 6.2.2, 6.2.1, 6.2.0, 6.1.x-dev, 6.1.4, 6.1.3, 6.1.2, 6.1.1, 6.1.0, 6.0.x-dev, 6.0.13, 6.0.12, 6.0.11, 6.0.10, 6.0.9, 6.0.8, 6.0.7, 6.0.6, 6.0.5, 6.0.4, 6.0.3, 6.0.2, 6.0.1, 6.0.0
type : library
license : BSD 3-Clause "New" or "Revised" License (BSD-3-Clause) (OSI approved) https://spdx.org/licenses/BSD-3-Clause.html#licenseText
source : [git] https://github.com/sebastianbergmann/phpunit.git 6bd77b57707c236833d2b57b968e403df060c9d9
dist : [zip] https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6bd77b57707c236833d2b57b968e403df060c9d9 6bd77b57707c236833d2b57b968e403df060c9d9
names : phpunit/phpunit
ここで、6系の最新版は6.5.7であることがわかったので、composer.jsonファイルを作ってインストールパッケージを指定します。
composer.jsonは、PHPUnitを導入したいディレクトリの直下に配置します。
たとえば/home/ezura/phpunitというディレクトリを作って、その配下にPHPUnitをインストールしたいという場合は、以下のようになります(/home/ezuraというホームディレクトリは、すでに存在するものとします。macOSなど、システムによっては「/Users/ユーザ名」などの場合もあります)。
$ cd
$ mkdir phpunit
$ cd phpunit
$ vi composer.json
エディタはvi(vim)でなくても構いません。
仮想環境や、WSLなどで共有フォルダを使える場合は、ホストOS側で編集したファイルを共有フォルダを通じて渡しても問題はないでしょう。
composer.jsonファイルの中身は、以下のようになります。
{
"require-dev": {
"phpunit/phpunit": "6.5.*"
}
}
このあと、composer installで実際にPHPUnitをインストールします。
$ composer install
さて、以下のようなエラーが出た場合、それはPHP拡張モジュールのmbstringがインストールされていないからとなります(割とありがちらいし)。
Your requirements could not be resolved to an installable set of packages.
Problem 1
- phpunit/phpunit 6.5.7 requires ext-mbstring * -> the requested PHP extension mbstring is missing from your system.
- phpunit/phpunit 6.5.6 requires ext-mbstring * -> the requested PHP extension mbstring is missing from your system.
- phpunit/phpunit 6.5.5 requires ext-mbstring * -> the requested PHP extension mbstring is missing from your system.
- phpunit/phpunit 6.5.4 requires ext-mbstring * -> the requested PHP extension mbstring is missing from your system.
- phpunit/phpunit 6.5.3 requires ext-mbstring * -> the requested PHP extension mbstring is missing from your system.
- phpunit/phpunit 6.5.2 requires ext-mbstring * -> the requested PHP extension mbstring is missing from your system.
- phpunit/phpunit 6.5.1 requires ext-mbstring * -> the requested PHP extension mbstring is missing from your system.
- phpunit/phpunit 6.5.0 requires ext-mbstring * -> the requested PHP extension mbstring is missing from your system.
- Installation request for phpunit/phpunit 6.5.* -> satisfiable by phpunit/phpunit[6.5.0, 6.5.1, 6.5.2, 6.5.3, 6.5.4, 6.5.5, 6.5.6, 6.5.7].
mbstringのインストールは、たとえばapt(apt-get)なら、以下のように行います。全部書ききれないので、他のパッケージシステムの場合は、適宜調べてみてください(macportsの場合は「sudo port install php70-mbstring」でよいと思いますが、macOSでのcomposerインストールなどを試みている方の記事がQiitaにもいくつかあるようなので、そちらも参考にされるとよいでしょう)。
mbstringのインストールが終わったら、改めてPHPUnitのインストール(composer install)を行ってください。
$ apt search php7.0-mbstring
php7.0-mbstring/xenial-updates 7.0.25-0ubuntu0.16.04.1 amd64
MBSTRING module for PHP
$ sudo apt install php7.0-mbstring
PHPUnitのインストールが完了しますが、「phpunit」と叩いても実行できません。
phpunitコマンドはインストールコマンドを実行したディレクトリ(この記事では$HOME/phpunit。$HOMEはホームディレクトリのシェル環境変数HOMEの中身が反映されます)からの相対パスでvendor/binディレクトリにあります。
以下のように実行するか、シェル環境変数PATHを$HOME/phpunit/vendor/binに通しておく必要があります。
$ vendor/bin/phpunit --version
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
なお、composer関連の環境変数を設定している場合は、その内容に従った先にインストールされます。
たとえば以下のような設定になっていたら、自分のホームディレクトリ直下の.composerディレクトリ配下のvendor/binディレクトリにphpunitが用意されます。その場合は、~/.composer/vendor/bin/phpunitと実行するか、COMPOSER_BIN_DIR環境変数を環境変数PATHに設定してパス指定なしで実行できるようにする必要があります。
$ cat ~/.bash_profile | grep COMPOSER
export COMPOSER_HOME=$HOME/.composer
export COMPOSER_BIN_DIR=$COMPOSER_HOME/vendor/bin
ディレクトリ
開発コードとテストコードを配置するディレクトリを、今回は簡単にsrc、testというディレクトリ名で作成します。
$ mkdir src test
これで、下準備はできたかと思います。
テスト駆動開発
実際に、テストコードと開発コードを徐々に書いて実装を進めていくいくシーンに入ります。
仕様
今回「開発」する仕様…というほどのものではありませんが、以下を完成させることを目標としてテスト駆動開発を進めます。
- 簡単な二項演算を行うクラスCalcを作成する。
- Calc::add($a, $b)で$aと$bを加算した値を返す。
- Calc::sub($a, $b)で$aから$bを減算した値を返す。
実際には、この程度のものはソラで書けて普通だしテストする価値のある対象ではありません。
今回は、Calcクラスの実装が目的ではなく、このクラスを通じてテスト駆動開発の有用性を実証するための個人的検証であるため、開発コードの内容や質は問わないこととしました。
テストをちゃんと動かせるまで
最初のテストの作成
テスト駆動開発は「テストファースト」なので、テストから先に書きます。
べつに最近の「なんとかファースト」を意識したわけではなく、書籍にも実際に登場する言葉です。
PHPUnitのテストコードは、以下の要領で書き始めることにしました。
- test/CalcTest.phpファイルを作成し、そこにCalcTestクラスを実装する。
- CalcTestクラスは、PHUnit\Framework\TestCaseクラスを継承する。これはPHPUnitを用いたテストクラスすべてに必要となります。
まず、空のテストクラスを作成してみました。
<?php
use PHPUnit\Framework\TestCase;
require_once 'src/Calc.php';
class CalcTest extends TestCase
{
}
本当に中身がありません。でもテストクラスはできました。
とはいえ、これだけでテストを実施してもCalcクラスを実装したsrc/Calc.phpファイルがないので、テスト以前にrequire_onceでPHPのエラーとなります。
そこで最低限、空のCalcクラスを実装したsrc/Calc.phpも同時に用意しておきます。
<?php
class Calc
{
}
テストの準備は整いましたので、phpunitのコマンドをどう実行するのか確認しておきます。
本記事では、phpunitコマンドはvendor/bin/phpunitにあるので、以下のようにコマンドを実行します。
任意のディレクトリ(たとえば/usr/local/binや$HOME/binなど)など、環境変数PATHに通っているディレクトリにphpunitがあれば、「vendor/bin/」は不要で、そのまま「phpunit」と打てば実行できます。
$ vendor/bin/phpunit テストするクラス
さて、いよいよテストを動かしてみます。
え、何もテストなんかできないのは分かり切ってる?
その通りです。
しかし、私はテスト初心者なので、まず本当にこの状況で動くのかを見てみたかったのでした。
つまり、今設定した状態で、phpunitから「テストクラスが見当たらない」「ファイルが見つからない」などテスト以前のエラーが戻されるのか、「テストクラスが空っぽです」など、環境は整ったがテストコードがないから先に進まないという状況なのかを判別したい目的がありました。
ということで、この状態でphpunitを実施します。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
W 1 / 1 (100%)
Time: 207 ms, Memory: 4.00MB
There was 1 warning:
1) Warning
No tests found in class "CalcTest".
WARNINGS!
Tests: 1, Assertions: 0, Warnings: 1.
お、なんかテストっぽい動きをみせている!!!
とはいえ、Warningが出てますね…。
当然といえば当然ですが、テストコードが実装されていないので「No tests found」と言われてしましました。
最終行に「Tests: 1, Assertions: 0, Warnings: 1.」という表示があるので、テストが1つあるということはphpunitに認識されているのでしょう。
「Assertions: 0」とあるのは、テストコードはアサーションを実施することで、ユニットテスト対象のメソッドの戻り値と、実際に戻されるであろう期待値を比較することで、正当性を確認していく作業になるため、いくつのアサーションを実行したか、その数が表示されます。
アサーションはプログラミング言語で用意されているassert関数などと同じように、値を比較した結果に応じて処理を中断/続行させるものです。
ユニットテストの場合は、言語で準備されたassert関数ではなく、そのフレームワークで用意されているassert*()メソッドを使って行います。
さて、結果的にテストはWarningで終わりましたが、ユニットテストの環境は、どうやらこれで間違っていない感じです。
これからちゃんとテストコードを書いてみます。
テストコードの実装
といっても、やっぱりまだ2歩目なので、中身のないテストメソッドだけを用意します。
<?php
use PHPUnit\Framework\TestCase;
require_once 'src/Calc.php';
class CalcTest extends TestCase
{
/**
* Calc::add()のテスト
* @test
*/
public function testAdd()
{
}
}
testAdd()メソッドは、Calc::add()メソッドをテストする目的で作ったテストメソッドです。
中身は、まだ空っぽです。
phpunitから「こんなメソッドではダメだ」とダメ出しされるのか、「メソッドは認識したけど空じゃん」と言われるのか、を確認していきます。
ここまで、ユニットテストをしてるのかしてないのか分からないと思います。
読み飽きちゃった方、すみません…。
ちなみにメソッド名が「test」から始める場合、@testアノテーションの記述は不要のようですが、ここでは一応、一貫してアノテーションを書いています。メソッド名がtestで始まっていれば、@testがなくてもよさそうです。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
R 1 / 1 (100%)
Time: 204 ms, Memory: 4.00MB
There was 1 risky test:
1) CalcTest::testAdd
This test did not perform any assertions
OK, but incomplete, skipped, or risky tests!
Tests: 1, Assertions: 0, Risky: 1.
おっと! phpunitから「OK」出ました!
しかし「, but...」って言われてますね。incomplete(不完全)か、skipped(飛ばされた)か、risky(リスクの高い)テストのどれかだと言われています。
そのちょっと上の行を見ると、1つもアサーションが実行されなかったことを示すメッセージが出ています。
つまり、アサーションによって戻り値と期待値の評価を行なっていないテストは不完全か、アサーションをすっとばすテスト実装になっていたか、テストを無理やり通すインチキをどっかでやっているか、などに分類されているとphpunitが言ってるようです。
ではメソッドに、やっとこアサーションを書いていきます。
が、次もまだ怠惰な状況が続くかも…ノロくてすみません…。
ダミーのアサーションを書く
いきなりCalc::add()をテストするアサーションを書いて、はたしてそれはテストに失敗しているのか、アサーションが間違っているのか、判断できるのでしょうか。
ということで、「確実にアサーションが動くぜ」を確立したいと思います。
どうするかというと、「ローカル配列変数の数を数える」というアサーションを書いて、これが期待通りに動作するかの確認です。これでアサーションの書き方を、自分なりに確認できるはずです。
<?php
use PHPUnit\Framework\TestCase;
require_once 'src/Calc.php';
class CalcTest extends TestCase
{
/**
* Calc::add()のテスト
* @test
*/
public function testAdd()
{
$stack = [];
$this->assertEquals(0, count($stack));
}
}
中身を書きましたが、開発コードとなるCalcクラスとは、全く関係のないテストですね。
ちなみに上記のメソッド内の2行はPHPUnitのマニュアルに登場しているものの一部を拝借しています。
PHPUnitでのテストの基本設定、というか、基盤づくりをしてみた感じです。このアサーションの書き方をもとにして、本格的にテストコードを記述していく状態にします。
この先テストコードをいくつ書いたところで、基本中の基本のアサーションを正しく書けなければ意味がないからです。
私の場合、まずは「ここにこうやってテストコードを書いてテストを実行すれば、ちゃんとユニットテストが動く」という状態を確立したかった意図がありました。
ちなみに上記ではassertEquals()アサーションメソッドを使っています。
このメソッドは第1引数に期待値($expected)、第2引数にテスト対象のメソッドの実際の戻り値($actual)を設定します。
なお、値の型まで一致させたい場合は、assertSame()アサーションメソッドを使います。
さて、この状態でテストしてみます。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 207 ms, Memory: 4.00MB
OK (1 test, 1 assertion)
今度はWarningも出ずにテストにパスしました! やったよ父ちゃん!
最終行に「1 test, 1 assertion」とあるので、テストメソッド1つを実施し、アサーション1つを実行したという結果が出ました。
もちろん、このテストコードでテストにパスするのは、当然といえば当然ですが、私はこの時点までで、以下のことを確認し、phpunitでのテストコードをこの先も書き続ける自信を獲得しました。
- テストコードと開発コードの配置に問題がない。
- テストクラスを記述するファイル名に問題がない。
- テストクラス名、テストクラスの実装方法に問題がない。
- テストメソッド名、テストメソッドの実装方法に問題がない。
- アサーションの書き方に問題がない。
ここまで確認できたので、これから、本当のテストコードを書いていく勇気がでてきました。
実際のテストコードをいきなり書き始めると、その時に出るエラーが、そのユニットテストが悪いのか、それ以前にテストのやり方が問題なのか、その切り分けがしにくくなると感じたため、あえて細かくステップを踏んでテストコードのベースまでたどり着きました。
開発コードのテスト
さて、さきほどの仕様に基づいた実装を行います。
が、テストファーストなので、テストコードから先に書きます。
Calc::add()メソッドのテスト
まずは、加算であるCalc::add()メソッドのテストコードを書きます。
assertEquals()の使用を基本に、いくつかのパターンを想定してアサーションを書いていきます。
ですがまずは、基本的な1パターンを書いてみます。
<?php
use PHPUnit\Framework\TestCase;
require_once 'src/Calc.php';
class CalcTest extends TestCase
{
/**
* Calc::add()のテスト
* @test
*/
public function testAdd()
{
$calc = new Calc();
$this->assertEquals(2, $calc->add(1, 1));
}
}
Calc::add()メソッドに「1+1=2」を期待するテストです。この結果がおかしければ、アサーションによりテストが中断されます。
では、テストしてみましょう。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
E 1 / 1 (100%)
Time: 206 ms, Memory: 4.00MB
There was 1 error:
1) CalcTest::testAdd
Error: Call to undefined method Calc::add()
/Users/ezura/test/CalcTest.php:15
ERRORS!
Tests: 1, Assertions: 0, Errors: 1.
「There was 1 error:」と、エラーが1つ出たとメッセージされました。
「1) CalcTest::testAdd」メソッド内で、「Call to undefined method Calc::add()」エラーになっています。
そりゃそうですね。Calcクラスは、まだ空っぽの状態なんですから。add()メソッドの実装がないので、当然テストでCalc::add()の呼び出しエラーとなります。phpunitも「ERRORS!」とお怒りになるのももっともです。
では、Calcクラスにadd()メソッドを実装していきます。
しかし、ここでは「現在のテストコードを変更せずに、必ずテストに合格するであろうadd()メソッド」を実装します。
それはどういうコードかというと、以下のコードになります。
<?php
class Calc
{
public function add($a, $b)
{
return 2;
}
}
バカにするな? いえいえ、バカにしていません。
テストコードは書き換えていません。このコードは、Calc::add()が2を返すことを期待しています。
ということで、期待に沿うコードを、まずは書きました。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 223 ms, Memory: 4.00MB
OK (1 test, 1 assertion)
期待通り、テストはパスします。これがパスしなければ、何かがおかしいのです。
さて、仕様ではCalc::add()メソッドは2つの引数の加算値を戻すようになっています。
つまり、1+1=2以外の加算もできなければなりません(そりゃそうだ)。
ということで、2つ目のアサーションを書き足します。
さっきは1と1という同じ値を足してみたので、今度は違う値を足してみたいと思います。
<?php
use PHPUnit\Framework\TestCase;
require_once 'src/Calc.php';
class CalcTest extends TestCase
{
/**
* Calc::add()のテスト
* @test
*/
public function testAdd()
{
$calc = new Calc();
$this->assertEquals(2, $calc->add(1, 1)); // 同値加算
$this->assertEquals(7, $calc->add(3, 4)); // 異値加算
}
}
3+4は7。この結果を期待してよいはずです。
さて、このテストコードでテストしてみましょう。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
F 1 / 1 (100%)
Time: 210 ms, Memory: 4.00MB
There was 1 failure:
1) CalcTest::testAdd
Failed asserting that 2 matches expected 7.
/Users/ezura/test/CalcTest.php:16
FAILURES!
Tests: 1, Assertions: 2, Failures: 1.
「There was 1 failure:」、つまり(PHPのエラーではなく)テストが1つ失敗したとメッセージが出ました。
「Failed asserting that 2 matches expected 7.」、7を期待していたのに戻り値が2のため失敗した、といっています。
ぐぬぬっ。どういうことですか!
いや、そりゃそうですね。Calc::add()の今の実装は、2という値を直接戻り値としていますから、当然2つ目の3+4のアサーションは失敗に終わります。
ということで、Calc::add()の実装を修正します。
今度は定数を戻すのではなく、仕様を正しく満たせるよう、加算の結果を戻す実装にしましょう。
<?php
class Calc
{
public function add($a, $b)
{
return $a + $b;
}
}
どうでしょうか…自分で信じて書いたコードです。この実装で、きっと間違いないと思います!(ぉぃ)
さっそくテストしてみます。テストコードは変更していません。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 206 ms, Memory: 4.00MB
OK (1 test, 2 assertions)
パスしました!
ここまでのテストにおいては、現実装で問題がないことを確認できました。ひと安心です。
ここで終わりにはしません。
さらに、考えられるパターンをいくつかテストコードとして書き足していきます。
<?php
use PHPUnit\Framework\TestCase;
require_once 'src/Calc.php';
class CalcTest extends TestCase
{
/**
* Calc::add()のテスト
* @test
*/
public function testAdd()
{
$calc = new Calc();
$this->assertEquals(2, $calc->add(1, 1)); // 同値加算
$this->assertEquals(7, $calc->add(3, 4)); // 異値加算
$this->assertEquals(0, $calc->add(0, 0)); // ゼロ同士の加算
$this->assertEquals(5, $calc->add(5, 0)); // 片方がゼロの加算
$this->assertEquals(-2, $calc->add(3, -5)); // 負数との加算
$this->assertEquals(-10, $calc->add(-4, -6)); // 負数同士の加算
}
}
どうでしょうか。このぐらいのパターンでテストしてパスすれば、実装は問題がないと言えるかもしれません。
テストしてみます。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 310 ms, Memory: 4.00MB
OK (1 test, 6 assertions)
おお! エラーも失敗もなし。テストにパスしました。
新たなテストメソッドの追加
加算のメソッドは概ねよさそうなので、今度は減算メソッドに取り掛かります。
テストファーストなので、テストを先に書きます。
/**
* Calc::sub()のテスト
* @test
*/
public function testSub()
{
$calc = new Calc();
$this->assertEquals(1, $calc->sub(2, 1)); // 大から小を引く
}
今回も、一歩ずつゆっくり始めます。そしてテストを実施。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
.E 2 / 2 (100%)
Time: 211 ms, Memory: 4.00MB
There was 1 error:
1) CalcTest::testSub
Error: Call to undefined method Calc::sub()
/Users/ezura/test/CalcTest.php:30
ERRORS!
Tests: 2, Assertions: 6, Errors: 1.
Calc::sub()は未実装なので、エラーになりました。
ということで、減算メソッドを実装します。
<?php
class Calc
{
public function add($a, $b)
{
return $a + $b;
}
public function sub($a, $b)
{
return 1;
}
}
ここでもテストが確実に通るように1を直接返しています。
テストコードは何も変えずに、テストを行います。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 222 ms, Memory: 4.00MB
OK (2 tests, 13 assertions)
当然ではありますが、テストをパスしました。
加算メソッドの時と同様に、1つだけアサーションを追加してみます。
さっきは大きい値から小さい値を引いたテストだったので、今度は小さい値から大きい値を引いてみます。
/**
* Calc::sub()のテスト
* @test
*/
public function testSub()
{
$calc = new Calc();
$this->assertEquals(1, $calc->sub(2, 1)); // 大から小を引く
$this->assertEquals(-3, $calc->sub(2, 5)); // 小から大を引く
}
これでテストしてみます。Calc::sub()メソッドの実装は変えていません。add()メソッドのテストのことを思い出せば、当然今回もエラーになる予兆です。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
.F 2 / 2 (100%)
Time: 24 ms, Memory: 4.00MB
There was 1 failure:
1) CalcTest::testSub
Failed asserting that 1 matches expected -3.
/Users/ezura/test/CalcTest.php:31
FAILURES!
Tests: 2, Assertions: 8, Failures: 1.
予感的中!(そりゃそうだ)
sub()メソッドのデバッグをしましょう。問題は1を直接戻り値に設定していたからなのは明白です。
これを仕様に従った正しい実装に変更します。
<?php
class Calc
{
public function add($a, $b)
{
return $a + $b;
}
public function sub($a, $b)
{
return $a - $b;
}
}
これでいけそうですね。ふたたびテストを実施します。
テストコードには手を加えていません。Calc::sub()メソッドのデバッグだけを行いました。1ヶ所だけの変更です。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 20 ms, Memory: 4.00MB
OK (2 tests, 8 assertions)
テストにパスしました。2テスト、8アサーションなのは、add()メソッドも同時に実行されているからです。
さて、add()メソッドの時と同様に、考え得るパターンのアサーションを入れてみましょう。
/**
* Calc::sub()のテスト
* @test
*/
public function testSub()
{
$calc = new Calc();
$this->assertEquals(1, $calc->sub(2, 1)); // 大から小を引く
$this->assertEquals(-3, $calc->sub(2, 5)); // 小から大を引く
$this->assertEquals(5, $calc->sub(5, 0)); // ゼロを引く
$this->assertEquals(-5, $calc->sub(0, 5)); // ゼロから引く
$this->assertEquals(0, $calc->sub(0, 0)); // ゼロ同士の減算
$this->assertEquals(4, $calc->sub(-3, -7)); // 負数同士を引く
$this->assertEquals(9, $calc->sub(0, -9)); // ゼロから負数を引く
}
sub()メソッドは手を入れていません。先ほどのデバッグで問題が改善されていれば、テストは通るはずです。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 18 ms, Memory: 4.00MB
OK (2 tests, 13 assertions)
どうやらsub()メソッドのロジックも適切だったようです。
仕様を満たす実装にひと段落がついたようで、ほっと胸をなでおろしました。
テストとリファクタリング
テストはリファクタリングに有用だといいます。逆に、テストのないリファクタリングは不安もしくはリスクを伴うとも言えそうです。
テストが現状のコードの正当性を担保するものだとするならば、コードに手を加えた結果、テストにパスしなくなったとしたら、変更した箇所に問題があることが明らかになります。これはリファクタリングに限らず通常の開発を進める上でも言えますが、リファクタリングでも少しずつ変更を加え、テストをする、というサイクルを繰り返すことで、バグをすぐに発見・修正することができそうです。
setUpBeforeClass()メソッドを使う
個別のテストごとにCalcクラスをnewしてインスタンスを生成するよりも、一度インスタンスを生成したらテスト全体で使い回せるようにした方が合理的かもしれません。
setUpBeforeClass()メソッドは、一番最初のテスト実施前に1度だけ実行されるメソッドです。テスト全般で使用する設定やリソース、インスタンスの生成などをここで行います。
なおsetUp()メソッドもありますが、これはテストメソッドの実行たびに最初に呼び出されるものです。
setUpBeforeClass()メソッドは、DBリソースへアクセスするためのハンドラインスタンスの生成などで利用されるのが主だそうです。
今回のテスト程度では、テストごとにインスタンスを生成してもたかがしれていますが、インスタンスの共有ができるという意味では、Calcクラスのインスタンスをテスト全体で共有できるように、テストコードを改修してみます。
なお、setUpBeforeClass()メソッドはstaticメソッドです。したがって、$thisなどは使用できません。共有させるためのクラスプロパティはstatic宣言したものに限られます。
それでは、テストコードをリファクタリングしていきます。ここでも、ステップを踏みながら進めてみます。
$calcをprivate staticでクラスプロパティとして設定します。
setUpBeforeClass()メソッドで、作成したクラスプロパティ$calcにCalcクラスのインスタンスを保存します。
<?php
use PHPUnit\Framework\TestCase;
require_once 'src/Calc.php';
class CalcTest extends TestCase
{
private static $calc;
public static function setUpBeforeClass()
{
self::$calc = new Calc();
}
// ... 略 ...
}
ここまでで一度テストをしてみます。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 18 ms, Memory: 4.00MB
OK (2 tests, 13 assertions)
テストには影響がでていないようなので、先に進みます。
setUpBeforeClass()メソッドでインスタンスを共有できるようにしたので、各テストでこの$calcを使えるように修正します。
<?php
use PHPUnit\Framework\TestCase;
require_once 'src/Calc.php';
class CalcTest extends TestCase
{
private static $calc;
public static function setUpBeforeClass()
{
self::$calc = new Calc();
}
/**
* Calc::add()のテスト
* @test
*/
public function testAdd()
{
$this->assertEquals(2, self::$calc->add(1, 1)); // 同値加算
$this->assertEquals(7, self::$calc->add(3, 4)); // 異値加算
$this->assertEquals(0, self::$calc->add(0, 0)); // ゼロ同士の加算
$this->assertEquals(5, self::$calc->add(5, 0)); // 片方がゼロの加算
$this->assertEquals(-2, self::$calc->add(3, -5)); // 負数との加算
$this->assertEquals(-10, self::$calc->add(-4, -6)); // 負数同士の加算
}
/**
* Calc::sub()のテスト
* @test
*/
public function testSub()
{
$this->assertEquals(1, self::$calc->sub(2, 1)); // 大から小を引く
$this->assertEquals(-3, self::$calc->sub(2, 5)); // 小から大を引く
$this->assertEquals(5, self::$calc->sub(5, 0)); // ゼロを引く
$this->assertEquals(-5, self::$calc->sub(0, 5)); // ゼロから引く
$this->assertEquals(0, self::$calc->sub(0, 0)); // ゼロ同士の減算
$this->assertEquals(4, self::$calc->sub(-3, -7)); // 負数同士を引く
$this->assertEquals(9, self::$calc->sub(0, -9)); // ゼロから負数を引く
}
}
リファクタリングとは、外部的な挙動を変えることなく内部構造を変更する作業。
すなわち、この変更においてテストが通らなくなったら失敗となります。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 19 ms, Memory: 4.00MB
OK (2 tests, 13 assertions)
テストは成功しました。インスタンスの生成に関わる部分なので、そもそもリファクタリングに失敗していたらメソッド自体が呼び出せないでしょう。
Calc::sub()メソッドのリファクタリング
最後に、テストコードではなく開発コード側をリファクタリングしてみます。
sub()メソッドは減算メソッドですが、基本的には加算に相当するはずです。
つまり、add()メソッドとsub()メソッドは一種の重複関係にあると言えなくもないはずです(つまり、a - b = a + -bということ)。
とまあ、強引に理屈を作ってみましたが、実際にsub()はadd()への呼び出しに置き換えられます。
実際の開発シーンでは、重複はメソッドに外出しして共有化を図るものです。こうすることでロジックに変更が起きても1ヶ所の修正だけでよくなるわけです。
加算と減算について、ロジックが変更されることは、おそらくこの宇宙が死滅するまで変わらないものとは思いますが、重複を避ける習慣はつけておきたいものです。
ということで、リファクタリングを試みます。
<?php
class Calc
{
// ... 略 ...
public function sub($a, $b)
{
return $this->add(-$a, -$b); // おや???
}
さて、リファクタリングしたのでテストをしてみましょう。
sub()メソッドのテストコードは変更していません。テストが通れば、このリファクタリングは成功したことになります。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
.F 2 / 2 (100%)
Time: 18 ms, Memory: 4.00MB
There was 1 failure:
1) CalcTest::testSub
Failed asserting that -3 matches expected 1.
/Users/ezura/test/CalcTest.php:35
FAILURES!
Tests: 2, Assertions: 7, Failures: 1.
エラーがでてしまいました!! どうやらリファクタリングに問題があったようです。
「Failed asserting that -3 matches expected 1.」なので、sub()メソッドが-3を戻しているようです。
CalcTest.phpの35行目とは、どのアサーションでしょうか。
<?php
use PHPUnit\Framework\TestCase;
require_once 'src/Calc.php';
class CalcTest extends TestCase
{
// ... 略 ...
/**
* Calc::sub()のテスト
* @test
*/
public function testSub()
{
$this->assertEquals(1, self::$calc->sub(2, 1)); // 大から小を引く
// ... 略 ...
減算メソッドのアサーションの一番最初、のっけからテストに失敗しています。
2-1が-3になってしまったとは、どういうことでしょうか。sub()の実装を再確認します。
<?php
class Calc
{
// ... 略 ...
public function sub($a, $b)
{
return $this->add(-$a, -$b);
}
既存のロジックは「$aから$bを引く」というものでした。もちろんsub()メソッドを使う側はその使い方を想定していますし、それで問題ありません。
しかし内部のロジックは「$aに-$bを足す」という変更です。
よく見ると、sub()メソッド内部から呼び出しているadd()メソッドの第1引数にもマイナス符号が付いてしまっています。
これではsub(2, 1)がadd(-2, -1)の戻り値をそのまま返すことになるのですから、-3になるのは当然でした。
ということで、正しいロジックになるようにデバッグします。
<?php
class Calc
{
// ... 略 ...
public function sub($a, $b)
{
return $this->add($a, -$b);
}
デバッグしたのでテストします。今度はどうでしょうか…。
$ vendor/bin/phpunit test/CalcTest
PHPUnit 6.5.7 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 41 ms, Memory: 4.00MB
OK (2 tests, 13 assertions)
無事、テストがパスしました。sub()メソッド内部のロジック変更は、テストにより無事にリファクタリング中のバグ混入を発見し、すぐにデバッグにより問題を解決してリリースにこぎつけることができました。
テスト駆動開発の体験を終えてみて
本記事の最初に感想を書いてしまいましたが、一応記事最後なのでちょっとだけ。
私はテスト駆動開発どころかユニットテストもロクにやったことがない初心者中の初心者です。
実際にテストを商業ベースの開発で適用したこともないので、その可能性や自分との相性確認も兼ねて、Kent Beckの名著「テスト駆動開発」を読み終えたタイミングもあって、PHPUnitを体験してみました。
正直、これはあくまでも簡単なサンプルコードでのテスト駆動開発であり、テストファーストでの開発なんて実際には難しいだろうなという印象も、ないこともないのです。
けれども実際にサンプルコードでさえ、リファクタリングへの安心感があったりすると、実際に活用できれば強力でもあることが認識できました。
仕事柄、API系のサーバプログラムをPHPあたりで書くことも多い私。PHPUnitに対応・サポートしたPHPフレームワークも多いようなので、次の新規プロジェクトあたりから、上手に活用していけるべく、知見を高めていきたいと思いました。