master
ブランチ master
LittleBeast / src / Std / Lib / Tester.php
21 KB、667行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, and/or sell copies of the Software, subject to the following conditions:
10
11 1. **Origin Attribution**:
12 - You must not misrepresent the origin of the Software; you must not claim
13 you created the original Software.
14 - If the Software is used in a product, you must either:
15 a. Provide clear attribution in the product's documentation, user interface,
16 or other visible areas, **OR**
17 b. Pay the original developers a fee they specify in writing.
18 2. **Usage Restriction**:
19 - The Software, or any derivative works, dependencies, or libraries
20 incorporating it, must not be used for censorship or to suppress freedom of
21 speech, expression, or creativity. Prohibited uses include, but are not
22 limited to:
23 - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24 ideas, or objective reality.
25 - Tools or systems designed to restrict access to information or
26 artistic works.
27 3. **Notice Preservation**:
28 - This license and the above copyright notice must remain intact in all copies
29 of the source code.
30
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Std\Lib;
39
40/**
41 * アサーション失敗用のカスタム例外
42 */
43class AssertionFailedException extends \Exception {
44}
45
46/**
47 * 軽量なユニットテストフレームワーク
48 */
49class Tester {
50 // テスト統計
51 private int $testCount = 0;
52 private int $passCount = 0;
53 private int $failCount = 0;
54 private int $errorCount = 0;
55
56 // 現在のテストケース情報
57 private string $currentTestCase = '';
58 private string $currentTest = '';
59
60 // テストスイート設定
61 private bool $colorOutput = true;
62 private bool $verboseOutput = true;
63 private bool $stopOnFailure = false;
64 private bool $skipCurrentDescribe = false;
65 private array $beforeEachCallbacks = [];
66 private array $afterEachCallbacks = [];
67 private array $beforeAllCallbacks = [];
68 private array $afterAllCallbacks = [];
69
70 // ターミナル出力用の色
71 private array $colors = [
72 'reset' => "\033[0m",
73 'red' => "\033[31m",
74 'green' => "\033[32m",
75 'yellow' => "\033[33m",
76 'blue' => "\033[34m",
77 'magenta' => "\033[35m",
78 'cyan' => "\033[36m",
79 'white' => "\033[37m",
80 'bold' => "\033[1m",
81 ];
82
83 private array $failures = [];
84 private array $errors = [];
85
86 /**
87 * コンストラクタ
88 *
89 * @param array $options 設定オプション
90 */
91 public function __construct(array $options = []) {
92 // オプションを設定
93 if (isset($options['colorOutput'])) {
94 $this->colorOutput = (bool)$options['colorOutput'];
95 }
96
97 if (isset($options['verboseOutput'])) {
98 $this->verboseOutput = (bool)$options['verboseOutput'];
99 }
100
101 if (isset($options['stopOnFailure'])) {
102 $this->stopOnFailure = (bool)$options['stopOnFailure'];
103 }
104
105 // サポートされていない場合は色を無効にする
106 if (PHP_SAPI !== 'cli' || strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' && !getenv('ANSICON')) {
107 $this->colorOutput = false;
108 }
109 }
110
111 /**
112 * 各テストの前に実行する関数を登録する
113 *
114 * @param callable $callback コールバック関数
115 * @return Tester このインスタンス
116 */
117 public function beforeEach(callable $callback): Tester {
118 $this->beforeEachCallbacks[] = $callback;
119 return $this;
120 }
121
122 /**
123 * 各テストの後に実行する関数を登録する
124 *
125 * @param callable $callback コールバック関数
126 * @return Tester このインスタンス
127 */
128 public function afterEach(callable $callback): Tester {
129 $this->afterEachCallbacks[] = $callback;
130 return $this;
131 }
132
133 /**
134 * すべてのテストの前に実行する関数を登録する
135 *
136 * @param callable $callback コールバック関数
137 * @return Tester このインスタンス
138 */
139 public function beforeAll(callable $callback): Tester {
140 $this->beforeAllCallbacks[] = $callback;
141 return $this;
142 }
143
144 /**
145 * すべてのテストの後に実行する関数を登録する
146 *
147 * @param callable $callback コールバック関数
148 * @return Tester このインスタンス
149 */
150 public function afterAll(callable $callback): Tester {
151 $this->afterAllCallbacks[] = $callback;
152 return $this;
153 }
154
155 /**
156 * テストケースを定義する
157 *
158 * @param string $description テストケースの説明
159 * @param callable $callback テストケース関数
160 * @return Tester このインスタンス
161 */
162 public function describe(string $description, callable $callback): Tester {
163 $this->currentTestCase = $description;
164 $this->skipCurrentDescribe = false;
165 $this->output($this->colorize('bold', "テストケース: {$description}"));
166
167 try {
168 foreach ($this->beforeAllCallbacks as $before) {
169 call_user_func($before);
170 }
171
172 call_user_func($callback, $this);
173
174 foreach ($this->afterAllCallbacks as $after) {
175 call_user_func($after);
176 }
177 } catch (\Throwable $e) {
178 // 以前はerrorCountを増やさなかったので、「全てのテストに合格しました!」と表示された
179 $this->errorCount++;
180 $this->currentTest = '';
181 $this->recordError(
182 "テストケースのセットアップ/ティアダウンでエラー: ".$e->getMessage(),
183 $e->getTraceAsString());
184 }
185
186 $this->output('');
187 return $this;
188 }
189
190 /**
191 * 単一のテストを実行する
192 *
193 * @param string $description テストの説明
194 * @param callable $callback テスト関数
195 * @return Tester このインスタンス
196 */
197 public function it(string $description, callable $callback): Tester {
198 if ($this->skipCurrentDescribe) {
199 $this->skip($description, 'skipAll() が呼び出されました');
200 return $this;
201 }
202
203 $this->currentTest = $description;
204 $this->testCount++;
205
206 if ($this->verboseOutput) {
207 $this->output(" ⋄ テスト中: {$description}... ", false);
208 }
209
210 try {
211 foreach ($this->beforeEachCallbacks as $before) {
212 call_user_func($before);
213 }
214
215 call_user_func($callback, $this);
216
217 foreach ($this->afterEachCallbacks as $after) {
218 call_user_func($after);
219 }
220
221 // Test has passed.
222 $this->passCount++;
223
224 if ($this->verboseOutput) {
225 $this->output($this->colorize('green', "合格"));
226 }
227 } catch (AssertionFailedException $e) {
228 $this->failCount++;
229
230 if ($this->verboseOutput) {
231 $this->output($this->colorize('red', "失敗"));
232 $this->output($this->colorize('red', " → ".$e->getMessage()));
233 }
234
235 $this->recordFailure($e->getMessage());
236
237 if ($this->stopOnFailure) {
238 $this->printSummary();
239 exit(1);
240 }
241 } catch (\Throwable $e) {
242 $this->errorCount++;
243
244 if ($this->verboseOutput) {
245 $this->output($this->colorize('yellow', "エラー"));
246 $this->output($this->colorize('yellow', " → ".$e->getMessage()));
247 }
248
249 $this->recordError($e->getMessage(), $e->getTraceAsString());
250
251 if ($this->stopOnFailure) {
252 $this->printSummary();
253 exit(1);
254 }
255 }
256
257 return $this;
258 }
259
260 /**
261 * テストをスキップする
262 *
263 * @param string $description テストの説明
264 * @param string $reason スキップする理由。デフォルト: "まだ実装されていません"
265 * @return Tester このインスタンス
266 */
267 public function skip(string $description,
268 string $reason = 'まだ実装されていません'): Tester {
269 if ($this->verboseOutput) {
270 $this->output(" ⋄ スキップ: {$description}... "
271 .$this->colorize('cyan', "スキップ"));
272 $this->output($this->colorize('cyan', " → {$reason}"));
273 }
274
275 return $this;
276 }
277
278 /**
279 * 条件がtrueである事をアサートする
280 *
281 * @param bool $condition チェックする条件
282 * @param string $message 失敗時のオプションメッセージ
283 * @throws AssertionFailedException アサーションが失敗した場合
284 * @return Tester このインスタンス
285 */
286 public function assertTrue(bool $condition,
287 string $message = '条件がtrueであることを期待しました'): Tester {
288 if ($condition !== true) {
289 throw new AssertionFailedException($message);
290 }
291
292 return $this;
293 }
294
295 /**
296 * 条件がfalseである事をアサートする
297 *
298 * @param bool $condition チェックする条件
299 * @param string $message 失敗時のオプションメッセージ
300 * @throws AssertionFailedException アサーションが失敗した場合
301 * @return Tester このインスタンス
302 */
303 public function assertFalse(bool $condition,
304 string $message = '条件がfalseであることを期待しました'): Tester {
305 if ($condition !== false) {
306 throw new AssertionFailedException($message);
307 }
308
309 return $this;
310 }
311
312 /**
313 * 二つの値が等しい事をアサートする
314 *
315 * @param mixed $expected 期待値
316 * @param mixed $actual 実際の値
317 * @param string|null $message 失敗時のオプションメッセージ
318 * @throws AssertionFailedException アサーションが失敗した場合
319 * @return Tester このインスタンス
320 */
321 public function assertEquals(mixed $expected, mixed $actual,
322 ?string $message = null): Tester {
323 if ($expected != $actual) {
324 if ($message === null) {
325 $expected = $this->exportValue($expected);
326 $actual = $this->exportValue($actual);
327 $message = "{$expected}を期待しましたが、{$actual}が得られました";
328 }
329
330 throw new AssertionFailedException($message);
331 }
332
333 return $this;
334 }
335
336 /**
337 * 二つの値が同一である事をアサートする
338 *
339 * @param mixed $expected 期待値
340 * @param mixed $actual 実際の値
341 * @param string|null $message 失敗時のオプションメッセージ
342 * @throws AssertionFailedException アサーションが失敗した場合
343 * @return Tester このインスタンス
344 */
345 public function assertSame(mixed $expected, mixed $actual,
346 ?string $message = null): Tester {
347 if ($expected !== $actual) {
348 if ($message === null) {
349 $expected = $this->exportValue($expected);
350 $actual = $this->exportValue($actual);
351 $message =
352 "{$expected}を期待しましたが、{$actual}が得られました(厳密な比較)";
353 }
354
355 throw new AssertionFailedException($message);
356 }
357
358 return $this;
359 }
360
361 /**
362 * 値がnullである事をアサートする
363 *
364 * @param mixed $actual チェックする値
365 * @param string|null $message 失敗時のオプションメッセージ
366 * @throws AssertionFailedException アサーションが失敗した場合
367 * @return Tester このインスタンス
368 */
369 public function assertNull(mixed $actual, ?string $message = null): Tester {
370 if ($actual !== null) {
371 if ($message === null) {
372 $actual = $this->exportValue($actual);
373 $message = "nullを期待しましたが、{$actual}が得られました";
374 }
375
376 throw new AssertionFailedException($message);
377 }
378
379 return $this;
380 }
381
382 /**
383 * 値がnullでない事をアサートする
384 *
385 * @param mixed $actual チェックする値
386 * @param string|null $message 失敗時のオプションメッセージ
387 * @throws AssertionFailedException アサーションが失敗した場合
388 * @return Tester このインスタンス
389 */
390 public function assertNotNull(mixed $actual, ?string $message = null): Tester {
391 if ($actual === null) {
392 if ($message === null) {
393 $message = "値がnullでない事を期待しました";
394 }
395
396 throw new AssertionFailedException($message);
397 }
398
399 return $this;
400 }
401
402 /**
403 * 値が特定のキーを持つ事をアサートする
404 *
405 * @param mixed $key チェックするキー
406 * @param array $array チェックする配列
407 * @param string|null $message 失敗時のオプションメッセージ
408 * @throws AssertionFailedException アサーションが失敗した場合
409 * @return Tester このインスタンス
410 */
411 public function assertArrayHasKey(mixed $key, array $array,
412 ?string $message = null): Tester {
413 if (NULL === $key) throw new AssertionFailedException("配列鍵がありません");
414 if (!is_array($array) && !($array instanceof \ArrayAccess)) {
415 throw new AssertionFailedException(
416 '第2引数は配列又はArrayAccessを実装している必要があります');
417 }
418
419 if (!array_key_exists($key, $array)) {
420 if ($message === null) {
421 $message = "配列がキー '{$key}' を持つ事を期待しました";
422 }
423
424 throw new AssertionFailedException($message);
425 }
426
427 return $this;
428 }
429
430 /**
431 * 文字列がサブ文字列を含むことをアサートする
432 *
433 * @param string $needle 検索するサブ文字列
434 * @param string $haystack 検索対象の文字列
435 * @param string|null $message 失敗時のオプションメッセージ
436 * @throws AssertionFailedException アサーションが失敗した場合
437 * @return Tester このインスタンス
438 */
439 public function assertStringContains(string $needle, string $haystack,
440 ?string $message = null): Tester {
441 if (!is_string($needle) || !is_string($haystack)) {
442 throw new AssertionFailedException('両方の引数は文字列である必要があります');
443 }
444
445 if (strpos($haystack, $needle) === false) {
446 if ($message === null) {
447 $message = "文字列 '{$haystack}' が '{$needle}' を含む事を期待しました";
448 }
449
450 throw new AssertionFailedException($message);
451 }
452
453 return $this;
454 }
455
456 /**
457 * コールバックが例外をスローする事をアサートする
458 *
459 * @param callable $callback 実行するコールバック
460 * @param string $exceptionClass 期待される例外クラス
461 * @param string|null $message 失敗時のオプションメッセージ
462 * @throws AssertionFailedException アサーションが失敗した場合
463 * @return Tester このインスタンス
464 */
465 public function assertThrows(callable $callback, string $exceptionClass,
466 ?string $message = null): Tester {
467 try {
468 call_user_func($callback);
469 } catch (\Throwable $e) {
470 if (!($e instanceof $exceptionClass)) {
471 if ($message === null) {
472 $message = "'{$exceptionClass}' 型の例外を期待しましたが、"
473 .get_class($e)." が得られました";
474 }
475
476 throw new AssertionFailedException($message);
477 }
478
479 return $this;
480 }
481
482 // 以前はtryの中で投げていたので、\Exceptionを期待するとこの例外自体が合格になった
483 if ($message === null) {
484 $message = "'{$exceptionClass}' 型の例外がスローされる事を期待しましたが、スローされませんでした";
485 }
486
487 throw new AssertionFailedException($message);
488 }
489
490 /**
491 * コレクションが指定された要素数を持つ事
492 *
493 * @param int $expoected 期待される要素数
494 * @param iterable $collection 配列または Traversable
495 * @param string|null $message 失敗時のカスタムメッセージ
496 * @return Tester
497 *
498 * @throws AssertionFailedException
499 */
500 public function assertCount(int $expected, iterable $collection, ?string $message = null): Tester {
501 $actual = is_countable($collection) ? count($collection) : iterator_count($collection);
502
503 if ($actual !== $expected) {
504 $message ??= "要素数が {$expected} であることを期待しましたが、{$actual} でした";
505 throw new AssertionFailedException($message);
506 }
507
508 return $this;
509 }
510
511 /**
512 * 現在の describe ブロック内の残りすべての it() をスキップ
513 *
514 * @param string $reason スキップ理由(出力に表示)
515 * @return void
516 */
517 public function skipAll(string $reason = 'このテストケースはスキップされました'): void {
518 $this->skipCurrentDescribe = true;
519
520 if ($this->verboseOutput) {
521 $this->output($this->colorize('cyan', " スキップAll: {$reason}"));
522 }
523 }
524
525 /**
526 * テスト結果の詳細を表示する
527 *
528 * @return Tester
529 */
530 public function printSummary(): Tester {
531 $this->output('');
532 $this->output($this->colorize('bold', "テスト結果の概要:"));
533 $this->output(" テスト総数: {$this->testCount}");
534 $this->output(" ".$this->colorize('green', "合格: {$this->passCount}"));
535 $this->output(" ".$this->colorize('red', "失敗: {$this->failCount}"));
536 $this->output(" ".$this->colorize('yellow', "エラー: {$this->errorCount}"));
537 $this->output('');
538
539 // 失敗を書き出す
540 if (count($this->failures) > 0) {
541 $this->output($this->colorize('bold', "失敗:"));
542
543 foreach ($this->failures as $i => $f) {
544 $num = $i + 1;
545 $this->output(" {$num}) {$f['testCase']} → {$f['test']}");
546 $this->output(" ".$this->colorize('red', $f['message']));
547 $this->output('');
548 }
549 }
550
551 // エラーを書き出す
552 if (count($this->errors) > 0) {
553 $this->output($this->colorize('bold', "エラー:"));
554
555 foreach ($this->errors as $i => $e) {
556 $num = $i + 1;
557 $this->output(" {$num}) {$e['testCase']} → {$e['test']}");
558 $this->output(" ".$this->colorize('yellow', $e['message']));
559
560 if (isset($e['trace'])) {
561 $this->output(" ".$this->colorize('yellow', "スタックトレース:"));
562 $this->output(" ".$this->colorize('yellow', $e['trace']));
563 }
564
565 $this->output('');
566 }
567 }
568
569 if ($this->failCount === 0 && $this->errorCount === 0) {
570 $this->output($this->colorize('green', "全てのテストに合格しました!"));
571 } else {
572 $this->output($this->colorize('red', "テストが失敗・エラーで完了しました。"));
573 }
574
575 return $this;
576 }
577
578 // 機能性メソッド
579
580 /**
581 * コンソールにテキストを出力する
582 *
583 * @param string $text 出力するテキスト
584 * @param bool $newline 改行を追加するかどうか
585 * @return void
586 */
587 private function output(string $text, bool $newline = true): void {
588 echo $text.($newline ? PHP_EOL : '');
589 }
590
591 /**
592 * 失敗もエラーも無かったか(tester.phpがファイル毎の結果に使う)
593 *
594 * @return bool
595 */
596 public function passed(): bool {
597 return $this->failCount === 0 && $this->errorCount === 0;
598 }
599
600 /**
601 * 有効な場合はテキストに色を適用する
602 *
603 * @param string $color 色名
604 * @param string $text 色付けするテキスト
605 * @return string
606 */
607 private function colorize(string $color, string $text): string {
608 if (!$this->colorOutput || !isset($this->colors[$color])) {
609 return $text;
610 }
611
612 return $this->colors[$color].$text.$this->colors['reset'];
613 }
614
615 /**
616 * 値を表示用の文字列としてエクスポートする
617 *
618 * @param mixed $value エクスポートする値
619 * @return string
620 */
621 private function exportValue(mixed $value): string {
622 if (is_null($value)) return 'null';
623 if (is_bool($value)) return $value ? 'true' : 'false';
624 if (is_array($value)) return 'Array('.count($value).')';
625 if (is_object($value)) return get_class($value).' Object';
626
627 if (is_string($value)) {
628 if (strlen($value) > 40) {
629 return "'".substr($value, 0, 37)."...'";
630 }
631
632 return "'{$value}'";
633 }
634
635 return (string)$value;
636 }
637
638 /**
639 * テストの失敗を記録する
640 *
641 * @param string $message 失敗メッセージ
642 * @return void
643 */
644 private function recordFailure(string $message): void {
645 $this->failures[] = [
646 'testCase' => $this->currentTestCase,
647 'test' => $this->currentTest,
648 'message' => $message,
649 ];
650 }
651
652 /**
653 * テストのエラーを記録する
654 *
655 * @param string $message エラーメッセージ
656 * @param string|null $trace スタックトレース
657 * @return void
658 */
659 private function recordError(string $message, ?string $trace = null): void {
660 $this->errors[] = [
661 'testCase' => $this->currentTestCase,
662 'test' => $this->currentTest,
663 'message' => $message,
664 'trace' => $trace,
665 ];
666 }
667}