外界に依存する機能をテストしやすくする手法
こんにちは。タカギです。
みなさん、テスト書いてますか?
先日、弊社の新入社員に「外界に依存する機能は入出力はできるだけ外に追いやるなどしてテストしやすくしようね」と先輩風を吹かせてみたところ、
「まあ口で言うだけなら簡単だわな」と言わんばかりの表情で「勉強になります!」とリアクションをいただきました。
今回の記事では、外界に依存する機能をテストしやすくするリファクタリングする初歩的な手法を紹介したいと思います。
※ 本記事で紹介するソースコードは全てPHP8.1で記述します。
ステップ1. グローバルへの依存をやめる
現在日時の例
このような簡単なクラスを考えます。
<?php
declare(strict_types=1);
namespace TakagiNextat\PhpTestExample;
use DateTimeImmutable;
use DateTimeInterface;
final class User
{
/**
* @param int $id ID
* @param string $name 名前
* @param DateTimeInterface $birthday 生年月日
*/
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly DateTimeInterface $birthday
) {
}
/**
* 年齢を取得する
* @return int
*/
public function age(): int
{
$now = new DateTimeImmutable();
return $now->diff($this->birthday)->y;
}
}
このプログラムでは、ユーザーの年齢による条件分岐が存在するため、Userクラスに年齢を計算するメソッドを実装しています。
年齢の計算方法は単純に現在日時と生年月日の差分(年)とします。
さっそくこのage()メソッドのテストを書いていきましょう。
<?php
declare(strict_types=1);
namespace Takagi\Nextat\PhpTestExample;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use TakagiNextat\PhpTestExample\User;
class UserTest extends TestCase
{
/**
* @testdox 年齢の計算が正しいこと
* @return void
*/
public function test_age(): void
{
$user = new User(1, '本田翼', new DateTimeImmutable('1992-06-27'));
$this->assertEquals(29, $user->age());
}
}
本当はもっと様々なケースでテストすべきですが、大体こんな感じになるんじゃないかと思います。
このテストを実行すると、問題なくパスします。簡単ですね。
勘の良いみなさんなら既にお気づきと思いますが、このテストは2022年3月現在では成功するものの、次の本田翼さんの誕生日である2022年6月27日以降は常に失敗することになります。とても困りました。
これを解決するには、毎年本田翼さんの誕生日にはテストコードの年齢のアサーションを1ずつ増やす作業を行うことになります。
ただでさえ無駄な工数が発生してしまう上、次回この作業者は「本田翼さんが30歳になった」という事実を否応なしに受け止めることになり、これは時間コスト以上に精神的負荷が非常に高いものと言えるのではないでしょうか?
age()メソッドの中で現在日時を所得していることが、グルーバルへの依存にあたります。
この問題を解決していきましょう。
修正後のUserクラスはこうなります。
<?php
declare(strict_types=1);
namespace TakagiNextat\PhpTestExample;
use DateTimeInterface;
final class User
{
/**
* @param int $id ID
* @param string $name 名前
* @param DateTimeInterface $birthday 生年月日
*/
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly DateTimeInterface $birthday
) {
}
/**
* 年齢を取得する
* @param DateTimeInterface $now 現在日時
* @return int
*/
public function age(DateTimeInterface $now): int
{
return $now->diff($this->birthday)->y;
}
}
age()メソッド内で現在日時を取得するのをやめ、メソッドの引数で受け取るようにしました。
これに合わせ、テストコードも修正します。
<?php
declare(strict_types=1);
namespace Takagi\Nextat\PhpTestExample;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use TakagiNextat\PhpTestExample\User;
class UserTest extends TestCase
{
/**
* @testdox 年齢の計算が正しいこと
* @return void
*/
public function test_age(): void
{
$user = new User(1, '本田翼', new DateTimeImmutable('1992-06-27'));
$now = new DateTimeImmutable('2022-03-14');
$this->assertEquals(29, $user->age($now));
}
}
修正前と同様、問題なくパスします。
この修正を行うことで、次回の誕生日以降にテストが失敗することもなくなり、本田翼さんはこのテストコードの中、及び私たちの思い出の中では29歳の状態で永遠に生き続けることとなりました!
修正前のage()メソッドは実行するタイミング(日時)によって違う結果を返し得るメソッドだったのに対し、
修正後は同じ引数での呼び出しに対しては常に同じ結果を返すようになりました。
これを「参照透過性」と言い、テストしやすいコードを書くためには非常に重要なポイントです。
参照透過であるメソッドは、テストコードが書きやすいと言えます。
このリファクタリングを行ったところ、一部のメンバーから「オタクくんさぁ…Carbon::setTestNow()
じゃダメなん?w」との指摘が入りました。
CarbonはPHPerには名の知れた日時操作ライブラリで、Laravelにも組み込まれており、
setTestNow()はテスト時に現在日時を任意の固定値に設定できる非常に強力な機能です。
なんとこの機能を使用することで、リファクタリング前の状態でもテスト時に現在日時を別の日時で固定することができてしまいます!
リファクタリング甲斐がなくて困りました。
Carbonを使用していればグローバル依存の例としてあげた現在日時の問題はクリアできてしまうことがわかったので、次は別の例でも考えてみたいと思います。
標準入力の例
先ほど作成したユーザークラスを使用し、簡単なコンソールアプリケーションを作ってみます。
要件は下記の通りです。
- プログラムを起動するとユーザ一覧が表示される。
- プログラムは標準入力を受け付ける
- プログラム利用者は表示されたユーザー一覧を見て、任意のユーザーIDを入力する(不正な入力は行わないものとする)
- プログラムはユーザーの名前と現在の年齢を表示する
完成イメージはこんな感じです。
nextat_staff@adminnoMBP php-test-example % php ./bin/users.php
[1] 本田翼
[2] 篠崎愛
1
本田翼さんは現在、29歳です。
nextat_staff@adminnoMBP php-test-example % php ./bin/users.php
[1] 本田翼
[2] 篠崎愛
2
篠崎愛さんは現在、30歳です。
nextat_staff@adminnoMBP php-test-example % php ./bin/users.php
[1] 本田翼
[2] 篠崎愛
3
入力に誤りがあります。
(篠崎愛が30歳ってマジ???)
サクッと作っていきます。
標準出力を扱いやすくするため、薄いラッパーであるStdinクラスを作成します。
これは日時操作におけるCarbonと同等のものと思ってください。
<?php
declare(strict_types=1);
namespace TakagiNextat\PhpTestExample;
/**
* 標準入力を扱うクラス
*/
final class Stdin
{
/**
* 標準入力を読み取り、半角スペースで分割した配列を返却する
* @return string[]
*/
public static function getInputs(): array
{
$input = fgets(STDIN);
return explode(' ', $input);
}
}
ユーザーの操作にはUserServiceクラスを作成します。
先ほどの現在日時をUser::age()で取得していたように、標準入力をUserService::find()内で行ってしまっているのがポイントです。
=グローバルに依存している状態。
<?php
declare(strict_types=1);
namespace TakagiNextat\PhpTestExample;
use DateTimeImmutable;
final class UserService
{
/**
* @var User[]
*/
private array $users;
public function __construct()
{
$this->users = [
new User(1, '本田翼', new DateTimeImmutable('1992-06-27')),
new User(2, '篠崎愛', new \DateTimeImmutable('1992-02-26')),
];
}
/**
* 全ユーザーを取得する
* @return User[]
*/
public function all(): array
{
return $this->users;
}
/**
* 標準入力から受け取ったIDからユーザーを返す
* @return User|null
*/
public function find(): ?User
{
$inputs = Stdin::getInputs();
$id = (int)$inputs[0]; // 普段はこんなキャストしちゃダメですよ!!!
foreach ($this->users as $user) {
if ($user->id === $id) {
return $user;
}
}
return null;
}
}
実行用のファイルを作成します。
<?php
declare(strict_types=1);
use TakagiNextat\PhpTestExample\UserService;
require_once __DIR__ . '/../vendor/autoload.php';
$service = new UserService();
$users = $service->all();
// ユーザー一覧を表示
foreach ($users as $user) {
echo "[{$user->id}] {$user->name}\n";
}
// 標準入力から受け取ったIDのユーザーの年齢を表示
$user = $service->find();
if (is_null($user)) {
echo "入力に誤りがあります。\n";
exit();
}
$now = new DateTimeImmutable();
$age = $user->age($now);
echo "{$user->name}さんは現在、{$age}歳です。\n";
これで完成です。
実行すると、上述した完成イメージ通りの結果が得られます。
ここまでは簡単ですね。
それでは本題に戻り、UserService::find()に対してテストコードを書いて行きましょう。
<?php
declare(strict_types=1);
namespace Takagi\Nextat\PhpTestExample;
use PHPUnit\Framework\TestCase;
use TakagiNextat\PhpTestExample\UserService;
class UserServiceTest extends TestCase
{
public function test_find_success()
{
$service = new UserService();
$user = $service->find();
$this->assertEquals(1, $user->id);
$this->assertEquals('本田翼', $user->name);
}
}
はい!!!困りました!!!
このテストを実行すると標準入力を待ち受けられ、テストは実行されません。
どうしましょうか?毎回手動で入力します?ローカルではそれでなんとかなるにしても(なってない)、CIではどうするのでしょうか。
この時点で「だからグローバル依存をやめるべきだよね」と結論づけても良いですが、
Carbon::setTestNow()を参考に、もう少し粘ってみましょう。
Stdinクラスにテストを見越した小細工を追加します。
<?php
declare(strict_types=1);
namespace TakagiNextat\PhpTestExample;
/**
* 標準入力を扱うクラス
*/
final class Stdin
{
/**
* テスト用の標準入力モック
* @var string[]|null
*/
private static array|null $testInputs = null;
/**
* 標準入力を読み取り、半角スペースで分割した配列を返却する
* @return string[]
*/
public static function getInputs(): array
{
if (self::hasTestInputs()) {
return self::$testInputs;
}
$input = fgets(STDIN);
return explode(' ', $input);
}
/**
* @return bool
*/
public static function hasTestInputs(): bool
{
return !is_null(self::$testInputs);
}
/**
* テスト用の標準入力をセット
* @param string[] $inputs
* @return void
*/
public static function setTestInputs(array $inputs): void
{
self::$testInputs = $inputs;
}
Carbonを参考に、テスト用の値がセットされている時はそちらを返すようにしました。
これでテストが書けます。
<?php
declare(strict_types=1);
namespace Takagi\Nextat\PhpTestExample;
use PHPUnit\Framework\TestCase;
use TakagiNextat\PhpTestExample\Stdin;
use TakagiNextat\PhpTestExample\UserService;
class UserServiceTest extends TestCase
{
/**
* @testdox 存在するIDを入力した場合、そのIDを持つユーザーが返却されること
* @return void
*/
public function test_find_success()
{
Stdin::setTestInputs(['1']);
$service = new UserService();
$user = $service->find();
$this->assertEquals(1, $user->id);
$this->assertEquals('本田翼', $user->name);
}
/**
* @testdox 存在しないIDを入力した場合、nullが返却されること
* @return void
*/
public function test_find_fail(): void
{
Stdin::setTestInputs(['3']);
$service = new UserService();
$user = $service->find();
$this->assertNull($user);
}
}
これでテストは通ります。
少々複雑にはなりましたが、なんとかなりました。
なんとかなりましたが、これも「グローバル依存をやめる」に従ってリファクタリングしていきましょう。
<?php
declare(strict_types=1);
namespace TakagiNextat\PhpTestExample;
use DateTimeImmutable;
final class UserService
{
/**
* @var User[]
*/
private array $users;
public function __construct()
{
$this->users = [
new User(1, '本田翼', new DateTimeImmutable('1992-06-27')),
new User(2, '篠崎愛', new \DateTimeImmutable('1992-02-26')),
];
}
/**
* 全ユーザーを取得する
* @return User[]
*/
public function all(): array
{
return $this->users;
}
/**
* @param int $id
* @return User|null
*/
public function find(int $id): ?User
{
foreach ($this->users as $user) {
if ($user->id === $id) {
return $user;
}
}
return null;
}
}
UserService::find()ではIDは引数で受け取るようにしました。
<?php
declare(strict_types=1);
use TakagiNextat\PhpTestExample\Stdin;
use TakagiNextat\PhpTestExample\UserService;
require_once __DIR__ . '/../vendor/autoload.php';
$service = new UserService();
$users = $service->all();
// ユーザー一覧を表示
foreach ($users as $user) {
echo "[{$user->id}] {$user->name}\n";
}
// 標準入力から受け取ったIDのユーザーの年齢を表示
$inputs = Stdin::getInputs();
$user = $service->find((int)$inputs[0]);
if (is_null($user)) {
echo "入力に誤りがあります。\n";
exit();
}
$now = new DateTimeImmutable();
$age = $user->age($now);
echo "{$user->name}さんは現在、{$age}歳です。\n";
標準入力の読み取りはUserServiceの呼び出し元で行います。
<?php
declare(strict_types=1);
namespace Takagi\Nextat\PhpTestExample;
use PHPUnit\Framework\TestCase;
use TakagiNextat\PhpTestExample\UserService;
class UserServiceTest extends TestCase
{
/**
* @testdox 存在するIDを入力した場合、そのIDを持つユーザーが返却されること
* @return void
*/
public function test_find_success()
{
$service = new UserService();
$user = $service->find(1);
$this->assertEquals(1, $user->id);
$this->assertEquals('本田翼', $user->name);
}
/**
* @testdox 存在しないIDを入力した場合、nullが返却されること
* @return void
*/
public function test_find_fail(): void
{
$service = new UserService();
$user = $service->find(3);
$this->assertNull($user);
}
}
テストからはStdin::setTestInputs()が不要になります。
このメソッドはもう使わないので削除できますね。
リファクタリング前後のソースコードを比較してみましょう。
どちらも要件は満たせていますし、テストコードも書けています。
リファクタリング後の方がソースコード量が少なく、条件分岐も少ないことからシンプルであると言えます。
逆に言えばリファクタリング前の状態でも現状は問題ないものの、余計な複雑さを持ち込んでしまっているということになります。
余計な複雑さは無い方が良いですよね。
現在日時の例でも同じことが言え、Carbon::setTestNow()は余計な複雑さを持ち込んでしまっています。
その複雑さはCarbonライブラリ内部に隠されているため利用者である私たちがあまり意識することはありませんが、
シンプルに実現できる方法が他にあるのであればその方が良いですね。
さらにリファクタリング後のメリットとして、UserService::find() はリファクタリング前の状態では標準入力に依存しているためコンソールアプリケーションでしか使用できませんでしたが、リファクタリング後はどこからでも呼び出せるという点があります。
例えば今後同じものをWebブラウザ版として開発することになっても、UserServiceを完全に使い回すことができます。
テストコードを書きやすくすることで自然と入力の具現と疎結合になり、再利用しやすいコードが得られたと言えます。
そしてこれはUser::age()でも同じことが言え、リファクタリング前は「現在の年齢」しか取得できなかったのに対し、リファクタリング後は「ある時点での年齢」を取得することができます。
篠崎愛さんのデビューは2006年だそうですが、こんな使い方もできるということです。
(※ これは動作確認用です。普通はこんなテスト書きませんよ!)
/**
* @testdox 篠崎愛さんのデビュー時の年齢
* @return void
*/
public function test_age_ai_shinozaki_debuted(): void
{
$ai = new User(2, '篠崎愛', new DateTimeImmutable('1992-02-26'));
$debuted = new DateTimeImmutable('2006-12-31'); // デビューが2006年ということしかわからなかったので、日付は適当
$this->assertEquals(14, $ai->age($debuted));
}
ここまで来ると、リファクタリング後の方が優れていることに納得していただけるのではないでしょうか。
Carbon::setTestNow()に限らず、世の中には強力な便利機能を持つテスティングライブラリが豊富にあります。
便利機能を使用する前に、実装内容を見直すことで余計な複雑さを持ち込まずに済む方法がないか考えるのは良い習慣です。
繰り返しにはなりますが、その手法の1つが、グローバル依存をやめることです。
少し余談ですが、上述した2つの例で言うと、さすがに標準入力の取り扱いについては慎重になる人でも、
現在日時の取り扱いは無頓着な人が多い印象です。
どちらも本質的には同じことなので、気をつけていきたいですね。
ステップ2. 抽象に依存する
ステップ1ではグローバル依存をやめ、メソッドを参照透過にすることでテストしやすいコードを実現しました。
しかし、テストが書きにくい状況というのは常にグローバル依存が原因というわけではありません。
よく遭遇するケースとしては、ファイル出力やDBの操作など、副作用を扱うものです。
副作用を扱う例として「ユーザーの一覧をCSVファイルに出力したい」というよくある要件を実装していきます。
下記のようなクラスを作成します。
(※ 実際のプロジェクトではCsvWriterのようなクラスは別クラスとして切り出すことになると思いますが、コード例を単純にするためこのようにしています。)
<?php
declare(strict_types=1);
namespace TakagiNextat\PhpTestExample;
use DateTimeInterface;
use Exception;
final class UserCsvWriter
{
/**
* @param iterable<User> $users
* @param DateTimeInterface $now
* @return void
* @throws Exception
*/
public function write(iterable $users, DateTimeInterface $now): void
{
$stream = fopen('users.csv', 'w');
if ($stream === false) {
throw new Exception('ファイル作成に失敗しました。');
}
fputcsv($stream, [
'ID',
'名前',
'生年月日',
'年齢',
]);
foreach ($users as $user) {
fputcsv($stream, [
$user->id,
$user->name,
$user->birthday->format('Y-m-d'),
$user->age($now),
]);
}
fclose($stream);
}
}
CSVファイルの出力先は一旦ローカルとし、ファイル名も決め打ちにしています。
ステップ1で述べた通り、現在日時はここでもwrite()メソッドの引数で受け取るようにしています。
テストも書いていきましょう。
<?php
declare(strict_types=1);
namespace Takagi\Nextat\PhpTestExample;
use Exception;
use PHPUnit\Framework\TestCase;
use TakagiNextat\PhpTestExample\User;
use TakagiNextat\PhpTestExample\UserCsvWriter;
class UserCsvWriterTest extends TestCase
{
/**
* @testdox CSV出力の正常系をテストしたいけど、何をアサートしたら良いのかわからない
* @return void
* @throws Exception
*/
public function test_write(): void
{
$writer = new UserCsvWriter();
$users = [
new User(1, '本田翼', new \DateTimeImmutable('1992-06-27')),
new User(2, '篠崎愛', new \DateTimeImmutable('1992-02-26')),
];
$now = new \DateTimeImmutable('2022-03-14');
$writer->write($users, $now);
// 何をアサートしたらいいのかわからない……
$this->assertTrue(true);
}
}
さて困りました。
このテストを実行すると2022年3月現在では下記のようなCSVファイルがローカルに生成されるのですが、
(篠崎愛が30歳ってマジ???)
これをテストコードでどう扱ったものでしょうか。
ID,名前,生年月日,年齢
1,本田翼,1992-06-27,29
2,篠崎愛,1992-02-26,30
例えばこのファイルをテストコード内から読み込み、文字列として比較することは考えられます。
しかしやりたいことに対しかなり大掛かりというか、そんなテストコードが書きたいでしょうか?(いいえ、書きたくありません。)
以上を踏まえて、UserCsvWriterクラスの修正を行います。
<?php
declare(strict_types=1);
namespace TakagiNextat\PhpTestExample;
use DateTimeInterface;
use Exception;
final class UserCsvWriter
{
/**
* @param resource $stream
* @param iterable<User> $users
* @param DateTimeInterface $now
* @return void
* @throws Exception
*/
public function write($stream, iterable $users, DateTimeInterface $now): void
{
if (!is_resource($stream)) {
throw new Exception('第一引数には書き込み可能なストリームを渡してください。');
}
fputcsv($stream, [
'ID',
'名前',
'生年月日',
'年齢',
]);
foreach ($users as $user) {
fputcsv($stream, [
$user->id,
$user->name,
$user->birthday->format('Y-m-d'),
$user->age($now),
]);
}
}
}
修正後のUserCsvWriter::write()ではCSVファイル生成は行わず、
第一引数で渡されたstreamにCSV形式でユーザー一覧を書き込むことのみを責務としました。
これに合わせて呼び出し側のテストコードも修正します。
<?php
declare(strict_types=1);
namespace Takagi\Nextat\PhpTestExample;
use DateTimeImmutable;
use Exception;
use PHPUnit\Framework\TestCase;
use TakagiNextat\PhpTestExample\User;
use TakagiNextat\PhpTestExample\UserCsvWriter;
class UserCsvWriterTest extends TestCase
{
/**
* @testdox ユーザー一覧がCSV形式で出力されること
* @return void
* @throws Exception
*/
public function test_write(): void
{
$writer = new UserCsvWriter();
$stream = fopen('php://memory', 'w');
$users = [
new User(1, '本田翼', new DateTimeImmutable('1992-06-27')),
new User(2, '篠崎愛', new DateTimeImmutable('1992-02-26')),
];
$now = new DateTimeImmutable('2022-03-14');
$writer->write($stream, $users, $now);
// ストリームを文字列に読み込む
rewind($stream);
$csv = stream_get_contents($stream);
fclose($stream);
$expected = <<<CSV
ID,名前,生年月日,年齢
1,本田翼,1992-06-27,29
2,篠崎愛,1992-02-26,30
CSV;
$this->assertEquals($expected, $csv);
}
}
テストコード側で開いたストリームリソースをUserCsvWriterに渡し、書き込み処理後に文字列として取得、それを期待するCSV形式の文字列と比較することでテストするようにしました。
リファクタリング前にはファイルという具現に依存していたのに対し、リファクタリング後はstreamという抽象に依存するようになりました。
テストでローカルファイルを扱う必要がなくなったため、テストしやすいコードになったと言えます。
また、この修正によりUserCsvWriterはローカルファイルへの出力専用ではなくなったため、S3にアップロードするも良し、HTTPレスポンスに乗せるも良し、呼び出し側の自由自在です。
テストコードを書きやすくすることで自然と出力の具現と疎結合になり、再利用しやすいコードが得られたと言えます。
懸念があるとすればこのテストではCSVファイルの生成そのものはテストできていないことですが、
多くの場合、テストコードによりテストしたいのではファイルそのものの生成より、その中身だと思います。
ファイルの生成自体のテストは手動で行ってもいいと思いますし、別途書きたい人は書いても良いと思います。
いずれにせよ、ファイルの内容はこのテストで細かく行うことになるので、ファイル生成の方のテストはかなり軽量になると思います。
これと良く似た手法として「リポジトリパターン」があります。
ここでは詳細には触れませんが、(テストの観点からのみ述べると)DBへの依存を抽象化することでメモリを使ってモック化してテストできるという手法です。
気になる方は調べてみてください。
ここでステップ1を改めて振り返ると、実はCarbonやStdinクラスが抽象化層のクラスにあたります。
日時や標準入力を取り扱う層を作ったため、(やや複雑さを追加することになるものの)テスト時にモック化できるようになっていたということですね。
まとめ
外界に依存する機能をテストしやすくする手法として、「グローバル依存をやめる」、「抽象に依存する」の2つの例を紹介しました。
どちらもリファクタリング後はテストしやすいだけでなく、再利用しやすいコードになっていることにも注目していただきたいポイントです。
今回紹介した内容はかなり初歩的な内容と言えるのですが、テストコードビギナーの方でもこれを意識するだけで得られるメリットはかなり大きいと思います。
これからテストコードに入門する方・これまでも書いてきたけどイマイチやりづらさを感じていた方のお役に立てれば幸いです。
ここまでお読みいただきありがとうございました。
おまけ(PR)
弊社では一緒に働く仲間を募集しています。
興味のある方は弊社HPの お問い合わせフォーム または 採用Twitterアカウント までご連絡ください!