src/Test.php source

1 <?php
2
3 namespace mini;
4
5 use Composer\Autoload\ClassLoader;
6 use ReflectionClass;
7
8 /**
9 * Base class for structured tests
10 *
11 * Usage:
12 * $test = new class extends mini\Test {
13 * public function testSomething(): void {
14 * $this->assertSame($expected, $actual);
15 * }
16 * };
17 * exit($test->run());
18 *
19 * Test methods must be public and start with "test".
20 * Method names are converted from camelCase to readable output:
21 * testSingletonReturnsSameInstance → "Singleton returns same instance"
22 */
23 abstract class Test
24 {
25 private array $logs = [];
26 private ?string $expectedExceptionClass = null;
27
28 // Output state
29 private bool $isTty;
30 private bool $verbose;
31 private int $passed = 0;
32 private int $failed = 0;
33 private ?string $currentTestName = null;
34 private string $normal = '';
35 private string $white = '';
36 private string $green = '';
37 private string $red = '';
38 private float $startTime = 0;
39 private string $indent = '';
40 /** @var resource|null File descriptor 3 for reporting to test runner */
41 private $runnerPipe = null;
42
43 /**
44 * Run a test file in a subprocess
45 *
46 * @return array{exitCode: int, info: ?array}
47 */
48 public static function runTestFile(string $path): array
49 {
50 $depth = ((int) getenv('MINI_TEST_RUNNER')) + 1;
51
52 // Pass env to child without modifying parent's environment
53 $env = getenv();
54 $env['MINI_TEST_RUNNER'] = (string) $depth;
55
56 $process = proc_open(
57 ['php', '-d', 'zend.assertions=1', '-d', 'assert.exception=1', $path],
58 [STDIN, STDOUT, STDERR, 3 => ['pipe', 'w']],
59 $pipes,
60 null,
61 $env
62 );
63
64 $info = null;
65 if (is_resource($process)) {
66 $json = stream_get_contents($pipes[3]);
67 fclose($pipes[3]);
68 if ($json) {
69 $info = json_decode($json, true);
70 }
71 $exitCode = proc_close($process);
72 } else {
73 $exitCode = 1;
74 }
75
76 return ['exitCode' => $exitCode, 'info' => $info];
77 }
78
79 private function getIndentString(): string
80 {
81 $depth = (int) getenv('MINI_TEST_RUNNER') ?: 0;
82 return str_repeat(' ', 1 + $depth);
83 }
84
85
86 public function run(bool $exit = true): int
87 {
88 $this->isTty = stream_isatty(STDOUT);
89 $this->indent = $this->getIndentString();
90 if ($this->isTty) {
91 $this->normal = "\033[0m";
92 $this->green = "\033[92m";
93 $this->red = "\033[91m";
94 $this->white = "\033[97m";
95 }
96 $this->verbose = in_array('-v', $GLOBALS['argv']) || (bool) getenv('MINI_TEST_RUNNER');
97
98 // Open fd 3 for reporting to test runner if available
99 if (getenv('MINI_TEST_RUNNER')) {
100 $this->runnerPipe = @fopen('php://fd/3', 'w');
101 }
102
103 // Check if test can run (e.g., required extensions)
104 if (!$this->canRun()) {
105 $reason = $this->skipReason();
106 if ($this->verbose) {
107 echo "{$this->indent}{$this->white}[{$this->normal}-{$this->white}]{$this->normal} Skipped" . ($reason ? ": $reason" : "") . "\n";
108 }
109 $this->reportToRunner(['skipped' => true, 'reason' => $reason]);
110 if ($exit) {
111 exit(0); // Skipped tests are not failures
112 }
113 return 0;
114 }
115
116 $this->setUp();
117
118 if (Mini::$mini->phase->getCurrentState() !== Phase::Ready) {
119 \mini\bootstrap();
120 }
121
122 foreach ($this->getTestMethods() as $method) {
123 $this->expectedExceptionClass = null;
124 $this->logs[$method] = [];
125 $name = $this->methodToName($method);
126
127 $this->startTest($name);
128
129 try {
130 $this->$method();
131
132 if ($this->expectedExceptionClass !== null) {
133 throw new \AssertionError("Expected {$this->expectedExceptionClass} to be thrown");
134 }
135
136 $this->endTest(true);
137 } catch (\Throwable $e) {
138 if ($this->expectedExceptionClass !== null && $e instanceof $this->expectedExceptionClass) {
139 $this->endTest(true);
140 } else {
141 $this->endTest(false, $e);
142 }
143 }
144
145 // Show logs (only in standalone mode)
146 if (!$this->verbose) {
147 foreach ($this->logs[$method] as $log) {
148 echo " → $log\n";
149 }
150 }
151 }
152
153 $this->printSummary();
154 $this->reportToRunner([
155 'passed' => $this->passed,
156 'failed' => $this->failed,
157 ]);
158
159 $exitCode = $this->failed > 0 ? 1 : 0;
160 if ($exit) {
161 exit($exitCode);
162 }
163 return $exitCode;
164 }
165
166 /**
167 * Write structured data to the test runner via fd 3
168 */
169 private function reportToRunner(array $data): void
170 {
171 if ($this->runnerPipe) {
172 fwrite($this->runnerPipe, json_encode($data) . "\n");
173 }
174 }
175
176 // ─────────────────────────────────────────────────────────────────────────
177 // Output methods
178 // ─────────────────────────────────────────────────────────────────────────
179
180 private function startTest(string $name): void
181 {
182 $this->startTime = microtime(true);
183 $this->currentTestName = $name;
184
185 if ($this->verbose) {
186 if ($this->isTty) {
187 // Show name, will be cleared on success
188 echo "{$this->indent} {$this->white}]{$this->normal} $name\r{$this->indent}{$this->white}[{$this->normal}";
189 } else {
190 $displayName = strlen($name) >= 50 ? substr($name, 0, 45) . '...' : $name;
191 echo "{$this->indent}" . str_pad($displayName, 50);
192 }
193 }
194 ob_flush();
195 }
196
197 private function endTest(bool $success, ?\Throwable $error = null): void
198 {
199 $name = $this->currentTestName;
200
201 if ($success) {
202 $this->passed++;
203 if ($this->verbose) {
204 if ($this->isTty) {
205 $time = microtime(true) - $this->startTime;
206 if ($time > 3) {
207 if ($time > 1) {
208 $timeStr = number_format($time, 3) . ' s';
209 } else {
210 $timeStr = number_format($time * 1000, 1) . 'ms';
211 }
212 echo "\r{$this->indent}{$this->white}[{$this->green}✓{$this->white}]{$this->normal} $name ({$this->red}" . $timeStr . "{$this->normal})\n";
213 } else {
214 echo "\r\033[2K"; // Clear line
215 }
216 } else {
217 echo "SUCCESS\n";
218 }
219 }
220 } else {
221 $this->failed++;
222 if ($this->verbose) {
223 if ($this->isTty) {
224 echo "\r{$this->indent}{$this->white}[{$this->red}✗{$this->white}]{$this->normal} $name\n";
225 } else {
226 echo "FAIL\n";
227 }
228 } else {
229 echo "{$this->indent}" . $this->currentTestName . " {$this->red}FAIL{$this->normal}\n";
230 }
231 if ($error !== null) {
232 echo rtrim($this->indentText($this->cleanup($error))) . "\n\n";
233 }
234 }
235 }
236
237 private function printSummary(): void
238 {
239 if ($this->verbose) {
240 if ($this->failed > 0) {
241 echo "{$this->indent}✗ {$this->failed} failed, {$this->passed} passed\n";
242 }
243 } else {
244 if ($this->failed === 0) {
245 echo "{$this->indent}✅ All {$this->passed} test(s) passed!\n";
246 } else {
247 $total = $this->passed + $this->failed;
248 echo "{$this->indent}❌ {$this->failed} of $total test(s) failed\n";
249 }
250 }
251 }
252
253 private function indentText(string $text): string {
254 $indent = $this->indent . ' ';
255 return preg_replace('/^/m', $indent, $text);
256 }
257
258 private function cleanup(string $text): string {
259 if (class_exists(ClassLoader::class)) {
260 $rc = new ReflectionClass(ClassLoader::class);
261 $prefixDir = dirname($rc->getFileName(), 3) . '/';
262 } else {
263 $prefixDir = getcwd() . '/';
264 }
265 return str_replace($prefixDir, '', $text);
266 }
267
268 // ─────────────────────────────────────────────────────────────────────────
269 // Lifecycle
270 // ─────────────────────────────────────────────────────────────────────────
271
272 /**
273 * Check if test can run (override to skip based on requirements)
274 *
275 * Use this to check for required extensions, PHP versions, etc.
276 * Return false to skip the entire test class.
277 */
278 protected function canRun(): bool
279 {
280 return true;
281 }
282
283 /**
284 * Reason for skipping (shown when canRun() returns false)
285 */
286 protected function skipReason(): string
287 {
288 return '';
289 }
290
291 protected function setUp(): void {}
292
293 protected function log(string $message): void
294 {
295 if ($this->currentTestName !== null) {
296 $key = array_key_last($this->logs);
297 if ($key !== null) {
298 $this->logs[$key][] = $message;
299 }
300 }
301 }
302
303 // ─────────────────────────────────────────────────────────────────────────
304 // Assertions
305 // ─────────────────────────────────────────────────────────────────────────
306
307 protected function assertTrue(mixed $value, string $message = ''): void
308 {
309 if ($value !== true) {
310 throw new \AssertionError($message ?: "Expected true, got " . $this->export($value));
311 }
312 }
313
314 protected function assertFalse(mixed $value, string $message = ''): void
315 {
316 if ($value !== false) {
317 throw new \AssertionError($message ?: "Expected false, got " . $this->export($value));
318 }
319 }
320
321 protected function assertSame(mixed $expected, mixed $actual, string $message = ''): void
322 {
323 if ($expected !== $actual) {
324 throw new \AssertionError(
325 $message ?: "Expected " . $this->export($expected) . ", got " . $this->export($actual)
326 );
327 }
328 }
329
330 protected function assertEquals(mixed $expected, mixed $actual, string $message = ''): void
331 {
332 if ($expected != $actual) {
333 throw new \AssertionError(
334 $message ?: "Expected " . $this->export($expected) . ", got " . $this->export($actual)
335 );
336 }
337 }
338
339 protected function assertNull(mixed $value, string $message = ''): void
340 {
341 if ($value !== null) {
342 throw new \AssertionError($message ?: "Expected null, got " . $this->export($value));
343 }
344 }
345
346 protected function assertNotNull(mixed $value, string $message = ''): void
347 {
348 if ($value === null) {
349 throw new \AssertionError($message ?: "Expected non-null value");
350 }
351 }
352
353 protected function assertThrows(callable $fn, string $exceptionClass = \Throwable::class, string $message = ''): void
354 {
355 try {
356 $fn();
357 throw new \AssertionError($message ?: "Expected $exceptionClass to be thrown");
358 } catch (\Throwable $e) {
359 if (!$e instanceof $exceptionClass) {
360 throw new \AssertionError(
361 $message ?: "Expected $exceptionClass, got " . get_class($e) . ": " . $e->getMessage()
362 );
363 }
364 }
365 }
366
367 protected function assertContains(string $needle, string $haystack, string $message = ''): void
368 {
369 if (!str_contains($haystack, $needle)) {
370 throw new \AssertionError($message ?: "String does not contain '$needle'");
371 }
372 }
373
374 protected function assertCount(int $expected, array|\Countable $value, string $message = ''): void
375 {
376 $actual = count($value);
377 if ($expected !== $actual) {
378 throw new \AssertionError($message ?: "Expected count $expected, got $actual");
379 }
380 }
381
382 protected function assertInstanceOf(string $class, mixed $value, string $message = ''): void
383 {
384 if (!$value instanceof $class) {
385 $actual = is_object($value) ? get_class($value) : gettype($value);
386 throw new \AssertionError($message ?: "Expected instance of $class, got $actual");
387 }
388 }
389
390 protected function assertArrayHasKey(string|int $key, array|\ArrayAccess $array, string $message = ''): void
391 {
392 $exists = $array instanceof \ArrayAccess
393 ? $array->offsetExists($key)
394 : array_key_exists($key, $array);
395
396 if (!$exists) {
397 throw new \AssertionError($message ?: "Array does not have key '$key'");
398 }
399 }
400
401 protected function assertIsArray(mixed $value, string $message = ''): void
402 {
403 if (!is_array($value)) {
404 throw new \AssertionError($message ?: "Expected array, got " . gettype($value));
405 }
406 }
407
408 protected function assertIsObject(mixed $value, string $message = ''): void
409 {
410 if (!is_object($value)) {
411 throw new \AssertionError($message ?: "Expected object, got " . gettype($value));
412 }
413 }
414
415 protected function assertJson(string $value, string $message = ''): void
416 {
417 json_decode($value);
418 if (json_last_error() !== JSON_ERROR_NONE) {
419 throw new \AssertionError($message ?: "Invalid JSON: " . json_last_error_msg());
420 }
421 }
422
423 protected function assertStringContainsString(string $needle, string $haystack, string $message = ''): void
424 {
425 if (!str_contains($haystack, $needle)) {
426 throw new \AssertionError($message ?: "String does not contain '$needle'");
427 }
428 }
429
430 protected function assertStringNotContainsString(string $needle, string $haystack, string $message = ''): void
431 {
432 if (str_contains($haystack, $needle)) {
433 throw new \AssertionError($message ?: "String unexpectedly contains '$needle'");
434 }
435 }
436
437 protected function assertStringStartsWith(string $prefix, string $string, string $message = ''): void
438 {
439 if (!str_starts_with($string, $prefix)) {
440 throw new \AssertionError($message ?: "String does not start with '$prefix'");
441 }
442 }
443
444 protected function assertStringEndsWith(string $suffix, string $string, string $message = ''): void
445 {
446 if (!str_ends_with($string, $suffix)) {
447 throw new \AssertionError($message ?: "String does not end with '$suffix'");
448 }
449 }
450
451 protected function assertNotSame(mixed $expected, mixed $actual, string $message = ''): void
452 {
453 if ($expected === $actual) {
454 throw new \AssertionError($message ?: "Values are unexpectedly the same");
455 }
456 }
457
458 protected function assertNotEmpty(mixed $value, string $message = ''): void
459 {
460 if (empty($value)) {
461 throw new \AssertionError($message ?: "Value is unexpectedly empty");
462 }
463 }
464
465 protected function assertGreaterThan(mixed $expected, mixed $actual, string $message = ''): void
466 {
467 if ($actual <= $expected) {
468 throw new \AssertionError($message ?: "Expected value greater than $expected, got $actual");
469 }
470 }
471
472 protected function assertLessThan(mixed $expected, mixed $actual, string $message = ''): void
473 {
474 if ($actual >= $expected) {
475 throw new \AssertionError($message ?: "Expected value less than $expected, got $actual");
476 }
477 }
478
479 protected function assertLessThanOrEqual(mixed $expected, mixed $actual, string $message = ''): void
480 {
481 if ($actual > $expected) {
482 throw new \AssertionError($message ?: "Expected value <= $expected, got $actual");
483 }
484 }
485
486 protected function assertGreaterThanOrEqual(mixed $expected, mixed $actual, string $message = ''): void
487 {
488 if ($actual < $expected) {
489 throw new \AssertionError($message ?: "Expected value >= $expected, got $actual");
490 }
491 }
492
493 protected function expectException(string $exceptionClass): void
494 {
495 $this->expectedExceptionClass = $exceptionClass;
496 }
497
498 protected function fail(string $message = 'Test failed'): void
499 {
500 throw new \AssertionError($message);
501 }
502
503 // ─────────────────────────────────────────────────────────────────────────
504 // Helpers
505 // ─────────────────────────────────────────────────────────────────────────
506
507 private function getTestMethods(): array
508 {
509 $methods = [];
510 $reflection = new \ReflectionClass($this);
511
512 foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
513 if (str_starts_with($method->getName(), 'test')) {
514 $methods[] = $method->getName();
515 }
516 }
517
518 return $methods;
519 }
520
521 private function methodToName(string $method): string
522 {
523 $name = substr($method, 4);
524 $name = preg_replace('/([a-z])([A-Z])/', '$1 $2', $name);
525 return ucfirst(strtolower($name));
526 }
527
528 private function export(mixed $value): string
529 {
530 if (is_object($value)) {
531 return get_class($value) . ' object';
532 }
533 return var_export($value, true);
534 }
535 }
536