実際にテストを書いてみた
最近バタバタしていて更新遅れてしまいましたが、技術ネタ書きます。
以前「テストの本」としてTDDを実践して思ったことを記載しましたが、TDDを始めてもう4ヶ月も経ちました。徐々に三十路が体に出てきています。腰が重いです。夜更かし辛いです。
実際にTDDでプロジェクトを始めると躓いたり、リアルで「考える顔」アイコンになったりした場面がけっこうありましたが、その中でも特に長い時間、リアルで「考える顔」アイコンになった作業である「GuzzleHttpでリクエストを投げる」ときのテストの作成と実装を簡単な説明を交えて書いていきます。
テストファーストで書くときにしていることや、テストを書くうえで頻繁に出てくるMockの作成のことを書いているので、少しでもテストの雰囲気が伝われば幸いです。
今回の環境
- PHP 7
- Laravel 5.7
- PHPUnit 7.5
- GuzzleHttp 6.3
別サーバーからのレスポンスをどうやってテストするよ
TDDをやっている以上、まずはテストを書くことからはじまります。
はじめに、レスポンスのデータ構造は、相手側のサーバー担当者が変えない限りは不変なので、Guzzleを通して実行すると特定の値が返ってくるようなモックを作成します。
<?php
$m_response_data = '{"name": "hogefuga","age": 30}';
$m_response = new \GuzzleHttp\Psr7\Response(200, [], $m_response_data);
$m_handler = new \GuzzleHttp\Handler\MockHandler([$m_response]);
$m_handler_stack = \GuzzleHttp\HandlerStack::create($m_handler);
$mock = new \GuzzleHttp\Client(['handler' => $m_handler_stack]);
これで、$mockを使ってリクエストを投げたとき、json_decodeにかけると['name' => 'hogefuga', 'age' => '30']
となるデータを得ることができるようになりました。tinkerを使って確かめてみます。
$ php artisan tinker
Psy Shell v0.9.9 (PHP 7.3.1 — cli) by Justin Hileman
>>> $m_response_data = '{"name": "hogefuga","age": 30}';
=> "{"name": "hogefuga","age": 30}"
>>> $m_response = new \GuzzleHttp\Psr7\Response(200, [], $m_response_data);
=> GuzzleHttp\Psr7\Response {#2962}
>>> $m_handler = new \GuzzleHttp\Handler\MockHandler([$m_response]);
=> GuzzleHttp\Handler\MockHandler {#2961}
>>> $m_handler_stack = \GuzzleHttp\HandlerStack::create($m_handler);
=> GuzzleHttp\HandlerStack {#2949}
>>> $mock = new \GuzzleHttp\Client(['handler' => $m_handler_stack]);
=> GuzzleHttp\Client {#2969}
>>> $request = $mock->requestAsync('GET', 'NON URL');
=> GuzzleHttp\Promise\Promise {#2976}
>>> array_map(function($_response){
... return (string)$_response['value']->getBody();
... }, \GuzzleHttp\Promise\settle($request)->wait());
=> [
"{"name": "hogefuga","age": 30}",
]
>>>
ちょっとした応用ですが、$m_response = new \GuzzleHttp\Psr7\Response(200, [], $m_response_data);
を複数つくり、$m_handler = new \GuzzleHttp\Handler\MockHandler();
で渡す引数の配列に作成したものを連続でいれると、作成したものすべてのレスポンスが返却されます。
$ php artisan tinker
Psy Shell v0.9.9 (PHP 7.3.1 — cli) by Justin Hileman
>>> $m_response_data_a = '{"name": "hogefuga","age": 30}';
=> "{"name": "hogefuga","age": 30}"
>>> $m_response_data_b = '{"name": "hoge","age": 20}';
=> "{"name": "hoge","age": 20}"
>>> $m_response_data_c = '{"name": "fuga","age": 10}';
=> "{"name": "fuga","age": 10}"
>>> $m_response_a = new \GuzzleHttp\Psr7\Response(200, [], $m_response_data_a);
=> GuzzleHttp\Psr7\Response {#2962}
>>> $m_response_b = new \GuzzleHttp\Psr7\Response(200, [], $m_response_data_b);
=> GuzzleHttp\Psr7\Response {#2964}
>>> $m_response_c = new \GuzzleHttp\Psr7\Response(200, [], $m_response_data_c);
=> GuzzleHttp\Psr7\Response {#2966}
>>> $m_handler = new \GuzzleHttp\Handler\MockHandler([$m_response_a, $m_response_b, $m_response_c]);
=> GuzzleHttp\Handler\MockHandler {#2970}
>>> $m_handler_stack = \GuzzleHttp\HandlerStack::create($m_handler);
=> GuzzleHttp\HandlerStack {#2959}
>>> $mock = new \GuzzleHttp\Client(['handler' => $m_handler_stack]);
=> GuzzleHttp\Client {#2973}
>>> $request[] = $mock->requestAsync('GET', 'NON URL');
=> GuzzleHttp\Promise\Promise {#2980}
>>> $request[] = $mock->requestAsync('GET', 'NON URL');
=> GuzzleHttp\Promise\Promise {#2969}
>>> $request[] = $mock->requestAsync('GET', 'NON URL');
=> GuzzleHttp\Promise\Promise {#2981}
>>> array_map(function($_response){
... return (string)$_response['value']->getBody();
... }, \GuzzleHttp\Promise\settle($request)->wait());
=> [
"{"name": "hogefuga","age": 30}",
"{"name": "hoge","age": 20}",
"{"name": "fuga","age": 10}",
]
>>>
ただし、モックの数がリクエスト数よりも少ないと、モックを介さずダイレクトに指定したURLへリクエストを投げてしまうので注意が必要です。テストのときは上記のように'NON URL'
とかいう絶対に失敗するような名前のほうがいいですね。
さて、無事にモック化できたので、これでテストが書けます。今回は、複数のAPIに投げたリクエストのレスポンスデータの中から、それぞれに存在するnameという名前だけを抜き出しているか、というテストを書きます。
<?php
namespace Tests\Unit;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
class GuzzleHttpTest extends TestCase
{
/**
* @test
*/
public function 送られてきたjson形式のレスポンス結果からnameだけ返しているか()
{
$m_response_data_a = '{"name": "hoge","NG1": "OUT","NG2": "OUT"}';
$m_response_data_b = '{"NG1": "OUT","name": "fuga","NG2": "OUT"}';
$m_response_data_c = '{"NG1": "OUT","NG2": "OUT","name": "hogefuga"}';
$m_response_a = new \GuzzleHttp\Psr7\Response(200, [], $m_response_data_a);
$m_response_b = new \GuzzleHttp\Psr7\Response(200, [], $m_response_data_b);
$m_response_c = new \GuzzleHttp\Psr7\Response(200, [], $m_response_data_c);
$m_handler = new \GuzzleHttp\Handler\MockHandler([$m_response_a, $m_response_b, $m_response_c]);
$m_handler_stack = \GuzzleHttp\HandlerStack::create($m_handler);
$mock = new \GuzzleHttp\Client(['handler' => $m_handler_stack]);
$expect = [
['name' => 'hoge'],
['name' => 'fuga'],
['name' => 'hogefuga'],
];
$actual = 'no name';
$this->assertEquals($expect, $actual);
}
}
これでテストは完成です。もちろん失敗します。配列と文字列の比較なんでそりゃ失敗しますよね。でもこれでいいんです。
のちのち、$actual
にはこれからレスポンスを返すクラスを書きますし、そのクラスへ入力する値も追記します。今はこれでいいんです。
予想する
テストをここまで書いたら、みんな大好きな「考える顔」アイコンになるときが来ました。「どう実装するのかを考える」という工程です。
まずはじめに、(無免許なので行ったことないですが)教習所で教わる「もし子供が交差点で飛び出してきたら」運転のように、このあと起こるであろう嫌なことを考えます。
- 運用してる最中、リクエストしたサーバーの担当者がレスポンスの構造を変える宣言をしたら・・・?
- いやいや、宣言なんてせずに思いつきで・・・「そうだ、札幌行こう」的なノリで急に変えたら・・・?
- 外のことだけじゃない!会社命令で「このAPIも追加な!」って言われたらどうすれば・・・?
やりすぎるといろいろ疑って手に負えないのでこのあたりで。本当に深くまでいって「もしこの前のように、AWSの東京リージョンだけが止まったら・・・!?」「いやマッコウクジラに海底ケーブル引きちぎられてそもそもリクエストできなくなったら・・・!?」なんてレベルまで行くと手に負えませんからね。(実際、マッコウクジラは深さ3000メートルまで潜れますが、日本海溝の海底ケーブルは一番深いところで8000メートルなので、マッコウクジラが海底ケーブルに届くんでしょうかね?)
いろいろ出した中で要約すると、これらの機能が必須ということがわかります。
- レスポンスデータをリクエストするURL先ごとに加工できるようにする
- レスポンスデータがなかった場合はnullを返すようにする
それを踏まえて考えると、このようになりました。
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Promise;
class MyGuzzle
{
private $client;
public function __construct(Client $client)
{
$this->client = $client;
}
public function execute($request_datas)
{
$requests = [];
$callbacks = [];
foreach ($request_datas as $data) {
$requests[] = $this->client->requestAsync($data['method'], $data['url']);
$callbacks[] = $data['callback'];
}
$responses = array_map(function($_response, $_callback) {
if (isset($_response['value']) === false) {
return null;
}
$body = json_decode((string)$_response['value']->getBody(), true);
if (is_null($body) === true) {
return null;
}
return $_callback($body);
}, Promise\settle($requests)->wait(), $callbacks);
return $responses;
}
}
では実装ができたので、テストにも入力と作成したクラスのインスタンス作成を記述します。
<?php
namespace Tests\Unit;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
class GuzzleHttpTest extends TestCase
{
/**
* @test
*/
public function 送られてきたjson形式のレスポンス結果からnameだけ返しているか()
{
$m_response_data_a = '{"name": "hoge","NG1": "OUT","NG2": "OUT"}';
$m_response_data_b = '{"NG1": "OUT","name": "fuga","NG2": "OUT"}';
$m_response_data_c = '{"NG1": "OUT","NG2": "OUT","name": "hogefuga"}';
$m_response_a = new \GuzzleHttp\Psr7\Response(200, [], $m_response_data_a);
$m_response_b = new \GuzzleHttp\Psr7\Response(200, [], $m_response_data_b);
$m_response_c = new \GuzzleHttp\Psr7\Response(200, [], $m_response_data_c);
$m_handler = new \GuzzleHttp\Handler\MockHandler([$m_response_a, $m_response_b, $m_response_c]);
$m_handler_stack = \GuzzleHttp\HandlerStack::create($m_handler);
$mock = new \GuzzleHttp\Client(['handler' => $m_handler_stack]);
$expect = [
['name' => 'hoge'],
['name' => 'fuga'],
['name' => 'hogefuga'],
];
$input = [
[
'method' => 'get',
'url' => 'NON URL',
'callback' => function($element){
return ['name' => $element['name']];
},
],
[
'method' => 'get',
'url' => 'NON URL',
'callback' => function($element){
return ['name' => $element['name']];
},
],
[
'method' => 'get',
'url' => 'NON URL',
'callback' => function($element){
return ['name' => $element['name']];
},
],
];
$target_class = new \App\MyGuzzle($mock);
$actual = $target_class->execute($input);
$this->assertEquals($expect, $actual);
}
}
最後にテストを実行し、すべてOKとなっていれば実装完了です。
※ 今回は3/3となっていますが、ひとつは今回作成したテストで、残り2つはデフォルトで作成されているサンプルのテストです。
$ vendor/bin/phpunit
PHPUnit 8.3.4 by Sebastian Bergmann and contributors.
... 3 / 3 (100%)
Time: 329 ms, Memory: 18.00 MB
OK (3 tests, 3 assertions)
さいごに
今回は文章だけでやたらと長くなってしまいましたが、TDDはおろかテスト自体を書き始めて4ヶ月、それなりのところまではいけたかと思いたいところです。
記載しているテストですが、ほかにも「送られてきたjsonがnullだった場合はnullを返しているか」とか「execteに渡された値が配列以外だったら例外を返しているか」などのテストを追加することで、より頑丈?なテストになっていきます。テストの追加だけでなく、テストを作成している段階で「もうクラス名決まってる」とか「インスタンスの作成方法決まってる」とかがあれば、実装の前に書けるだけ書いてしまうのもいいかと思います。個人的になりますが、一度書いたテストはテストコードそのものの間違いでない限りは実装完了まで手を加えないのがベストかなと。
また、テストを作成するときにアノテーションを設定してやることで、ピンポイントでテストができたり、テストクラス内の全てのテストの実行前後に処理を入れたりなどができますが、そこはまた次回。