複数ネームスペースを可能に
This commit is contained in:
617
src/Std/Lib/Tester.php
Normal file
617
src/Std/Lib/Tester.php
Normal file
@@ -0,0 +1,617 @@
|
||||
<?php
|
||||
namespace Std\Lib;
|
||||
|
||||
/**
|
||||
* アサーション失敗用のカスタム例外
|
||||
*/
|
||||
class AssertionFailedException extends \Exception {
|
||||
}
|
||||
|
||||
/**
|
||||
* 軽量なユニットテストフレームワーク
|
||||
*/
|
||||
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 bool $skipCurrentDescribe = 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->skipCurrentDescribe = false;
|
||||
$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 {
|
||||
if ($this->skipCurrentDescribe) {
|
||||
$this->skip($description, 'skipAll() が呼び出されました');
|
||||
return $this;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* コレクションが指定された要素数を持つ事
|
||||
*
|
||||
* @param int $expoected 期待される要素数
|
||||
* @param iterable $collection 配列または Traversable
|
||||
* @param string|null $message 失敗時のカスタムメッセージ
|
||||
* @return Tester
|
||||
*
|
||||
* @throws AssertionFailedException
|
||||
*/
|
||||
public function assertCount(int $expected, iterable $collection, ?string $message = null): Tester {
|
||||
$actual = is_countable($collection) ? count($collection) : iterator_count($collection);
|
||||
|
||||
if ($actual !== $expected) {
|
||||
$message ??= "要素数が {$expected} であることを期待しましたが、{$actual} でした";
|
||||
throw new AssertionFailedException($message);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 現在の describe ブロック内の残りすべての it() をスキップ
|
||||
*
|
||||
* @param string $reason スキップ理由(出力に表示)
|
||||
* @return void
|
||||
*/
|
||||
public function skipAll(string $reason = 'このテストケースはスキップされました'): void {
|
||||
$this->skipCurrentDescribe = true;
|
||||
|
||||
if ($this->verboseOutput) {
|
||||
$this->output($this->colorize('cyan', " スキップAll: {$reason}"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* テスト結果の詳細を表示する
|
||||
*
|
||||
* @return Tester
|
||||
*/
|
||||
public function printSummary(): Tester {
|
||||
$this->output('');
|
||||
$this->output($this->colorize('bold', "テスト結果の概要:"));
|
||||
$this->output(" テスト総数: {$this->testCount}");
|
||||
$this->output(" ".$this->colorize('green', "合格: {$this->passCount}"));
|
||||
$this->output(" ".$this->colorize('red', "失敗: {$this->failCount}"));
|
||||
$this->output(" ".$this->colorize('yellow', "エラー: {$this->errorCount}"));
|
||||
$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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user