【PHPUnit】HTTPテストのTestCaseを作ってみる

PHP

フレームワークや外部ライブラリ禁止でもPHPUnitはOKというプロジェクト用に、LaravelのようなHTTPテストができるTestCaseクラスを作ってみます。

前提条件

  • PHP 8.2以降(PHPUnit12の場合は8.3以降)
  • PHPUnit 11 / 12
  • WEBアプリが動く環境(筆者はnginxでhttps://localhost/)
  • Ubuntu24.04上(WSL2 on Windows11)で作業しています

※次のリポジトリをdocker compose が可能な環境にクローンして、「/etc/hosts」に「127.0.0.1 minio」を追記してから(しなくてもminioのテストが失敗するだけで本件に影響なし)「bin/buildup」でコンテナ作成すれば上記条件をクリアできます。

GitHub - macocci7/docker-la12-al2023-minio: Skelton of docker environment with Laravel12, Amazon Linux 2023 and MinIO
Skelton of docker environment with Laravel12, Amazon Linux 2023 and MinIO - macocci7/docker-la12-al2023-minio

※PHPUnitは「bin/al-user」でコンテナへ接続してからcomposerでインストールしてください。

composer require --dev phpunit/phpunit:^12

どこまでやるのか

  • HTTPクライアント作成:get()、post()、loginAs() は使えるようにする。
  • アサーション作成:assertStatus()、assertOk()、assertRedirect()、assertSee()、assertDontSee()くらいは作る。

フォルダ構成

[プロジェクト]
 ├─ public/ ・・・ドキュメントルート
 ├─ tests/ ・・・テストディレクトリ
 │  ├─ Extensions/ ・・・拡張置き場
 │  │  ├─ Http/ ・・・Httpテスト用拡張置き場
 │  │    ├─ HttpTestAssertions.php ・・・HTTPテスト用アサーショントレイト
 │  │    ├─ HttpTestClient.php ・・・HTTPテスト用HTTPクライアントトレイト(セッションID、クッキー等の処理)
 │  │    └─ HttpTestClientActions.php ・・・HTTPテスト用HTTPクライアントアクショントレイト(get()/post()/loginAs())
 │  ├─ Http/ ・・・HTTPテスト
 │  ├─ HttpTestCase.php ・・・HTTPテスト用TestCaseクラス
 └─ phpunit.xml ・・・PHPUnit設定

※なんか、コードが長くなってしまったのでトレイトに分けました。

※上記リポジトリのクローンを使う場合は、/var/www/html/storage/logs は残しておいてください。

HTTPクライアント作成

▼「tests/Extensions/Http/HttpTestClient.php」:セッションID、クッキー等の処理

<?php

namespace Tests\Extensions\Http;

trait HttpTestClient
{
	use HttpTestClientActions;

	public string $sessionId = "";
	public array $httpResponse = [];
	public array $cookies = [];
	public const APP_URL = "https://localhost";

	protected function getAndSetSessionId(): void
	{
		$this->sessionId = $this->cookies["PHPSESSID"] ?? "";
	}

	protected function setCookies(array $responseHeaders): void
	{
		foreach (array_filter($responseHeaders, fn($h) => str_starts_with($h, "Set-Cookie: ")) as $header) {
			$values = explode("; ", str_replace("Set-Cookie: ", "", $header));
			$pair = explode("=", array_shift($values) ?? "");
			if (! empty($pair)) {
				$this->cookies[$pair[0]] = $pair[1];
			}
		}
	}

	protected function getCoookiesAsHttpRequestHeader(): string
	{
		return "Cookie: " . implode(
			";",
			array_map(
				fn($k, $v) => $k . "=" . $v,
				array_keys($this->cookies),
				$this->cookies
			)
		);
	}
}

▼「tests/Extensions/Http/HttpTestClientActions.php」:get()/post()/loginAs()

※loginAs() はガチのログインなので email と password が必要です。

※リダイレクト先への自動遷移はしません。

<?php

namespace Tests\Extensions\Http;

trait HttpTestClientActions
{
	public function loginAs(string $email, string $password): static
	{
		$this->getAndSetSessionId();
		$data = ["email" => $email, "password" => $password];
		$this->post("/login.php", $data);
		$this->httpResponse = [];
		return $this;
	}

	public function get(string $url, array $queries = [], array $headers = []): static
	{
		$url = static::APP_URL . $url;
		if (! empty($queries)) {
			$url .= (strpos($url, "?") === false ? "?" : "&") . http_build_query($queries);
		}
		$defaultHeaders = ["Accept: text/html"];
		if (! empty($this->cookies)) {
			$defaultHeaders[] = $this->getCoookiesAsHttpRequestHeader();
		}
		$headers = array_merge($defaultHeaders, $headers);
		$context = stream_context_create([
			"http" => [
				"method"  => "GET",
				"header"  => implode("\r\n", $headers),
				"ignore_errors" => true,
				"follow_location" => 0,
			],
		]);
		$this->httpResponse = $this->sendRequest($url, $context);
		$this->setCookies($this->httpResponse["headers"]);
		$this->getAndSetSessionId();
		return $this;
	}

	/**
	 * @param	string $url
	 * @param	array<string, string|int|float>	$data = []
	 * @param	string[]	$headers = []
	 */
	public function post(string $url, array $data = [], array $headers = []): static
	{
		$url = static::APP_URL . $url;
		$data["_token"] = $data["_token"] ?? $this->sessionId;
		$body = http_build_query($data);
		$defaultHeaders = [
			"Content-Type: application/x-www-form-urlencoded",
			"Accept: text/html",
			"Content-Length: " . strlen($body),
		];
		if (! empty($this->cookies)) {
			$defaultHeaders[] = $this->getCoookiesAsHttpRequestHeader();
		}
		$headers = array_merge($defaultHeaders, $headers);
		$context = stream_context_create([
			"http" => [
				"method"  => "POST",
				"header"  => implode("\r\n", $headers),
				"content" => $body,
				"ignore_errors" => true,
				"follow_location" => 0,
			],
		]);
		$this->httpResponse = $this->sendRequest($url, $context);
		$this->setCookies($this->httpResponse["headers"]);
		$this->getAndSetSessionId();
		return $this;
	}

	private function sendRequest(string $url, $context): array
	{
		$responseBody = file_get_contents($url, false, $context);
		$statusCode = 0;
		$headers = $http_response_header ?? [];
		if (isset($headers[0]) && preg_match("/HTTP\/\S+ (\d+)/", $headers[0], $matches)) {
			$statusCode = (int) $matches[1];
		}
		return [
			"status"  => $statusCode,
			"headers" => $headers,
			"body"    => $responseBody,
			"json"    => json_decode($responseBody, true),
		];
	}
}

アサーション作成

▼「tests/Extensions/Http/HttpTestAssertions.php」:HTTPテスト用アサーション

※アサーションは、既存のアサーションをラッピングするか、「assertThat()」を使って拡張できます。

※「assertThat()」を使わない場合は、「fail()」メソッドを使ってカスタムメッセージを定義できます。

※「assert***()」を使わないアサーションの場合は、パスした後の経路に「addToAssertionCount(1)」を忘れずに入れておきましょう。

<?php

namespace Tests\Extensions\Http;

trait HttpTestAssertions
{
	final public function assertStatus(int $status): static
	{
		$this->assertSame(
			$status,
			$this->httpResponse["status"] ?? null,
		);
		return $this;
	}

	final public function assertOk(): static
	{
		$this->assertSame(200, $this->httpResponse["status"]);
		return $this;
	}

	final public function assertSee(string $string): static
	{
		$this->assertStringContainsString($string, $this->httpResponse["body"]);
		return $this;
	}

	final public function assertDontSee(string $string): static
	{
		$this->assertThat(
			$string,
			$this->logicalNot(
				$this->stringContains($this->httpResponse["body"])
			)
		);
		return $this;
	}

	final public function assertRedirect(string|null $uri = null): static
	{
		$status = $this->httpResponse["status"] ?? 0;
		$isRedirected = $status === 301 || $status === 302;
		if (! $isRedirected) {
			$this->fail("Failed asserting that response status is a redirect (301 or 302). Got: {$status}");
			return $this;
		}
		if (! is_null($uri)) {
			$locationHeader = array_filter(
				$this->httpResponse["headers"] ?? [],
				fn($h) => str_starts_with($h, "Location:")
			);
			$locationUrl = empty($locationHeader)
				? ""
				: str_replace("Location: ", "", array_shift($locationHeader));
			if ($locationUrl !== $uri) {
				$this->fail("Failed asserting that Location header is '{$uri}'. Got: '{$locationUrl}'");
			}
		}
		$this->addToAssertionCount(1);
		return $this;
	}
}

HTTPテスト用TestCase

▼「tests/HttpTestCase.php」

<?php

declare(strict_types=1);

namespace Tests;

use PHPUnit\Framework\TestCase as PHPUnitTestCase;
use Tests\Extensions\Http\HttpTestAssertions;
use Tests\Extensions\Http\HttpTestClient;

class HttpTestCase extends PHPUnitTestCase {
	use HttpTestClient;
	use HttpTestAssertions;
}

動作確認用のWEBアプリ

雑にログイン機構を作ってみます。(良い子は真似しないでください。)

▼ファイル構成

[プロジェクト]
 ├─ public/
 │  ├─ dashboard.php ・・・ダッシュボード(ログイン後アクセス可)
 │  ├─ index.php ・・・サイトトップ⇒ログインへリダイレクト
 │  ├─ login.php ・・・ログインフォーム
 │  └─ logout.php ・・・ログアウト⇒ログインへリダイレクト
 └─ tests/

▼ index.php:ログインフォームへリダイレクト

<?php
header("Location: /login.php" , true, 301);
exit;

▼ login.php:GETの場合はログインフォーム、POSTの場合は認証。認証済ならダッシュボードへ。

<?php
    session_start();
    if($_SERVER["REQUEST_METHOD"] === "POST") {
        $email = $_POST["email"] ?? null;
        $password = $_POST["password"] ?? null;
        $authenticated = $email === "hoge@example.com"
            && $password === "password";
        if($authenticated) {
            $_SESSION["authenticated"] = true;
            session_regenerate_id();
            header("Location: /dashboard.php" , true, 301);
            exit;
        }
        $_SESSION["authenticated"] = false;
        $message = "ログインに失敗しました。";
    } elseif($_SESSION["authenticated"] ?? false) {
        header("Location: /dashboard.php" , true, 301);
        exit;
    }
    session_write_close();
?>
<!doctype html>
<html>
    <head>
        <title>ログイン</title>
        <meta charset="utf-8" >
    </head>
    <body>
        <h1>ログイン</h1>
        <?php echo $message ?? "" ?>
        <form method="POST" action="/login.php">
            <input type="hidden" name="_token" value="hogehoge" />
            <p>
                <label for="email">メールアドレス</label>
                <input type="text" name="email" id="email" placeholder="user@example.com" value="" />
            </p>
            <p>
                <label for="password">パスワード</label>
                <input type="password" name="password" id="password" value="" />
            </p>
            <button type="submit">ログイン</button>
        </form>
    </body>
</html>

▼ logout.php:ログアウト⇒ログインフォームへリダイレクト。

<?php
session_start();
unset($_SESSION["authenticated"]);
session_write_close();

header("Location: /login.php" , true, 301);
exit;

▼ dashboard.php:ダッシュボード。未認証ならログインフォームへリダイレクト。

<?php
    session_start();
    $authenticated = $_SESSION["authenticated"] ?? false;
    if(! $authenticated) {
        header("Location: /login.php" , true, 301);
        exit;
    }
    session_write_close();
?>
<!doctype html>
<html>
    <head>
        <title>ダッシュボード</title>
        <meta charset="utf-8" >
    </head>
    <body>
        <h1>ダッシュボード</h1>
        <a href="/logout.php">ログアウト</a>
    </body>
</html>

HTTPテストコード

▼ファイル

[プロジェクト]
 ├─ public/
 ├─ tests/Http/
       ├─ DashboardTest.php ・・・ダッシュボードのテスト
       ├─ IndexTest.php ・・・トップページのテスト
       ├─ LoginTest.php ・・・ログインのテスト
       └─ LogoutTest.php ・・・ログアウトのテスト

▼「tests/Http/DashboardTest.php」

<?php

declare(strict_types=1);

namespace Tests\Http;

use PHPUnit\Framework\Attributes\DataProvider;
use Tests\HttpTestCase as TestCase;

final class DashboardTest extends TestCase {
    public function testDashboardCanRedirectToLoginFormWithoutLogin(): void {
        $this->get("/dashboard.php")
            ->assertRedirect("/login.php");
    }

    public function testDashboardCanRenderDashboardAfterLogin(): void {
        $this->loginAs("hoge@example.com", "password")
            ->get("/dashboard.php")
            ->assertOk()
            ->assertSee("ダッシュボード")
            ->assertSee("ログアウト")
            ->assertDontSee("ログイン");
    }
}

▼「tests/Http/IndexTest.php」

<?php

declare(strict_types=1);

namespace Tests\Http;

use PHPUnit\Framework\Attributes\DataProvider;
use Tests\HttpTestCase as TestCase;

final class IndexTest extends TestCase {
    public function testIndexCanRedirectToLoginForm(): void {
        $this->get("/")
            ->assertRedirect("/login.php");
    }
}

▼「tests/Http/LoginTest.php」

<?php

declare(strict_types=1);

namespace Tests\Http;

use PHPUnit\Framework\Attributes\DataProvider;
use Tests\HttpTestCase as TestCase;

final class LoginTest extends TestCase {
    public function testLoginCanRenderLoginForm(): void {
        $this->get("/login.php")
            ->assertOk()
            ->assertSee("ログイン")
            ->assertSee("メールアドレス")
            ->assertSee("パスワード")
            ->assertDontSee("ログインに失敗しました。");
    }

    public function testLoginCanRedirectToDashboardWhenAuthenticated(): void {
        $credential = [
            "email" => "hoge@example.com",
            "password" => "password",
        ];
        $this->post("/login.php", $credential)
            ->assertRedirect("/dashboard.php");
    }

    public static function provideLoginCanRenderLoginFormAgainWhenLoginFailed(): array {
        return [
            "case 1" => ["email" => "", "password" => ""],
            "case 2" => ["email" => "hoge@example.com", "password" => ""],
            "case 3" => ["email" => "", "password" => "password"],
            "case 4" => ["email" => "no-such@mail.address", "password" => "password"],
            "case 5" => ["email" => "hoge@example.com", "password" => "hogehoge"],
        ];
    }

    #[DataProvider("provideLoginCanRenderLoginFormAgainWhenLoginFailed")]
    public function testLoginCanRenderLoginFormAgainWhenLoginFailed(
        string $email,
        string $password,
    ): void {
        $this->post("/login.php", ["email" => $email, "password" => $password])
            ->assertOk()
            ->assertSee("ログイン")
            ->assertSee("ログインに失敗しました。")
            ->assertSee("メールアドレス")
            ->assertSee("パスワード")
            ->assertDontSee("ダッシュボード");
    }

    public function testLoginCanRedirectToDashboardAfterAuthenticated(): void {
        $this->loginAs("hoge@example.com", "password")
            ->get("/login.php")
            ->assertRedirect("/dashboard.php");
    }
}

▼「tests/Http/LogoutTest.php」

<?php

declare(strict_types=1);

namespace Tests\Http;

use PHPUnit\Framework\Attributes\DataProvider;
use Tests\HttpTestCase as TestCase;

final class LogoutTest extends TestCase {
    public function testLogoutCanRedirectToLoginForm(): void {
        $this->get("/logout.php")
            ->assertRedirect("/login.php");
        $this->loginAs("hoge@example.com", "password")
            ->get("/logout.php")
            ->assertRedirect("/login.php");
    }
}

PHPUnitのXML設定

▼「phpunit.xml」

<phpunit
    colors="true"
    testdox="true"
    testdoxSummary="true"
    displayDetailsOnTestsThatTriggerNotices="true"
    displayDetailsOnTestsThatTriggerWarnings="true"
    displayDetailsOnTestsThatTriggerErrors="true"
    displayDetailsOnTestsThatTriggerDeprecations="true"
    displayDetailsOnPhpunitDeprecations="true"
>
	<testsuites>
		<testsuite name="Http">
			<directory>tests/Http</directory>
		</testsuite>
	</testsuites>
	<source>
		<include>
			<directory suffix=".php">public</directory>
		</include>
		<exclude>
		</exclude>
	</source>
	<php>
		<env name="APP_ENV" value="testing"/>
	</php>
</phpunit>

composer.jsonにautoload-dev追加

「composer.json」にautoload-devの項目を追記して、クラスの自動読込を有効にします。

{
    "require-dev": {
        "phpunit/phpunit": "^12"
    },
    "autoload-dev": {
        "psr-4": {
            "Tests\\": "tests/"
        }
    }
}

HTTPテストを実行してみる

では、HTTPテストを実行してみます。

vendor/bin/phpunit tests/Http

無事、HTTPテストを実施することができました。

コメント

タイトルとURLをコピーしました