レポジトリ種類: SVN
<?php
namespace Site\Lib;
/**
* 軽量なユニットテストフレームワーク
*/
class Tester {
// テスト統計
private int $testCount = 0;
private int $passCount = 0;
private int $failCount = 0;
private int $errorCount = 0;
// 現在のテストケース情報
private string $currentTestCase = '';
private string $currentTest = '';
// テストスイート設定
private bool $colorOutput = true;
private bool $verboseOutput = true;
private bool $stopOnFailure = false;
private array $beforeEachCallbacks = [];
private array $afterEachCallbacks = [];
private array $beforeAllCallbacks = [];
private array $afterAllCallbacks = [];
// ターミナル出力用の色
private array $colors = [
'reset' => "\033[0m",
'red' => "\033[31m",
'green' => "\033[32m",
'yellow' => "\033[33m",
'blue' => "\033[34m",
'magenta' => "\033[35m",
'cyan' => "\033[36m",
'white' => "\033[37m",
'bold' => "\033[1m",
];
private array $failures = [];
private array $errors = [];
/**
* コンストラクタ
*
* @param array $options 設定オプション
*/
public function __construct(array $options = []) {
// オプションを設定
if (isset($options['colorOutput'])) {
$this->colorOutput = (bool)$options['colorOutput'];
}
if (isset($options['verboseOutput'])) {
$this->verboseOutput = (bool)$options['verboseOutput'];
}
if (isset($options['stopOnFailure'])) {
$this->stopOnFailure = (bool)$options['stopOnFailure'];
}
// サポートされていない場合は色を無効にする
if (PHP_SAPI !== 'cli' || strtoupper(substr(PHP_OS, 0, 3)) == 'WIN'
&& !getenv('ANSICON')) {
$this->colorOutput = false;
}
}
/**
* 各テストの前に実行する関数を登録する
*
* @param callable $callback コールバック関数
* @return Tester このインスタンス
*/
public function beforeEach(callable $callback): Tester {
$this->beforeEachCallbacks[] = $callback;
return $this;
}
/**
* 各テストの後に実行する関数を登録する
*
* @param callable $callback コールバック関数
* @return Tester このインスタンス
*/
public function afterEach(callable $callback): Tester {
$this->afterEachCallbacks[] = $callback;
return $this;
}
/**
* すべてのテストの前に実行する関数を登録する
*
* @param callable $callback コールバック関数
* @return Tester このインスタンス
*/
public function beforeAll(callable $callback): Tester {
$this->beforeAllCallbacks[] = $callback;
return $this;
}
/**
* すべてのテストの後に実行する関数を登録する
*
* @param callable $callback コールバック関数
* @return Tester このインスタンス
*/
public function afterAll(callable $callback): Tester {
$this->afterAllCallbacks[] = $callback;
return $this;
}
/**
* テストケースを定義する
*
* @param string $description テストケースの説明
* @param callable $callback テストケース関数
* @return Tester このインスタンス
*/
public function describe(string $description, callable $callback): Tester {
$this->currentTestCase = $description;
$this->output($this->colorize('bold', "テストケース: {$description}"));
try {
foreach ($this->beforeAllCallbacks as $before) {
call_user_func($before);
}
call_user_func($callback, $this);
foreach ($this->afterAllCallbacks as $after) {
call_user_func($after);
}
} catch (\Throwable $e) {
$this->recordError(
"テストケースのセットアップ/ティアダウンでエラー: ".$e->getMessage(),
$e->getTraceAsString());
}
$this->output('');
return $this;
}
/**
* 単一のテストを実行する
*
* @param string $description テストの説明
* @param callable $callback テスト関数
* @return Tester このインスタンス
*/
public function it(string $description, callable $callback): Tester {
$this->currentTest = $description;
$this->testCount++;
if ($this->verboseOutput) {
$this->output(" ⋄ テスト中: {$description}... ", false);
}
try {
foreach ($this->beforeEachCallbacks as $before) {
call_user_func($before);
}
call_user_func($callback, $this);
foreach ($this->afterEachCallbacks as $after) {
call_user_func($after);
}
// Test has passed.
$this->passCount++;
if ($this->verboseOutput) {
$this->output($this->colorize('green', "合格"));
}
} catch (AssertionFailedException $e) {
$this->failCount++;
if ($this->verboseOutput) {
$this->output($this->colorize('red', "失敗"));
$this->output($this->colorize('red', " → ".$e->getMessage()));
}
$this->recordFailure($e->getMessage());
if ($this->stopOnFailure) {
$this->printSummary();
exit(1);
}
} catch (\Throwable $e) {
$this->errorCount++;
if ($this->verboseOutput) {
$this->output($this->colorize('yellow', "エラー"));
$this->output($this->colorize('yellow', " → ".$e->getMessage()));
}
$this->recordError($e->getMessage(), $e->getTraceAsString());
if ($this->stopOnFailure) {
$this->printSummary();
exit(1);
}
}
return $this;
}
/**
* テストをスキップする
*
* @param string $description テストの説明
* @param string $reason スキップする理由。デフォルト: "まだ実装されていません"
* @return Tester このインスタンス
*/
public function skip(string $description,
string $reason = 'まだ実装されていません'): Tester {
if ($this->verboseOutput) {
$this->output(" ⋄ スキップ: {$description}... "
.$this->colorize('cyan', "スキップ"));
$this->output($this->colorize('cyan', " → {$reason}"));
}
return $this;
}
/**
* 条件がtrueである事をアサートする
*
* @param bool $condition チェックする条件
* @param string $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertTrue(bool $condition,
string $message = '条件がtrueであることを期待しました'): Tester {
if ($condition !== true) {
throw new AssertionFailedException($message);
}
return $this;
}
/**
* 条件がfalseである事をアサートする
*
* @param bool $condition チェックする条件
* @param string $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertFalse(bool $condition,
string $message = '条件がfalseであることを期待しました'): Tester {
if ($condition !== false) {
throw new AssertionFailedException($message);
}
return $this;
}
/**
* 二つの値が等しい事をアサートする
*
* @param mixed $expected 期待値
* @param mixed $actual 実際の値
* @param string|null $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertEquals(mixed $expected, mixed $actual,
?string $message = null): Tester {
if ($expected != $actual) {
if ($message === null) {
$expected = $this->exportValue($expected);
$actual = $this->exportValue($actual);
$message = "{$expected}を期待しましたが、{$actual}が得られました";
}
throw new AssertionFailedException($message);
}
return $this;
}
/**
* 二つの値が同一である事をアサートする
*
* @param mixed $expected 期待値
* @param mixed $actual 実際の値
* @param string|null $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertSame(mixed $expected, mixed $actual,
?string $message = null): Tester {
if ($expected !== $actual) {
if ($message === null) {
$expected = $this->exportValue($expected);
$actual = $this->exportValue($actual);
$message =
"{$expected}を期待しましたが、{$actual}が得られました(厳密な比較)";
}
throw new AssertionFailedException($message);
}
return $this;
}
/**
* 値がnullである事をアサートする
*
* @param mixed $actual チェックする値
* @param string|null $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertNull(mixed $actual, ?string $message = null): Tester {
if ($actual !== null) {
if ($message === null) {
$actual = $this->exportValue($actual);
$message = "nullを期待しましたが、{$actual}が得られました";
}
throw new AssertionFailedException($message);
}
return $this;
}
/**
* 値がnullでない事をアサートする
*
* @param mixed $actual チェックする値
* @param string|null $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertNotNull(mixed $actual, ?string $message = null): Tester {
if ($actual === null) {
if ($message === null) {
$message = "値がnullでない事を期待しました";
}
throw new AssertionFailedException($message);
}
return $this;
}
/**
* 値が特定のキーを持つ事をアサートする
*
* @param mixed $key チェックするキー
* @param array $array チェックする配列
* @param string|null $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertArrayHasKey(mixed $key, array $array,
?string $message = null): Tester {
if (!is_array($array) && !($array instanceof \ArrayAccess)) {
throw new AssertionFailedException(
'第2引数は配列又はArrayAccessを実装している必要があります');
}
if (!array_key_exists($key, $array)) {
if ($message === null) {
$message = "配列がキー '{$key}' を持つ事を期待しました";
}
throw new AssertionFailedException($message);
}
return $this;
}
/**
* 文字列がサブ文字列を含むことをアサートする
*
* @param string $needle 検索するサブ文字列
* @param string $haystack 検索対象の文字列
* @param string|null $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertStringContains(string $needle, string $haystack,
?string $message = null): Tester {
if (!is_string($needle) || !is_string($haystack)) {
throw new AssertionFailedException('両方の引数は文字列である必要があります');
}
if (strpos($haystack, $needle) === false) {
if ($message === null) {
$message = "文字列 '{$haystack}' が '{$needle}' を含む事を期待しました";
}
throw new AssertionFailedException($message);
}
return $this;
}
/**
* コールバックが例外をスローする事をアサートする
*
* @param callable $callback 実行するコールバック
* @param string $exceptionClass 期待される例外クラス
* @param string|null $message 失敗時のオプションメッセージ
* @throws AssertionFailedException アサーションが失敗した場合
* @return Tester このインスタンス
*/
public function assertThrows(callable $callback, string $exceptionClass,
?string $message = null): Tester {
try {
call_user_func($callback);
if ($message === null) {
$message = "'{$exceptionClass}' 型の例外がスローされる事を期待しましたが、スローされませんでした";
}
throw new AssertionFailedException($message);
} catch (\Throwable $e) {
if (!($e instanceof $exceptionClass)) {
if ($message === null) {
$message = "'{$exceptionClass}' 型の例外を期待しましたが、"
.get_class($e)." が得られました";
}
throw new AssertionFailedException($message);
}
}
return $this;
}
/**
* Print a summary of the test results
*
* @return Tester
*/
public function printSummary(): Tester {
$this->output('');
$this->output($this->colorize('bold', "テスト結果の概要:"));
$this->output(" テスト総数: {$this->testCount}");
$this->output(" ".$this->colorize('green', "合格: {$this->passCount}"));
if ($this->failCount > 0) {
$this->output(" ".$this->colorize('red', "失敗: {$this->failCount}"));
} else {
$this->output(" 失敗: 0");
}
if ($this->errorCount > 0) {
$this->output(" ".$this->colorize('yellow', "エラー: {$this->errorCount}"));
} else {
$this->output(" エラー: 0");
}
$this->output('');
// 失敗を書き出す
if (count($this->failures) > 0) {
$this->output($this->colorize('bold', "失敗:"));
foreach ($this->failures as $i => $f) {
$num = $i + 1;
$this->output(" {$num}) {$f['testCase']} → {$f['test']}");
$this->output(" ".$this->colorize('red', $f['message']));
$this->output('');
}
}
// エラーを書き出す
if (count($this->errors) > 0) {
$this->output($this->colorize('bold', "エラー:"));
foreach ($this->errors as $i => $e) {
$num = $i + 1;
$this->output(" {$num}) {$e['testCase']} → {$e['test']}");
$this->output(" ".$this->colorize('yellow', $e['message']));
if (isset($e['trace'])) {
$this->output(" ".$this->colorize('yellow', "スタックトレース:"));
$this->output(" ".$this->colorize('yellow', $e['trace']));
}
$this->output('');
}
}
if ($this->failCount === 0 && $this->errorCount === 0) {
$this->output($this->colorize('green', "全てのテストに合格しました!"));
} else {
$this->output($this->colorize('red', "テストが失敗・エラーで完了しました。"));
}
return $this;
}
// 機能性メソッド
/**
* コンソールにテキストを出力する
*
* @param string $text 出力するテキスト
* @param bool $newline 改行を追加するかどうか
* @return void
*/
private function output(string $text, bool $newline = true): void {
echo $text.($newline ? PHP_EOL : '');
}
/**
* 有効な場合はテキストに色を適用する
*
* @param string $color 色名
* @param string $text 色付けするテキスト
* @return string
*/
private function colorize(string $color, string $text): string {
if (!$this->colorOutput || !isset($this->colors[$color])) {
return $text;
}
return $this->colors[$color].$text.$this->colors['reset'];
}
/**
* 値を表示用の文字列としてエクスポートする
*
* @param mixed $value エクスポートする値
* @return string
*/
private function exportValue(mixed $value): string {
if (is_null($value)) return 'null';
if (is_bool($value)) return $value ? 'true' : 'false';
if (is_array($value)) return 'Array('.count($value).')';
if (is_object($value)) return get_class($value).' Object';
if (is_string($value)) {
if (strlen($value) > 40) {
return "'".substr($value, 0, 37)."...'";
}
return "'{$value}'";
}
return (string)$value;
}
/**
* テストの失敗を記録する
*
* @param string $message 失敗メッセージ
* @return void
*/
private function recordFailure(string $message): void {
$this->failures[] = [
'testCase' => $this->currentTestCase,
'test' => $this->currentTest,
'message' => $message,
];
}
/**
* テストのエラーを記録する
*
* @param string $message エラーメッセージ
* @param string|null $trace スタックトレース
* @return void
*/
private function recordError(string $message, ?string $trace = null): void {
$this->errors[] = [
'testCase' => $this->currentTestCase,
'test' => $this->currentTest,
'message' => $message,
'trace' => $trace,
];
}
}
/**
* アサーション失敗用のカスタム例外
*/
class AssertionFailedException extends \Exception {
}