diff --git a/README.md b/README.md
index 31acf08..6c1bb7c 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,58 @@ self::assertEventually(function () use ($connection) {
});
```
+### Coroutine Runner
+
+`Utopia\Tests\Async\Runner` runs your existing PHPUnit test cases concurrently —
+each test in its own [Swoole](https://www.swoole.com/) coroutine, bounded by a
+pool of N coroutines. While one test waits on coroutine I/O (a channel, a hooked
+socket, `Coroutine::sleep`), another runs, so a suite of slow integration tests
+finishes in roughly the time of its slowest test rather than their sum.
+
+It requires the `swoole` extension.
+
+**Command line:**
+
+```bash
+# Run every *Test.php under tests/, 10 at a time (the default)
+vendor/bin/co-phpunit tests --concurrency=20
+```
+
+**Programmatically:**
+
+```php
+use Utopia\Tests\Async\Runner;
+
+$runner = new Runner(concurrency: 20);
+$runner->addDirectory(__DIR__ . '/tests');
+// ...or queue classes explicitly: $runner->addTestCase(MyTest::class);
+
+exit($runner->run());
+```
+
+Your test classes are plain `PHPUnit\Framework\TestCase`s — `setUp`/`tearDown`,
+`setUpBeforeClass`/`tearDownAfterClass`, `#[DataProvider]`, assertions and
+`markTestSkipped()` all work as usual:
+
+```php
+use PHPUnit\Framework\TestCase;
+use Swoole\Coroutine as Co;
+
+class HealthTest extends TestCase
+{
+ public function testServiceResponds(): void
+ {
+ Co::sleep(0.5); // e.g. an async HTTP call to a service
+ $this->assertTrue(true);
+ }
+}
+```
+
+The runner drives each test's lifecycle directly instead of going through
+PHPUnit's sequential runner, so process-global features (output-buffering
+assertions, global-state isolation, separate-process tests) are out of scope —
+it is built for coroutine-friendly integration tests that assert and skip.
+
## Development
### Run Tests
diff --git a/bin/co-phpunit b/bin/co-phpunit
new file mode 100755
index 0000000..ca2ef2e
--- /dev/null
+++ b/bin/co-phpunit
@@ -0,0 +1,34 @@
+#!/usr/bin/env php
+addDirectory($path);
+}
+
+exit($runner->run());
diff --git a/composer.json b/composer.json
index 41960eb..aeeee4e 100755
--- a/composer.json
+++ b/composer.json
@@ -23,6 +23,10 @@
"require": {
"php": ">=8.3"
},
+ "suggest": {
+ "ext-swoole": "Required for the concurrent coroutine test runner (Utopia\\Tests\\Async\\Runner)"
+ },
+ "bin": ["bin/co-phpunit"],
"require-dev": {
"phpstan/phpstan": "2.0.*",
"phpunit/phpunit": "12.4.*",
diff --git a/composer.lock b/composer.lock
index 662ee49..cba4eef 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "a253448dc201730fab02860e9b1bf27d",
+ "content-hash": "10ce499b8335730e21baf146eeea3756",
"packages": [],
"packages-dev": [
{
@@ -1815,5 +1815,5 @@
"php": ">=8.3"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/phpstan.neon b/phpstan.neon
index 282b269..1bfc7b4 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,3 +1,5 @@
parameters:
+ scanFiles:
+ - stubs/swoole.stub
excludePaths:
- vendor
diff --git a/phpunit.xml b/phpunit.xml
index 93d5f65..6c2c2bd 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,5 +1,6 @@
tests
+ tests/fixtures
diff --git a/src/Tests/Async/Result.php b/src/Tests/Async/Result.php
new file mode 100644
index 0000000..a6a40b6
--- /dev/null
+++ b/src/Tests/Async/Result.php
@@ -0,0 +1,13 @@
+> */
+ private array $classes = [];
+
+ /** @var list */
+ private array $results = [];
+
+ public function __construct(private readonly int $concurrency = 10)
+ {
+ }
+
+ /**
+ * @param class-string $class
+ */
+ public function addTestCase(string $class): self
+ {
+ $this->classes[] = $class;
+
+ return $this;
+ }
+
+ /**
+ * Discover every `*Test.php` under a directory and queue the TestCase classes it declares.
+ */
+ public function addDirectory(string $path): self
+ {
+ $files = new \RegexIterator(
+ new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path)),
+ '/Test\.php$/'
+ );
+
+ foreach ($files as $file) {
+ $before = \get_declared_classes();
+ require_once $file->getPathname();
+
+ foreach (\array_diff(\get_declared_classes(), $before) as $class) {
+ if (\is_subclass_of($class, TestCase::class) && ! (new \ReflectionClass($class))->isAbstract()) {
+ $this->classes[] = $class;
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Run every queued test concurrently and report the outcome.
+ *
+ * @return int 0 when all tests passed, 1 otherwise (suitable for `exit()`)
+ */
+ public function run(): int
+ {
+ $this->bootstrap();
+
+ // Make blocking calls (sleep, sockets, file & DB I/O, ...) yield to the
+ // scheduler instead of stalling it, so tests genuinely overlap.
+ \Swoole\Runtime::enableCoroutine(\SWOOLE_HOOK_ALL);
+
+ $cases = [];
+ foreach ($this->classes as $class) {
+ $class::setUpBeforeClass();
+ foreach ($this->cases($class) as $case) {
+ $cases[] = $case;
+ }
+ }
+
+ coroutineRun(function () use ($cases) {
+ $pool = new Channel($this->concurrency);
+ $group = new WaitGroup();
+
+ foreach ($cases as $case) {
+ $pool->push(true);
+ $group->add();
+
+ Coroutine::create(function () use ($case, $pool, $group) {
+ $this->results[] = $this->execute(...$case);
+ $pool->pop();
+ $group->done();
+ });
+ }
+
+ $group->wait();
+ });
+
+ foreach (\array_unique($this->classes) as $class) {
+ $class::tearDownAfterClass();
+ }
+
+ return $this->report();
+ }
+
+ /**
+ * Initialise the PHPUnit configuration singleton that assertions rely on for
+ * failure diffs. Normally done by PHPUnit's CLI; we are the CLI here.
+ */
+ private function bootstrap(): void
+ {
+ Registry::init((new Builder())->fromParameters([]), DefaultConfiguration::create());
+ }
+
+ /**
+ * Expand a TestCase class into its individual runnable cases, one per data set.
+ *
+ * @param class-string $class
+ * @return list, string, string, array}> [class, method, label, args]
+ */
+ private function cases(string $class): array
+ {
+ $cases = [];
+
+ foreach ((new \ReflectionClass($class))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
+ $name = $method->getName();
+
+ if ($method->isStatic() || $method->getDeclaringClass()->isAbstract()) {
+ continue;
+ }
+
+ if (! \str_starts_with($name, 'test') && $method->getAttributes(Test::class) === []) {
+ continue;
+ }
+
+ $datasets = $this->datasets($class, $method);
+
+ if ($datasets === null) {
+ $cases[] = [$class, $name, "{$class}::{$name}", []];
+
+ continue;
+ }
+
+ foreach ($datasets as $label => $args) {
+ $cases[] = [$class, $name, "{$class}::{$name}#{$label}", $args];
+ }
+ }
+
+ return $cases;
+ }
+
+ /**
+ * Resolve the rows supplied by any #[DataProvider] attributes, or null when there are none.
+ *
+ * @param class-string $class
+ * @return array>|null
+ */
+ private function datasets(string $class, \ReflectionMethod $method): ?array
+ {
+ $attributes = $method->getAttributes(DataProvider::class);
+
+ if ($attributes === []) {
+ return null;
+ }
+
+ $rows = [];
+ foreach ($attributes as $attribute) {
+ $provider = $attribute->newInstance()->methodName();
+ /** @var iterable> $yielded */
+ $yielded = $class::$provider();
+ foreach ($yielded as $key => $row) {
+ $rows[$key] = $row;
+ }
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Run a single test case in isolation and capture its outcome.
+ *
+ * @param class-string $class
+ * @param array $args
+ */
+ private function execute(string $class, string $method, string $label, array $args): Result
+ {
+ $test = new $class($method);
+
+ $invoke = \Closure::bind(function () use ($method, $args) {
+ $this->setUp();
+ try {
+ $this->{$method}(...$args);
+ } finally {
+ $this->tearDown();
+ }
+ }, $test, $class);
+
+ try {
+ $invoke();
+
+ return new Result($label, Status::Passed);
+ } catch (SkippedTest|IncompleteTest $e) {
+ return new Result($label, Status::Skipped, $e->getMessage());
+ } catch (AssertionFailedError $e) {
+ return new Result($label, Status::Failed, $e->getMessage());
+ } catch (\Throwable $e) {
+ return new Result($label, Status::Errored, $e::class.': '.$e->getMessage());
+ }
+ }
+
+ /**
+ * Print results and return the process exit code.
+ */
+ private function report(): int
+ {
+ $counts = [Status::Passed->value => 0, Status::Failed->value => 0, Status::Errored->value => 0, Status::Skipped->value => 0];
+
+ foreach ($this->results as $result) {
+ echo $result->status->symbol();
+ $counts[$result->status->value]++;
+ }
+
+ echo "\n\n";
+
+ foreach ($this->results as $result) {
+ if ($result->status === Status::Failed || $result->status === Status::Errored) {
+ echo "{$result->status->symbol()} {$result->name}\n {$result->message}\n";
+ }
+ }
+
+ \printf(
+ "\nTests: %d, Assertions: %d, Failures: %d, Errors: %d, Skipped: %d\n",
+ \count($this->results),
+ Assert::getCount(),
+ $counts[Status::Failed->value],
+ $counts[Status::Errored->value],
+ $counts[Status::Skipped->value],
+ );
+
+ return $counts[Status::Failed->value] + $counts[Status::Errored->value] === 0 ? 0 : 1;
+ }
+}
diff --git a/src/Tests/Async/Status.php b/src/Tests/Async/Status.php
new file mode 100644
index 0000000..0a2772b
--- /dev/null
+++ b/src/Tests/Async/Status.php
@@ -0,0 +1,21 @@
+ '.',
+ self::Failed => 'F',
+ self::Errored => 'E',
+ self::Skipped => 'S',
+ };
+ }
+}
diff --git a/src/Tests/Async/TestCase.php b/src/Tests/Async/TestCase.php
new file mode 100644
index 0000000..4171037
--- /dev/null
+++ b/src/Tests/Async/TestCase.php
@@ -0,0 +1,19 @@
+markTestSkipped('The swoole extension is required for the coroutine runner.');
+ }
+ }
+
+ public function testReportsEachOutcome(): void
+ {
+ [$exit, $output] = $this->runFixtures(concurrency: 10);
+
+ // 3 plain + 2 data-set + 1 skip + 1 fail + 1 error = 8 cases.
+ $this->assertMatchesRegularExpression('/Tests: 8, Assertions: \d+, Failures: 1, Errors: 1, Skipped: 1/', $output);
+ $this->assertStringContainsString('SampleTest::testFails', $output);
+ $this->assertStringContainsString('SampleTest::testErrors', $output);
+ $this->assertStringContainsString('RuntimeException: boom', $output);
+ $this->assertSame(1, $exit, 'a failing/erroring run must exit non-zero');
+ }
+
+ public function testRunsConcurrently(): void
+ {
+ // Six tests each sleep 0.2s. Serial ~1.2s; fully concurrent ~0.2s.
+ [, , $serial] = $this->runFixtures(concurrency: 1);
+ [, , $concurrent] = $this->runFixtures(concurrency: 10);
+
+ $this->assertGreaterThan(2 * $concurrent, $serial, 'coroutines should overlap their waits');
+ }
+
+ /**
+ * @return array{int, string, float} [exit code, output, elapsed milliseconds]
+ */
+ private function runFixtures(int $concurrency): array
+ {
+ $command = \sprintf(
+ 'php -d xdebug.mode=off %s %s --concurrency=%d 2>&1',
+ \escapeshellarg(\dirname(__DIR__).'/bin/co-phpunit'),
+ \escapeshellarg(\dirname(__DIR__).'/tests/fixtures'),
+ $concurrency,
+ );
+
+ $start = \microtime(true);
+ $output = (string) \shell_exec($command.'; echo "EXIT:$?"');
+ $elapsed = (\microtime(true) - $start) * 1000;
+
+ \preg_match('/EXIT:(\d+)/', $output, $matches);
+
+ return [(int) ($matches[1] ?? -1), $output, $elapsed];
+ }
+}
diff --git a/tests/fixtures/SampleTest.php b/tests/fixtures/SampleTest.php
new file mode 100644
index 0000000..fbf45b4
--- /dev/null
+++ b/tests/fixtures/SampleTest.php
@@ -0,0 +1,70 @@
+= the number of
+ * tests they overlap: wall-clock stays near a single sleep, not their sum.
+ */
+class SampleTest extends TestCase
+{
+ public function testPassesAfterYield(): void
+ {
+ Co::sleep(0.2);
+ $this->assertGreaterThanOrEqual(0, Co::getCid());
+ }
+
+ public function testRunsInsideCoroutine(): void
+ {
+ Co::sleep(0.2);
+ $this->assertGreaterThan(0, Co::getCid());
+ }
+
+ #[DataProvider('numbers')]
+ public function testDataProvider(int $n): void
+ {
+ Co::sleep(0.2);
+ $this->assertGreaterThan(0, $n);
+ }
+
+ /**
+ * @return array
+ */
+ public static function numbers(): array
+ {
+ return ['one' => [1], 'two' => [2]];
+ }
+
+ public function testSkips(): void
+ {
+ $this->markTestSkipped('not today');
+ }
+
+ public function testFails(): void
+ {
+ Co::sleep(0.2);
+ $this->assertSame(1, 2);
+ }
+
+ public function testErrors(): void
+ {
+ throw new \RuntimeException('boom');
+ }
+
+ public function testEventuallyConverges(): void
+ {
+ // assertEventually comes from the Async base class; its usleep polling
+ // yields under the runner's hook-all instead of blocking the scheduler.
+ $start = \microtime(true);
+
+ self::assertEventually(function () use ($start) {
+ $this->assertGreaterThan(0.1, \microtime(true) - $start);
+ }, timeoutMs: 2000, waitMs: 50);
+ }
+}