フレームワークや外部ライブラリ禁止でも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」でコンテナ作成すれば上記条件をクリアできます。
※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テストを実施することができました。
コメント