テストしやすいコードを書きたい!
今回は PHPのテストしやすいコードを書くにはどうしたら良いかわからない、
そんな方を対象にした回です。
- テストが書きやすいって何?
- テストが書ける設計って何?
- テストで何が変わるの?ブラウザとかREST Clientで確認できれば良いじゃん?
- とりあえず動くんだから良いじゃん
- 抽象化って何?
そんな方には多分ぴったりかもしれません。
例
例として以下のコードを見てみましょう。
<?php
declare(strict_types=1);
namespace Acme\HttpClient;
use GuzzleHttp\Client;
use function json_decode;
final class Example
{
const URI = '';
/**
* @return mixed
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function call(): mixed
{
$client = new Client([
'base_uri' => 'https://example.com',
]);
$result = $client->request('GET', Example::URI);
return json_decode($result->getBody()->getContents(), true);
}
}
ぱっと見良さそうに見えますが、この処理にはいくつかの問題があります。
callメソッドの内部でインスタンス生成が行われています。
また、このクラスをベースに実装していくと、機能追加などで内部にたくさんのifや、
パラメータの操作などが加わっていくのが想像できます。
jsonのみに作用する実装にもなっており、
またURIも https://example.com
固定になっています。
つまりそのURI固定でしか処理ができないクラスということになります。
これでは使いやすいHTTP Clientクラス、というコードにはなりません。
またHTTPメソッドも固定になっています。
POSTを利用したい時はどうすればいいでしょう?!
設定値を使う
フレームワークなどを利用している方は以下の様に書くかもしれません。
<?php
declare(strict_types=1);
namespace Acme\HttpClient;
use GuzzleHttp\Client;
use function json_decode;
final class Example
{
const URI = '';
/**
* @return mixed
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function call()
{
$client = new Client([
'base_uri' => config('url'),
]);
$result = $client->request('GET', Example::URI);
return json_decode($result->getBody()->getContents(), true);
}
}
設定値を取得するヘルパー関数を利用しました。
一番わかりやすい対応ではありますが、
このクラスは設定値そのものが欲しいのであって、
設定値の解決方法を知りたいわけではありません。
またこのクラスがもつ問題を解決しているわけではありません。
それに初めて触れるフレームワークの場合、
このクラスをパッとみて設定値が必要そうかどうかはわからないかもしれません。
(例としてconfig ヘルパー関数というわかりやすい名前ですが実際はそうとは限りません)
そこで、解決の一歩としてまずは下記の様に変更してみるとどうでしょうか。
public function callBy(string $url)
{
$client = new Client([
'base_uri' => $url,
]);
$result = $client->request('GET', Example::URI);
return json_decode($result->getBody()->getContents(), true);
}
設定値を実行時に渡すことで、このクラスの振る舞いを少しだけ変更することができます。
実際にこのクラスをテストしてみることができます。
(new \Acme\HttpClient\Example())->callBy(config('url'));
(new \Acme\HttpClient\Example())->callBy('http://example.com');
クラス内に内包されていた設定値を外に出したことで、
いくつかのURLに対応することができました。
このクラスのテストコードを記述してみるとどうなるでしょうか?
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function testShouldReturnResult(): void
{
$example = new Example();
$this->assertNull($example->callBy('http://example.com'));
}
}
テストコードは書けますが、このままでは不十分な様です。
テストを書いてみると、以下のことがわかります。
- example.comはHTMLを返却するサイト
- json_decodeの処理はどうやってもnullのみ返却
つまりこのメソッドが動くかどうかでしかなく、アプリケーションで必要なテストにはなっていないことがわかります。
もしかしたら、テストの時だけ変なURIに変えてテストしまーす
という方もいるかもしれません。
<?php
declare(strict_types=1);
namespace Acme\HttpClient;
use GuzzleHttp\Client;
use function json_decode;
final class Example
{
const URI = '/12345678901234567890';
public function callBy(string $url)
{
$client = new Client([
'base_uri' => $url,
]);
$result = $client->request('GET', Example::URI);
return json_decode($result->getBody()->getContents(), true);
}
}
constのみを変更しました。
これはダメですね。
テストの時に値を変更する、ということは不変であるはずのconstの意味がありません。
処理中には絶対にありえない動作になります。
対応の仕方がわからず、テストの度にURIを変更していては
他のエンジニアが理解できるはずがありません。
そういったアプローチは非常に危険であるといえます。
数年後、アプリケーションの保守・運用をするのは違う人、ということを必ず覚えておきましょう。
ただし、悪いことだけではありません。
このテストからわかる良い発見があります。
このコードを実際に動かすと
404 Not Found
であることを示すExceptionが投げられます。
エラーハンドリングが必要なことがわかりました。
<?php
declare(strict_types=1);
namespace Acme\HttpClient;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use function json_decode;
final class Example
{
/**
* @param string $url
* @param string $uri
*
* @return mixed|null
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function callBy(string $url, string $uri)
{
$client = new Client([
'base_uri' => $url,
]);
try {
$result = $client->request('GET', $uri);
return json_decode($result->getBody()->getContents(), true);
} catch (ClientException $e) {
return null;
}
}
}
テストコードを書いてみてわかったことを実際のコードに反映しました。
URIを外部から指定するように変更し、とりあえずエラーハンドリングを追加しました。
簡単な例なのでnullで返却していますが、実際にはエラーを投げるか、アプリケーションに合わせた実装にしてください。
エラーに対応することはできましたが、
まだ成功時のテストが書けない状態です。
この例ではGuzzleを利用していますが、
このライブラリは振る舞いを変更できる様になっています。
guzzleを利用していない環境であっても振る舞いを変更できる様に
、
例えばcurlをラップしたクラスなどを作る必要でてきます。
このクラスの振る舞いを変更できるようにするには、
インスタンス生成を外部で行うようにする必要があります。
<?php
declare(strict_types=1);
namespace Acme\HttpClient;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ClientException;
use function json_decode;
final class Example
{
private ClientInterface $client;
public function __construct(
ClientInterface $client
) {
$this->client = $client;
}
/**
* @param string $uri
*
* @return mixed|null
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function callBy(string $uri)
{
try {
$result = $this->client->request('GET', $uri);
return json_decode($result->getBody()->getContents(), true);
} catch (ClientException $e) {
return null;
}
}
}
コンストラクタでGuzzleHttp\ClientInterface
を
必要とする様に型指定を記述しました。
またメソッドの引数も一つだけにし、シンプルに使える様になりました。
これで、振る舞いを変更して正常系のテストも記述できるようになります。
use PHPUnit\Framework\TestCase;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\Psr7\Response;
use Acme\HttpClient\Example;
class ExampleTest extends TestCase
{
/**
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function testShouldReturnResult(): void
{
$expect = ['message' => 'testing'];
$example = new Example($this->createMockClient(200, json_encode($expect)));
$this->assertSame($expect, $example->callBy('/url'));
}
/**
* @param int $statusCode
* @param string|null $body
* @param array $options
*
* @return Client
*/
private function createMockClient(
int $statusCode,
string $body = null,
array $options = []
): Client {
return new Client([
'base_uri' => 'http://example.com',
'handler' => HandlerStack::create(new MockHandler([
new Response($statusCode, $options, $body),
])),
]);
}
}
Exampleクラスはインターフェースのみに依存し、
振る舞いを外から与えることによって外部リソースの状況に関わらず、
記述できる様になりました。
大きな一歩です!
<?php
declare(strict_types=1);
use Acme\HttpClient\Example;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
class ExampleTest extends \PHPUnit\Framework\TestCase
{
/**
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function testShouldReturnResult(): void
{
$expect = ['message' => 'testing'];
$example = new Example($this->createMockClient(200, json_encode($expect)));
$this->assertSame($expect, $example->callBy('/url'));
}
/**
* @param int $statusCode
* @param string|null $body
* @param array $options
*
* @return Client
*/
private function createMockClient(
int $statusCode,
string $body = null,
array $options = []
): Client {
return new Client([
'base_uri' => 'http://example.com',
'handler' => HandlerStack::create(new MockHandler([
new Response($statusCode, $options, $body),
])),
]);
}
/**
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function testShouldBeNull(): void
{
$example = new Example($this->createThrowExceptionClient());
$this->assertNull($example->callBy('/url'));
}
/**
* @return Client
*/
private function createThrowExceptionClient(): Client
{
return new Client([
'base_uri' => 'http://example.com',
'handler' => HandlerStack::create(new MockHandler([
new \GuzzleHttp\Exception\ClientException(
"Error Communicating with Server",
new Request('GET', '/url'),
new Response()
),
])),
]);
}
}
エラー系のテストもできるように、意図的にExceptionを発生させ
エラーハンドリングも問題なくできる様になりました。
全体的に抽象化してみよう
ただまだ例のクラスは不十分です。
クライアントとして実装するのであれば汎用的に使いたい、
JSON以外のAPIにも使える様にしたい、
もちろん他のドメインのAPIもコールしたい、
実装時に余計なコード書きたくない、などいろんな声があるでしょう。
これに対して、
クライアントとして実装するのであれば汎用的に使いたい
=> そのメソッドだけコールしておけばなんでもできる
JSON以外のAPIにも使える様にしたい
=> とりあえずAPI全部JSONにすればよくね?
実装時に余計なコード書きたくない
=> 今動いてるっぽいコードをコピペして似た処理増やす
といったコードを書いてしまうのではアプリケーションのコードがどんどん複雑化していきます。
ifが増え複雑になる、テストも書けなくなり読めないコードになってしまいます。
まずは今のクラスの責務を考えてみましょう。
- HTTPリクエストを送信する
- HTTPリクエストを行うための設定を行う
- アプリケーション内で扱いやすいようにjson_decodeする?
このくらいは上記の責務を持っています。
これでは多すぎるので、HTTPリクエストを送信するだけにしましょう。
<?php
declare(strict_types=1);
namespace Acme\HttpClient;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ClientException;
final class Example
{
private ClientInterface $client;
public function __construct(
ClientInterface $client
) {
$this->client = $client;
}
/**
* @param string $uri
* @param string $method
*
* @return string|null
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function executeBy(string $uri, string $method): ?string
{
try {
$response = $this->client->request($method, $uri);
return $response->getBody()->getContents();
} catch (ClientException $e) {
return null;
}
}
}
レスポンスのjson_decodeをやめ、
リクエストを送信するだけのクラスに変更しました。
レスポンスのパースを行える様にパースのクラスを記述します。
ここではテンプレートメソッドパターンを使ってすこし簡単に実装します。
<?php
declare(strict_types=1);
namespace Acme\Parse;
abstract class ResourceAdapter
{
private string $resource;
public function __construct(string $resource)
{
$this->resource = $resource;
}
protected function getResource(): string
{
return $this->resource;
}
abstract public function toArray(): array;
}
この抽象クラスを使って、JSONなどに対応するパーサークラスを用意していきます。
<?php
declare(strict_types=1);
namespace Acme\Parse;
use JsonException;
use function json_decode;
use const JSON_THROW_ON_ERROR;
final class JsonParser extends ResourceAdapter
{
/**
* @return array
* @throws JsonException
*/
public function toArray(): array
{
$result = json_decode($this->getResource(), true, 512, JSON_THROW_ON_ERROR);
if ($result !== '' && !is_null($result)) {
return $result;
}
return [];
}
}
JSON以外のレスポンスに対応する場合は同じ様に、
それぞれのフォーマットに合わせたパーサークラスを用意するといいでしょう。
パースされた値をアプリケーション内でそのまま使うのではなく、
DDL的に使える様に型を用意した方が利便性もあがります。
ここでは下記のクラスを用意しましょう。
<?php
declare(strict_types=1);
namespace Acme\Parse;
abstract class ResourceAdapter
{
private ?string $resource;
public function __construct(?string $resource = '')
{
$this->resource = $resource;
}
protected function getResource(): string
{
return (string) $this->resource;
}
abstract public function toArray(): array;
}
PHPは配列が強力な言語ではありますが、
配列は型指定ができません。
また配列の中身を知るにはデバッガーなどを活用して調べるなどをしなければなりません。
この配列が特定のクラスだけで構成されていたらどうでしょうか?
ソースコードの内容を把握するのも簡単になり、
PHPStormなどのIDEを併用することでオブジェクトのみの配列であることもわかるようになります。
まずはパースした値が見つからなかった場合に返すエラーを独自に用意します。
<?php
declare(strict_types=1);
namespace Acme\Exception;
final class ResourceNotFoundException extends \Exception
{
}
続いて値を詰め替えるクラスを用意します。
<?php
declare(strict_types=1);
namespace Acme\Mapper;
use Acme\Collection\ExampleResource;
use Acme\Exception\ResourceNotFoundException;
use Acme\Parse\ResourceAdapter;
final class ExampleResourceMapper
{
private ResourceAdapter $parse;
public function __construct(
ResourceAdapter $parse
) {
$this->parse = $parse;
}
/**
* @return ExampleResource
* @throws ResourceNotFoundException
*/
public function find(): ExampleResource
{
$resource = $this->parse->toArray();
if (count($resource)) {
return new ExampleResource($resource['message']);
}
throw new ResourceNotFoundException('resource not found.');
}
}
ここまでのクラスを使ってテストを記述していきます。
<?php
declare(strict_types=1);
use Acme\HttpClient\Example;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Acme\Parse\JsonParser;
use Acme\Collection\ExampleResource;
use Acme\Mapper\ExampleResourceMapper;
use Acme\Exception\ResourceNotFoundException;
class ExampleTest extends \PHPUnit\Framework\TestCase
{
public function testShouldReturnNotEmptyResult(): void
{
$expect = ['message' => 'testing'];
$example = new Example($this->createMockClient(200, json_encode($expect)));
$parser = new JsonParser($example->executeBy('http://example.com', '/api'));
$mapper = new ExampleResourceMapper($parser);
$this->assertInstanceOf(ExampleResource::class, $mapper->find());
$this->assertSame('testing', $mapper->find()->getMessage());
}
public function testReturnEmptyResult(): void
{
$this->expectException(ResourceNotFoundException::class);
$expect = '';
$example = new Example($this->createMockClient(200, json_encode($expect)));
$parser = new JsonParser($example->executeBy('http://example.com', '/api'));
$mapper = new ExampleResourceMapper($parser);
$mapper->find();
}
private function createMockClient(
int $statusCode,
string $body = null,
array $options = []
): Client {
return new Client([
'base_uri' => 'http://example.com',
'handler' => HandlerStack::create(new MockHandler([
new Response($statusCode, $options, $body),
])),
]);
}
}
パーサーと値マッパークラスがそれぞれの責務をもち、
拡張性持たされたのがわかると思います。
これでレスポンスの処理に関して多様性を持たせることができます。
例なので、実際にはもう少し考慮することはあります。
/**
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function testShouldThrowResourceNotFoundException(): void
{
$this->expectException(JsonException::class);
$example = new Example($this->createThrowExceptionClient());
$mapper = new ExampleResourceMapper(
new JsonParser(
$example->executeBy('http://example.com', '/api')
)
);
$mapper->find();
}
/**
* @return Client
*/
private function createThrowExceptionClient(): Client
{
return new Client([
'base_uri' => 'http://example.com',
'handler' => HandlerStack::create(new MockHandler([
new \GuzzleHttp\Exception\ClientException(
"Error Communicating with Server",
new Request('GET', '/url'),
new Response(200)
),
])),
]);
}
JSONのエラーを検知できるかどうかは上記の様なテストで対応できるでしょう。
ここまで、レスポンスのパースについて対応していきました。
ここからは固定になっているURIやパラメータなども抽象に依存する様に変更します。
同じくテンプレートメソッドパターンを使って簡単に実装する例です。
<?php
declare(strict_types=1);
namespace Acme\Parameter;
abstract class AbstractParameter
{
protected string $method = 'GET';
protected string $uri = '/';
private array $params = [];
private array $options = [];
public function __construct(
array $params = [],
array $options = []
) {
$this->params = $params;
$this->options = $options;
}
public function getUri(): string
{
return $this->uri;
}
public function getMethod(): string
{
return $this->method;
}
protected function getParameters(): array
{
return $this->params;
}
public function getOptions(): array
{
return $this->options;
}
}
URIやHTTPメソッドなどを上書きできる様にしておきます。
<?php
declare(strict_types=1);
namespace Acme\Parameter;
final class ExampleRequestParameter extends AbstractParameter
{
}
URIやHTTPメソッドごとにクラスを増やして利用する例です。
これでClientになっているクラスに色々手を加えなくて済む様になります。
<?php
declare(strict_types=1);
namespace Acme\HttpClient;
use GuzzleHttp\ClientInterface;
use Acme\Parameter\AbstractParameter;
use GuzzleHttp\Exception\ClientException;
final class Example
{
private ClientInterface $client;
public function __construct(
ClientInterface $client
) {
$this->client = $client;
}
/**
* @param AbstractParameter $parameter
*
* @return string
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function executeBy(AbstractParameter $parameter): string
{
try {
return $this->client->request(
$parameter->getMethod(),
$parameter->getUri(),
$parameter->getOptions()
)->getBody()->getContents();
} catch (ClientException $exception) {
return '';
}
}
}
Clientとして動かしていたクラスの内部を抽象クラスに依存する様に変更しました。
パラメータや送信先などもこれである程度自由に変更できる様になりました。
変更したクラスに対応するために、下記のテストコードを追加すると良いでしょう。
private function createParameter(): AbstractParameter
{
return new class() extends AbstractParameter {
protected string $method = 'GET';
protected string $uri = '/url';
};
}
public function testShouldReturnEmptyResult(): void
{
$expect = ['message' => 'testing'];
$example = new Example(
$this->createMockClient(200, json_encode($expect))
);
$parser = new JsonParser(
$example->executeBy(
$this->createParameter()
)
);
$mapper = new ExampleResourceMapper($parser);
$this->assertInstanceOf(ExampleResource::class, $mapper->find());
$this->assertSame('testing', $mapper->find()->getMessage());
}
最初のクラスで持っていた責務をそれぞれを小さなオブジェクトにし、
責務を一つずつに分解、
それらを組み合わせて汎用性を持たせる様に変更しました。
このことから一つのメソッドでなんでもできるコードは拡張性とテスタビリティが低いということがわかると思います。
今回紹介した例以外にもさまざまな手法がありますので、
テストを書きやすいコード、保守しやすいコードにするためにさまざまな手法を身につけ、
トランアンドエラーを重ねて身につける様にしましょう!
テストしやすいコードを書きたい、という方の参考になれば幸いです。