PHPUnitでHTTPステータスコード確認するテスト


# これはなに

単に「HTTPステータスコードを確認するテスト」をPHPUnitで実装してローカルで動かしているよという記事です。

サンプルをGitHubのリポジトリ（[phpunit-httpcheck](https://github.com/gcchaan-sandbox/phpunit-httpcheck)）として公開しています。

# 要約

- PHPロジック多めの独自WordPressテーマをチューニングしたい
- 各ページがデグレしていないかリリース前に把握したい
- 用意したURLのリストに対してリクエストを発生させ、HTTPステータスコードを調べるPHPUnitのテストケースを作成した
- ローカルに加えてステージング・本番環境もテストできるようにした
- 検証するURLが多く時間がかかるためparatestで並列化

使用したライブラリはGuzzle、PHPUnit、paratest、phpdotenv

# 背景

リビンテクノロジーズで運営しているサイトの一部は、独自テーマを使ったWordPressで運用しています。
コンテンツで利用する各種データをAPIから取得するようには作れているのですが、テンプレートファイルの共通化やリクエストパラメーターの整理をする余地がある状態です。:cry:

最近は、このWordPressのサイトのパフォーマンス改善を行っています。
変更を加えた際に影響範囲を考慮できずに想定しなかったURLで不具合を発生させてしまうことがありました。

回帰テストを実行してエラーを事前に検知したいです。

# 仕様

#### 前提条件

- リリースする前に検知したい
    - 本番環境であれば監視ツールでHttpCheckを行いますが、開発中に気軽に実行できるようにローカルで実行できるようにしたいです
- DOMの確認はしなくてよい
    - E2Eテストも将来的に行いたいところですが、まずはリクエストを発生させてエラーを検知できればよいとしました
- プログラミング言語は不問
    - プロダクションコードを検査するわけではないため、プログラミング言語の縛りは特にないです。個人的には他の言語で書きたいところですが、同僚が使い慣れているPHPで実装することにします

### 要件のアイデア

* PHPUnitのテストコードとして表現できること
* Composerのscriptsによって実行できること
* ローカルに加え、ステージング環境と本番環境もチェックできること

# 実装

### requirements

- PHP >= 7.4
- php.iniの`variables_order`に`E`が含まれている
    - ※`$_ENV`を使うために必要

### illuminate/testing 使おうとするもうまくいかない

Laravel初期化時に自動生成されるテストコード`test/Feature/ExampleTest.php`が、今回の実装アイデアに影響を与えています。
まず、これをそのままWordPressプロジェクトにも使えないかなと考えました。
EloquentやCollectionはLaravelではないPHPプロジェクトからでも使えるらしいことは知っていました。同じノリで `composer require --dev illuminate/testing` して実装をしようと思うもautoloaderの設定がうまくいきませんでした。
今回の要件ではむしろシンプルに実装すればよいので深追いはしませんでした。

### GuzzleでHTTPリクエストして検査

ライブラリをインストールします。
```
$ composer init
$ composer require --dev guzzlehttp/guzzle phpunit/phpunit
```

テストケースを書きます。このコンストラクタのシグネチャと`parent`の呼出しはPhpstormが自動生成してくれました、便利。

```php:tests/Feature/HttpCheckTest.php
<?php

declare(strict_types=1);

namespace Tests\Feature;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use PHPUnit\Framework\TestCase;

class HttpCheckTest extends TestCase {
    private Client $client;
    public function __construct(?string $name = null, array $data = [], $dataName = '')
    {
        $this->client = new Client();
        parent::__construct($name, $data, $dataName);
    }

    public function test_HTTPステータスが200(): void
    {
        $url = 'https://example.com';
        try {
            $response = $this->client->request('GET', $url);
            $this->assertEquals(200, $response->getStatusCode());
        } catch (ClientException $e) {
            $this->fail("レスポンスエラー。url: '$url', statusCode: " . $e->getCode());
        } catch (GuzzleException) {
            $this->fail("通信エラー。url: '$url'");
        }
    }
```

実行します。

```
$ ./vendor/bin/phpunit tests/Feature/HttpCheckTest.php
PHPUnit 9.5.13 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 00:00.743, Memory: 6.00 MB

OK (1 test, 1 assertion)

```

URLを変更し、失敗することも確認します。例えば以下です。
* ステータスコード404を返すもの `https://example.com/404`
* でたらめなもの `https://example`

### 複数のURLに対応する

確認したいページ（パス）は複数あるため、それらを簡単にテストできるようにしたいです。
一般的なPHPアプリケーションだとControllerごとにテストを分けるのでしょうが、WordPressなので一括でHTTPテストをしてしまいます。

PHPUnitはパラメーターを変更して簡単にテストを行うためにDataProviderという仕組みがあります。
さっそくDataProviderを使って複数のURLに対応します。

```diff
+    /**
+     * @dataProvider urlProvider
+     */
-    public function test_HTTPステータスが200(): void
+    public function test_HTTPステータスが200(string $url): void
    {
-       $url = 'https://example.com';
...略
    }
+
+    public function urlProvider(): array
+    {
+        return [
+            ['https://example.com'],
+            ['https://example.com/404'],
+        ];
+    }
```

### ファイル指定せずに実行できるようにする

ファイルを指定しなくても実行できるようにします。
ルートに `phpunit.xml.dist` を追加すればOKです。

```xml:phpunit.xml.dist
<?xml version="1.0"?>
<phpunit
        backupGlobals="false"
        colors="true"
        convertErrorsToExceptions="true"
        convertNoticesToExceptions="true"
        convertWarningsToExceptions="true"
        >
        <testsuites>
                <testsuite name="wordpress">
                        <directory suffix="Test.php">./tests/</directory>
                </testsuite>
        </testsuites>
</phpunit>

```

これにより、次のコマンドで実行できるようになりました。

```
$ ./vendor/bin/phpunit
```


### composerコマンドから実行できるようにする

composer.jsonにエイリアスをはります。

```json:composer.json
{
    "name": "lvn/lvnmatch_jp",
    "type": "project",
    "require-dev": {
        "guzzlehttp/guzzle": "^7.4",
        "phpunit/phpunit": "^9.5"
    },
    "autoload-dev": {
        "psr-4": {
            "Tests\\": "tests/"
        }
    },
    "scripts": {
        "http": [
            "phpunit"
        ]
    }
}

```

次のコマンドで実行できるようになりました。

```
$ composer http
```

### 複数の環境に対応する

本番環境・ステージング環境・ローカル環境があります。
特にステージング環境にデプロイした際にも確認したいため、
それぞれのURLに対応するコマンドを実行することでテストできるようにします。

開発者によってローカル開発環境のURLが異なるため `.env` で設定できるようにします。

`.env`を作成します。同様の内容を`.env.example`としてリポジトリにコミットしておくとよいです。
WordPressプロジェクト（Composerを使っていない）ため考慮する必要性は低いですが、通常の環境変数と区別できるように `TEST_` というプレフィックスをつけてやります。

```.env
TEST_LOCAL_URL="http://localhost:8000"
```

`.env` はgit管理からは除外します。

```diff:.gitignore
+ .env
```

テストコードから`.env`ファイルを読めるようにします。
各環境でテストしたいパスは`$basePaths`として扱えるようにします。

WordPressの記事などは環境によってURLが違うことがあります。環境特有のパスは `$stagePaths` として扱えるようにします。

本番環境へのテストは負荷をかけないようにsleepをいれます。

なお本WordPressプロジェクトに関しては `All-in-One WP Migration`でエクスポートした本番環境のデータをローカル環境で使っているため、本番とローカルの記事のパスは同じです。

```
$ composer require --dev vlucas/phpdotenv
```

```diff:tests/Feature/HttpCheckTest.php
class HttpCheckTest extends TestCase {
    private Client $client;
    public function __construct(?string $name = null, array $data = [], $dataName = '')
    {
+        $dotenv = \Dotenv\Dotenv::createImmutable(__DIR__ . '/../../');
+        $dotenv->load();
        $this->client = new Client();
        parent::__construct($name, $data, $dataName);
    }

    /**
     * @dataProvider urlProvider
     */
    public function test_HTTPステータスが200(string $url)
    {
+        if($_ENV['TEST_ENV'] === 'production') {
+            sleep(1);
+        }
...略
    }

    public function urlProvider(): array
    {
-        return [
-            ['https://example.com'],
-        ];
+        $env = $_ENV['TEST_ENV'];
+        if ($env === 'production' || $env === 'local') {
+            if ($env === 'production') {
+                $url = 'https://本番環境のURL';
+            } else {
+                $url = $_ENV['TEST_LOCAL_URL'];
+            }
+            $stagePaths = [];
+        } else if ($env === 'staging') {
+            $url = 'https://ステージング環境のURL';
+            $stagePaths = [];
+        } else {
+            return []; // skip test.
+        }
+        $basePaths = [
+            '/',
+            '/hoge',
+            '/fuga',
+        ];
+        $paths = [...$stagePaths, ...$basePaths];
+        return array_map(fn($path) => [$url . $path], $paths);
    }

```

```diff:composer.json
    "scripts": {
-        "http": [
-            "phpunit"
+        "http:prod": [
+            "TEST_ENV=production phpunit"
+        ],
+        "http:stg": [
+            "TEST_ENV=staging phpunit"
+        ],
+        "http:dev": [
+            "TEST_ENV=local phpunit"
        ]
    }

```

テストしたい環境のコマンドを実行できるようになりました。
コロンに違和感があるかもしれませんがnpmのscriptsでよく見る書き方を真似しています。

- `$ composer http:prod`
- `$ composer http:stg`
- `$ composer http:dev`

### paratestで並列実行する

テストケースを直列で実行しているためURLの数が多いと時間がかかります。[paratest](https://github.com/paratestphp/paratest)を使って並列で行えるように変更します。

まずはparatestを依存に加えます。

```
$ composer require --dev brianium/paratest
```

phpdotenvを`createMutable()`に変更します。

```diff::HttpCheckTest.php
    public function __construct(?string $name = null, array $data = [], $dataName = '')
    {
-        $dotenv = \Dotenv\Dotenv::createImmutable(__DIR__ . '/../../');
+        $dotenv = \Dotenv\Dotenv::createMutable(__DIR__ . '/../../');
        $dotenv->load();
        $this->client = new Client();
        parent::__construct($name, $data, $dataName);
    }
```

テストするURLを試しに30個に増やして挙動を確認します。
例として`urlPrivider()`が提供するURLを`https://google.com/`にして検証する場合は次のように関数を変更します。
※実際は負荷をかけても問題のないURLで実行してください。

```php:HttpCheckTest.php
    public function urlProvider(): array
    {
        return array_map(fn() => ['https://google.com/'], range(1, 30));
    }
```

実行します。テストケースが少なく、dataProviderを使ったテストをしているため`-f`オプションを指定する点がミソです。

```
$ ./vendor/bin/paratest -p 4 -f
```

4プロセスで実行されます。テストケースに`sleep(1);`をはさむと1秒おきに4つずつテストが進むことを目視できます。

本番環境以外のComposerのscriptsをparatestのものに置き換えます。

```json:composer:json
{
    "scripts": {
        "http:prod": [
            "TEST_ENV=production phpunit"
        ],
        "http:stg": [
            "TEST_ENV=staging paratest -p 4 -f"
        ],
        "http:dev": [
            "TEST_ENV=local paratest -p 4 -f"
        ]
    }
}
```

これで求めているものが実装できました。
`urlPrivider()`はもちろん元に戻します。

# 運用

### 実行するタイミング

ローカル環境用のテストケースを実行するタイミングは開発中ですが、
少なくともプルリクエストのレビュー依頼前に1度実行するようにします。

### エラーを確認する仕組みを作る

本テストケースでは、不具合のあったURLはわかりますがエラーの内容まではわかりません。
ローカルからの実行でもメールやSlack、Sentryなどにエラー内容を通知できるようにして対応を簡単にします。

### テストケースにURLを追加する

次のような場合はテストケースにURLを追加します。

- 配列に用意していないURLでエラーが発生した場合
    - エラーが関数に関係するものならユニットテストを書きます。
    - ユニットテストで対応できない時はそのURLを今回のテストケースに追加します。
- ページ（URL）が増えた場合

# おわりに

アプリケーションへ変更を加えた後に、簡単なものですがデグレを検知する仕組みを構築できました。
検知したエラーはSentryに通知して対応できるようにしています。
以前よりも安心してリリースできて体験がよい！