diff --git a/.github/workflows/isolated-tests.yml b/.github/workflows/isolated-tests.yml
index 3cf644085f..d7af87adf5 100644
--- a/.github/workflows/isolated-tests.yml
+++ b/.github/workflows/isolated-tests.yml
@@ -33,7 +33,7 @@ jobs:
outputs:
matrix: ${{ steps.get_json.outputs.json }}
- phpunit:
+ tests:
runs-on: ${{ matrix.os }}
needs: get_packages
strategy:
@@ -79,5 +79,6 @@ jobs:
cd "packages/${{ matrix.package.basename }}"
composer update --${{ matrix.stability }} --prefer-dist --no-interaction --ignore-platform-reqs
- - name: Execute tests
- run: phpunit -c "packages/${{ matrix.package.basename }}/phpunit.xml"
+ - name: Execute PHPUnit tests
+ run: |
+ [ ! -f "packages/${{ matrix.package.basename }}/phpunit.xml" ] || phpunit -c "packages/${{ matrix.package.basename }}/phpunit.xml"
\ No newline at end of file
diff --git a/bin/validate-packages b/bin/validate-packages
index 4897800e16..cf7a680e4f 100755
--- a/bin/validate-packages
+++ b/bin/validate-packages
@@ -48,7 +48,7 @@ function checkPackage(array $package): void
{
checkPackageFile($package, '.gitattributes');
checkPackageFile($package, 'composer.json');
- checkPackageFile($package, 'phpunit.xml');
+// checkPackageFile($package, 'phpunit.xml');
// TODO: Issue #426
// checkPackageFile($package, 'README.md');
checkPackageLicense($package);
diff --git a/composer.json b/composer.json
index 907d7aa43a..9428884dac 100644
--- a/composer.json
+++ b/composer.json
@@ -28,11 +28,12 @@
"psr-discovery/http-client-implementations": "^1.4",
"psr-discovery/http-factory-implementations": "^1.2",
"psr/cache": "^3.0",
- "psr/clock": "^1.0.0",
- "psr/http-client": "^1.0.0",
- "psr/http-factory": "^1.0",
- "psr/http-message": "^1.0|^2.0",
- "psr/log": "^3.0.0",
+ "psr/clock": "^1.0",
+ "psr/container": "^2.0",
+ "psr/http-client": "^1.0",
+ "psr/http-factory": "^1.1",
+ "psr/http-message": "^2.0",
+ "psr/log": "^3.0",
"rector/rector": "^2.2.5",
"symfony/cache": "^7.3",
"symfony/mailer": "^7.2.6",
@@ -118,6 +119,7 @@
"tempest/router": "self.version",
"tempest/storage": "self.version",
"tempest/support": "self.version",
+ "tempest/testing": "self.version",
"tempest/upgrade": "self.version",
"tempest/validation": "self.version",
"tempest/view": "self.version",
@@ -159,6 +161,7 @@
"Tempest\\Router\\": "packages/router/src",
"Tempest\\Storage\\": "packages/storage/src",
"Tempest\\Support\\": "packages/support/src",
+ "Tempest\\Testing\\": "packages/testing/src",
"Tempest\\Upgrade\\": "packages/upgrade/src",
"Tempest\\Validation\\": "packages/validation/src",
"Tempest\\View\\": "packages/view/src",
@@ -196,6 +199,7 @@
"packages/support/src/Str/functions.php",
"packages/support/src/Uri/functions.php",
"packages/support/src/functions.php",
+ "packages/testing/src/functions.php",
"packages/view/src/functions.php",
"packages/vite/src/functions.php"
]
@@ -227,6 +231,7 @@
"Tempest\\Router\\Tests\\": "packages/router/tests",
"Tempest\\Storage\\Tests\\": "packages/storage/tests",
"Tempest\\Support\\Tests\\": "packages/support/tests",
+ "Tempest\\Testing\\Tests\\": "packages/testing/tests",
"Tempest\\Upgrade\\Tests\\": "packages/upgrade/tests",
"Tempest\\Validation\\Tests\\": "packages/validation/tests",
"Tempest\\View\\Tests\\": "packages/view/tests",
@@ -248,7 +253,7 @@
"fmt": "vendor/bin/mago fmt",
"lint:fix": "vendor/bin/mago lint --fix --format-after-fix",
"style": "composer fmt && composer lint:fix",
- "test": "composer phpunit",
+ "test": "@php tempest test && composer phpunit",
"lint": "vendor/bin/mago lint --potentially-unsafe --minimum-fail-level=note",
"phpstan": "vendor/bin/phpstan analyse src tests --memory-limit=1G",
"rector": "vendor/bin/rector process --no-ansi",
@@ -266,7 +271,7 @@
"composer rector",
"./bin/validate-packages",
"./tempest discovery:clear --no-interaction",
- "composer phpunit",
+ "composer test",
"composer phpstan"
]
}
diff --git a/mago.toml b/mago.toml
index 8b029a6e17..694818869d 100644
--- a/mago.toml
+++ b/mago.toml
@@ -13,6 +13,7 @@ excludes = [
"**/*.stub.php",
"**/*.input.php",
"**/*.expected.php",
+ "packages/testing/tests/ProviderTest.php",
]
[formatter]
diff --git a/packages/clock/composer.json b/packages/clock/composer.json
index d3411aa199..f42ba96512 100644
--- a/packages/clock/composer.json
+++ b/packages/clock/composer.json
@@ -3,7 +3,7 @@
"description": "A clock component that handle few simple clock operations.",
"require": {
"php": "^8.5",
- "psr/clock": "^1.0.0",
+ "psr/clock": "^1.0",
"tempest/datetime": "dev-main"
},
"autoload": {
diff --git a/packages/http-client/composer.json b/packages/http-client/composer.json
index 62016d3a7b..28a2ad8779 100644
--- a/packages/http-client/composer.json
+++ b/packages/http-client/composer.json
@@ -5,8 +5,8 @@
"minimum-stability": "dev",
"require": {
"php": "^8.5",
- "psr/http-client": "^1.0.0",
- "psr/http-message": "^1.0|^2.0",
+ "psr/http-client": "^1.0",
+ "psr/http-message": "^2.0",
"tempest/container": "dev-main",
"tempest/http": "dev-main",
"tempest/router": "dev-main",
diff --git a/packages/http/composer.json b/packages/http/composer.json
index cdb45d1b03..404f132bb5 100644
--- a/packages/http/composer.json
+++ b/packages/http/composer.json
@@ -12,8 +12,8 @@
"tempest/container": "dev-main",
"tempest/cryptography": "dev-main",
"laminas/laminas-diactoros": "^3.3",
- "psr/http-factory": "^1.0",
- "psr/http-message": "^1.0|^2.0",
+ "psr/http-factory": "^1.1",
+ "psr/http-message": "^2.0",
"symfony/uid": "^7.1"
},
"autoload": {
diff --git a/packages/log/composer.json b/packages/log/composer.json
index 8453b6e447..099b7bcdba 100644
--- a/packages/log/composer.json
+++ b/packages/log/composer.json
@@ -6,7 +6,7 @@
"require": {
"php": "^8.5",
"monolog/monolog": "^3.7.0",
- "psr/log": "^3.0.0",
+ "psr/log": "^3.0",
"tempest/container": "dev-main"
},
"autoload": {
diff --git a/packages/testing/.gitattributes b/packages/testing/.gitattributes
new file mode 100644
index 0000000000..3f7775660b
--- /dev/null
+++ b/packages/testing/.gitattributes
@@ -0,0 +1,14 @@
+# Exclude build/test files from the release
+.github/ export-ignore
+tests/ export-ignore
+.gitattributes export-ignore
+.gitignore export-ignore
+phpunit.xml export-ignore
+README.md export-ignore
+
+# Configure diff output
+*.view.php diff=html
+*.php diff=php
+*.css diff=css
+*.html diff=html
+*.md diff=markdown
diff --git a/packages/testing/LICENSE.md b/packages/testing/LICENSE.md
new file mode 100644
index 0000000000..54215b7261
--- /dev/null
+++ b/packages/testing/LICENSE.md
@@ -0,0 +1,9 @@
+The MIT License (MIT)
+
+Copyright (c) 2024 Brent Roose brendt@stitcher.io
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/packages/testing/README.md b/packages/testing/README.md
new file mode 100644
index 0000000000..ca0a5f081c
--- /dev/null
+++ b/packages/testing/README.md
@@ -0,0 +1,141 @@
+## A parallel test runner for modern PHP
+
+This package is an experiment in rethinking testing for PHP. It's not intended for use in real-life projects. Some of the core ideas behind this package:
+
+### ✅ A fluent testing API
+
+```php
+use Tempest\Testing\Test;
+
+final class ArrayTest
+{
+ #[Test]
+ public function forget_keys_mutates_array(): void
+ {
+ $original = [
+ 'foo' => 'bar',
+ 'baz' => 'qux',
+ ];
+
+ Arr\forget_keys($original, ['foo']);
+
+ test($original)
+ ->hasCount(1)
+ ->hasKey('baz')
+ ->hasNoKey('foo');
+ }
+}
+```
+
+### ✅ Dependency injection support
+
+Use Tempest's container or any PSR-11 compatible one to inject dependencies into your tests.
+
+```php
+use Tempest\Testing\Test;
+
+final class BookTest
+{
+ public function __construct(
+ private Database $database,
+ ) {}
+
+ #[Test]
+ public function book_can_be_created(BookRepository $repository): void
+ {
+ // …
+ }
+}
+```
+
+```php
+use Tempest\Container\Initializer;
+use Tempest\Container\Singleton;
+use Tempest\Testing\Actions\RunTest;
+
+final class RunTestInitializer implements Initializer
+{
+ #[Singleton]
+ public function initialize(Container $container): RunTest
+ {
+ return new RunTest(container: new YourOwnContainer());
+ }
+}
+```
+
+### ✅ Parallel execution
+
+Parallel execution by default instead of an afterthought.
+
+### ✅ Immediate output
+
+Get immediate feedback on test failures while running them.
+
+```console
+× // Tempest\Testing\Tests\TestFoo::a
+ // /Dev/tempest-testing/tests/TestFoo.php:13
+ // failed asserting that true is false
+
+× // Tempest\Testing\Tests\TestFoo::b
+ // /Dev/tempest-testing/tests/TestFoo.php:20
+ // failed asserting that true is false
+
+× // Tempest\Testing\Tests\TestFoo::e
+ // /Dev/tempest-testing/tests/TestFoo.php:39
+ // failed asserting that true is false
+
+ 2 succeeded 3 failed 0 skipped 0.12s
+```
+
+### ✅ Compose tests however you like
+
+```php
+final class ApplicationTest
+{
+ use TestsEvents, TestsDatbase, TestsHttp;
+
+ #[Test]
+ public function test_before(): void
+ {
+ $this->events
+ ->preventPropagation();
+
+ $this->http
+ ->post('/books', ['title' => 'Timeline Taxi'])
+ ->assertRedirectTo('/books/timeline-taxi');
+
+ $this->database
+ ->assertContains('books', ['title' => 'Timeline Taxi']);
+ }
+}
+```
+
+### ✅ Tempest's no-config approach
+
+Structure your tests however you like: in a separate dev namespaces or alongside your production code. Tempest's discovery will find them for you without any configuration on your part.
+
+### ✅ Extensible event-driven architecture
+
+Anything that happens during tests is easy to hook into with your own event listeners.
+
+```php
+use Tempest\Container\Singleton;
+use Tempest\Console\HasConsole;
+use Tempest\EventBus\EventHandler;
+use Tempest\Testing\Events\TestFailed;
+
+#[Singleton]
+final class TestEventListeners
+{
+ use HasConsole;
+
+ #[EventHandler]
+ public function onTestFailed(TestFailed $event): void
+ {
+ $this->error(sprintf('', $event->name));
+ $this->writeln(sprintf(' ', $event->location));
+ $this->writeln(sprintf(' ', $event->reason));
+ $this->writeln();
+ }
+}
+```
\ No newline at end of file
diff --git a/packages/testing/composer.json b/packages/testing/composer.json
new file mode 100644
index 0000000000..88db55f834
--- /dev/null
+++ b/packages/testing/composer.json
@@ -0,0 +1,27 @@
+{
+ "name": "tempest/testing",
+ "description": "A parallel test runner for modern PHP",
+ "license": "MIT",
+ "minimum-stability": "dev",
+ "require": {
+ "php": "^8.5",
+ "tempest/core": "dev-main",
+ "tempest/event-bus": "dev-main",
+ "tempest/console": "dev-main",
+ "symfony/process": "^7.3",
+ "psr/container": "^2.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Tempest\\Testing\\": "src"
+ },
+ "files": [
+ "src/functions.php"
+ ]
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Tempest\\Testing\\Tests\\": "tests"
+ }
+ }
+}
diff --git a/packages/testing/src/Actions/ChunkAndRunTests.php b/packages/testing/src/Actions/ChunkAndRunTests.php
new file mode 100644
index 0000000000..41b644ff42
--- /dev/null
+++ b/packages/testing/src/Actions/ChunkAndRunTests.php
@@ -0,0 +1,31 @@
+count() / $processes);
+
+ $tests = $tests
+ ->chunk($chunks)
+ ->map(fn (ImmutableArray $tests, int $i) => new TestRunner($i)->run($tests));
+
+ event(new TestsChunked($tests->count()));
+
+ event(new TestRunStarted());
+
+ $tests->map(fn (TestRunner $runner) => $runner->wait());
+
+ event(new TestRunEnded());
+ }
+}
diff --git a/packages/testing/src/Actions/RunTest.php b/packages/testing/src/Actions/RunTest.php
new file mode 100644
index 0000000000..64b6c88ca9
--- /dev/null
+++ b/packages/testing/src/Actions/RunTest.php
@@ -0,0 +1,127 @@
+getInstance($test);
+
+ $providedData = [];
+
+ foreach ($test->provide ?? [[]] as $provider) {
+ if (is_array($provider)) {
+ $providedData[] = $provider;
+ continue;
+ }
+
+ if (is_string($provider)) {
+ if (! method_exists($instance, $provider)) {
+ throw InvalidProviderData::invalidMethodName($test, $provider);
+ }
+
+ $provider = $instance->{$provider}(...);
+ }
+
+ if (is_callable($provider)) {
+ // TODO: add DI here as well?
+ $provider = $provider();
+ }
+
+ if (is_iterable($provider)) {
+ $providedData = [...$providedData, ...iterator_to_array($provider)];
+ }
+ }
+
+ foreach ($providedData as $data) {
+ $this->runEntry($test, $instance, $data);
+ }
+ }
+
+ private function runEntry(Test $test, object $instance, array $data): void
+ {
+ event(new TestStarted($test->name));
+
+ try {
+ $this->runBefore($test, $instance);
+
+ $this->callMethod($instance, $test->handler, $data);
+
+ $this->runAfter($test, $instance);
+
+ event(new TestSucceeded($test->name));
+ } catch (TestHasFailed $exception) {
+ $this->runAfter($test, $instance);
+
+ event(TestFailed::fromException($test->name, $exception));
+ }
+
+ event(new TestFinished($test->name));
+ }
+
+ private function runBefore(Test $test, object $instance): void
+ {
+ foreach ($test->before as $before) {
+ $this->callMethod($instance, $before);
+
+ event(new TestBeforeExecuted($test, $before));
+ }
+ }
+
+ private function runAfter(Test $test, object $instance): void
+ {
+ foreach ($test->after as $after) {
+ $this->callMethod($instance, $after);
+
+ event(new TestAfterExecuted($test, $after));
+ }
+ }
+
+ private function getInstance(Test $test): object
+ {
+ return $this->container->get($test->handler->getDeclaringClass()->getName());
+ }
+
+ private function callMethod(object $instance, MethodReflector $method, array $data = []): void
+ {
+ foreach ($method->getParameters() as $parameter) {
+ if (isset($data[$parameter->getName()])) {
+ continue;
+ }
+
+ if ($parameter->hasDefaultValue()) {
+ continue;
+ }
+
+ if ($parameter->getType()->isScalar()) {
+ continue;
+ }
+
+ $data[$parameter->getName()] = $this->container->get($parameter->getType()->getName());
+ }
+
+ $instance->{$method->getName()}(...$data);
+ }
+}
diff --git a/packages/testing/src/After.php b/packages/testing/src/After.php
new file mode 100644
index 0000000000..8c7fda33ee
--- /dev/null
+++ b/packages/testing/src/After.php
@@ -0,0 +1,10 @@
+tests[] = $test;
+
+ return $this;
+ }
+}
diff --git a/packages/testing/src/Config/tests.config.php b/packages/testing/src/Config/tests.config.php
new file mode 100644
index 0000000000..e1b0300a63
--- /dev/null
+++ b/packages/testing/src/Config/tests.config.php
@@ -0,0 +1,5 @@
+getTests($filter),
+ processes: $processes,
+ );
+ }
+
+ private function getTests(?string $filter): ImmutableArray
+ {
+ $tests = arr($this->testConfig->tests);
+
+ if (! $filter) {
+ return $tests;
+ }
+
+ return $tests->filter(function (Test $test) use ($filter) {
+ if (! $test->matchesFilter($filter)) {
+ event(new TestSkipped($test->name));
+ return false;
+ }
+
+ return true;
+ });
+ }
+}
diff --git a/packages/testing/src/Console/TestRunCommand.php b/packages/testing/src/Console/TestRunCommand.php
new file mode 100644
index 0000000000..a776080866
--- /dev/null
+++ b/packages/testing/src/Console/TestRunCommand.php
@@ -0,0 +1,42 @@
+eventBusConfig->middleware->add(DispatchToParentProcessMiddleware::class);
+
+ foreach ($tests as $testName) {
+ try {
+ $test = Test::fromName($testName);
+ } catch (ReflectionException) {
+ // Reflection Error, skipping, probably need to provide an error
+
+ continue;
+ }
+
+ ($this->runTest)($test);
+ }
+ }
+}
diff --git a/packages/testing/src/Console/WithDiscoveredTestsMiddleware.php b/packages/testing/src/Console/WithDiscoveredTestsMiddleware.php
new file mode 100644
index 0000000000..e9a672a5cc
--- /dev/null
+++ b/packages/testing/src/Console/WithDiscoveredTestsMiddleware.php
@@ -0,0 +1,39 @@
+container->invoke(
+ LoadDiscoveryClasses::class,
+ discoveryClasses: [
+ TestDiscovery::class,
+ ],
+ discoveryLocations: arr($this->composer->devNamespaces)
+ ->map(fn (Psr4Namespace $namespace) => DiscoveryLocation::fromNamespace($namespace))
+ ->toArray(),
+ );
+
+ return $next($invocation);
+ }
+}
diff --git a/packages/testing/src/Discovery/TestDiscovery.php b/packages/testing/src/Discovery/TestDiscovery.php
new file mode 100644
index 0000000000..f06781aeb5
--- /dev/null
+++ b/packages/testing/src/Discovery/TestDiscovery.php
@@ -0,0 +1,41 @@
+getPublicMethods() as $method) {
+ if ($method->hasAttribute(Test::class)) {
+ $this->discoveryItems->add(
+ $location,
+ Test::fromReflector($method),
+ );
+ }
+ }
+ }
+
+ public function apply(): void
+ {
+ /** @var Test $test */
+ foreach ($this->discoveryItems as $test) {
+ $this->testConfig->addTest($test);
+ }
+ }
+}
diff --git a/packages/testing/src/Events/DispatchToParentProcess.php b/packages/testing/src/Events/DispatchToParentProcess.php
new file mode 100644
index 0000000000..99d71bf92b
--- /dev/null
+++ b/packages/testing/src/Events/DispatchToParentProcess.php
@@ -0,0 +1,10 @@
+ $event::class,
+ 'data' => $event->serialize(),
+ ]);
+
+ $this->writeln('[EVENT] ' . $payload);
+
+ return;
+ }
+
+ $next($event);
+ }
+}
diff --git a/packages/testing/src/Events/TestAfterExecuted.php b/packages/testing/src/Events/TestAfterExecuted.php
new file mode 100644
index 0000000000..a358a932b0
--- /dev/null
+++ b/packages/testing/src/Events/TestAfterExecuted.php
@@ -0,0 +1,16 @@
+ new TeamcityMessage(
+ TeamcityMessageName::TEST_FAILED,
+ [
+ 'name' => $this->name,
+ 'message' => $this->reason,
+ 'details' => $this->location,
+ ],
+ );
+ }
+
+ public static function fromException(string $name, TestHasFailed $exception): self
+ {
+ return new self(
+ name: $name,
+ reason: $exception->reason,
+ location: $exception->location,
+ );
+ }
+
+ public function serialize(): array
+ {
+ return [
+ 'name' => $this->name,
+ 'reason' => $this->reason,
+ 'location' => $this->location,
+ ];
+ }
+
+ public static function deserialize(array $data): DispatchToParentProcess
+ {
+ return new self(
+ name: $data['name'],
+ reason: $data['reason'],
+ location: $data['location'],
+ );
+ }
+}
diff --git a/packages/testing/src/Events/TestFinished.php b/packages/testing/src/Events/TestFinished.php
new file mode 100644
index 0000000000..baa7145513
--- /dev/null
+++ b/packages/testing/src/Events/TestFinished.php
@@ -0,0 +1,39 @@
+ new TeamcityMessage(
+ TeamcityMessageName::TEST_FINISHED,
+ [
+ 'name' => $this->name,
+ ],
+ );
+ }
+
+ public function serialize(): array
+ {
+ return [
+ 'name' => $this->name,
+ ];
+ }
+
+ public static function deserialize(array $data): DispatchToParentProcess
+ {
+ return new self(
+ name: $data['name'],
+ );
+ }
+}
diff --git a/packages/testing/src/Events/TestRunEnded.php b/packages/testing/src/Events/TestRunEnded.php
new file mode 100644
index 0000000000..af1d063163
--- /dev/null
+++ b/packages/testing/src/Events/TestRunEnded.php
@@ -0,0 +1,21 @@
+ new TeamcityMessage(
+ TeamcityMessageName::TEST_SWEET_FINISHED,
+ [
+ 'name' => 'Default',
+ ],
+ );
+ }
+}
diff --git a/packages/testing/src/Events/TestRunStarted.php b/packages/testing/src/Events/TestRunStarted.php
new file mode 100644
index 0000000000..0cab50ac12
--- /dev/null
+++ b/packages/testing/src/Events/TestRunStarted.php
@@ -0,0 +1,21 @@
+ new TeamcityMessage(
+ TeamcityMessageName::TEST_SWEET_STARTED,
+ [
+ 'name' => 'Default',
+ ],
+ );
+ }
+}
diff --git a/packages/testing/src/Events/TestSkipped.php b/packages/testing/src/Events/TestSkipped.php
new file mode 100644
index 0000000000..3524ca722a
--- /dev/null
+++ b/packages/testing/src/Events/TestSkipped.php
@@ -0,0 +1,25 @@
+ new TeamcityMessage(
+ TeamcityMessageName::TEST_IGNORED,
+ [
+ 'name' => $this->name,
+ ],
+ );
+ }
+}
diff --git a/packages/testing/src/Events/TestStarted.php b/packages/testing/src/Events/TestStarted.php
new file mode 100644
index 0000000000..151ea98b5e
--- /dev/null
+++ b/packages/testing/src/Events/TestStarted.php
@@ -0,0 +1,39 @@
+ new TeamcityMessage(
+ TeamcityMessageName::TEST_STARTED,
+ [
+ 'name' => $this->name,
+ ],
+ );
+ }
+
+ public function serialize(): array
+ {
+ return [
+ 'name' => $this->name,
+ ];
+ }
+
+ public static function deserialize(array $data): DispatchToParentProcess
+ {
+ return new self(
+ name: $data['name'],
+ );
+ }
+}
diff --git a/packages/testing/src/Events/TestSucceeded.php b/packages/testing/src/Events/TestSucceeded.php
new file mode 100644
index 0000000000..78388b0b15
--- /dev/null
+++ b/packages/testing/src/Events/TestSucceeded.php
@@ -0,0 +1,27 @@
+ $this->name,
+ ];
+ }
+
+ public static function deserialize(array $data): DispatchToParentProcess
+ {
+ return new self(
+ name: $data['name'],
+ );
+ }
+}
diff --git a/packages/testing/src/Events/TestsChunked.php b/packages/testing/src/Events/TestsChunked.php
new file mode 100644
index 0000000000..8eb6fef382
--- /dev/null
+++ b/packages/testing/src/Events/TestsChunked.php
@@ -0,0 +1,13 @@
+name}`");
+ }
+
+ public static function providerMethodMustReturnIterable(Test $test, string $name): self
+ {
+ return new self("The provider method `{$name}` must return an iterable for `{$test->name}`");
+ }
+}
diff --git a/packages/testing/src/Exceptions/TestException.php b/packages/testing/src/Exceptions/TestException.php
new file mode 100644
index 0000000000..460b7cebfb
--- /dev/null
+++ b/packages/testing/src/Exceptions/TestException.php
@@ -0,0 +1,9 @@
+export($value) . '`';
+ }
+
+ $this->reason = sprintf($reason, ...$parsedData);
+
+ $trace = $this->getTrace();
+
+ foreach ($this->getTrace() as $key => $traceEntry) {
+ if (str_starts_with($trace[$key + 1]['class'] ?? null, 'Tempest\Testing\Testers\Tester')) {
+ continue;
+ }
+
+ $this->location = sprintf('%s:%d', $traceEntry['file'], $traceEntry['line']);
+
+ break;
+ }
+
+ parent::__construct($this->reason);
+ }
+
+ private function export(mixed $value): string
+ {
+ if (is_object($value)) {
+ return $value::class;
+ }
+
+ if (is_array($value)) {
+ return 'array';
+ }
+
+ if (is_resource($value)) {
+ return 'resource';
+ }
+
+ return var_export($value, true);
+ }
+}
diff --git a/packages/testing/src/Output/ConvertsToTeamcityMessage.php b/packages/testing/src/Output/ConvertsToTeamcityMessage.php
new file mode 100644
index 0000000000..52a7be2a4e
--- /dev/null
+++ b/packages/testing/src/Output/ConvertsToTeamcityMessage.php
@@ -0,0 +1,10 @@
+verbose) {
+ $this->writeln()
+ ->info(sprintf(
+ 'will run on %d %s',
+ $event->processCount,
+ str('process')->pluralize($event->processCount),
+ ))
+ ->writeln();
+ }
+ }
+
+ public function onTestStarted(TestStarted $event): void
+ {
+ return;
+ }
+
+ public function onTestFailed(TestFailed $event): void
+ {
+ $this->result->addFailed();
+
+ $this->error(sprintf('', $event->name));
+ $this->writeln(sprintf(' ', $event->location));
+ $this->writeln(sprintf(' ', $event->reason));
+ $this->writeln();
+ }
+
+ public function onTestSkipped(TestSkipped $event): void
+ {
+ $this->result->addSkipped();
+
+ if ($this->verbose) {
+ $this->info("skipped: {$event->name}");
+ }
+ }
+
+ public function onTestSucceeded(TestSucceeded $event): void
+ {
+ $this->result->addSucceeded();
+
+ if ($this->verbose) {
+ $this->success($event->name);
+ }
+ }
+
+ public function onTestFinished(TestFinished $event): void
+ {
+ return;
+ }
+
+ public function onTestRunStarted(TestRunStarted $event): void
+ {
+ $this->result->startTime();
+ }
+
+ public function onTestRunEnded(TestRunEnded $event): void
+ {
+ $this->result->endTime();
+
+ $message = sprintf(
+ ' ',
+ $this->result->succeeded,
+ $this->result->failed,
+ $this->result->skipped,
+ $this->result->elapsedTime,
+ );
+
+ if ($this->result->failed > 0 || $this->verbose) {
+ $this->writeln();
+ }
+
+ $this->writeln($message);
+ }
+}
diff --git a/packages/testing/src/Output/OutputListeners.php b/packages/testing/src/Output/OutputListeners.php
new file mode 100644
index 0000000000..5d0fb8ac49
--- /dev/null
+++ b/packages/testing/src/Output/OutputListeners.php
@@ -0,0 +1,73 @@
+output->onTestsChunked($event);
+ }
+
+ #[EventHandler]
+ public function onTestStarted(TestStarted $event): void
+ {
+ $this->output->onTestStarted($event);
+ }
+
+ #[EventHandler]
+ public function onTestFailed(TestFailed $event): void
+ {
+ $this->output->onTestFailed($event);
+ }
+
+ #[EventHandler]
+ public function onTestSkipped(TestSkipped $event): void
+ {
+ $this->output->onTestSkipped($event);
+ }
+
+ #[EventHandler]
+ public function onTestSucceeded(TestSucceeded $event): void
+ {
+ $this->output->onTestSucceeded($event);
+ }
+
+ #[EventHandler]
+ public function onTestFinished(TestFinished $event): void
+ {
+ $this->output->onTestFinished($event);
+ }
+
+ #[EventHandler]
+ public function onTestRunStarted(TestRunStarted $event): void
+ {
+ $this->output->onTestRunStarted($event);
+ }
+
+ #[EventHandler]
+ public function onTestRunEnded(TestRunEnded $event): void
+ {
+ $this->output->onTestRunEnded($event);
+ }
+}
diff --git a/packages/testing/src/Output/TeamcityMessage.php b/packages/testing/src/Output/TeamcityMessage.php
new file mode 100644
index 0000000000..d1cdc4e4b0
--- /dev/null
+++ b/packages/testing/src/Output/TeamcityMessage.php
@@ -0,0 +1,23 @@
+ */
+ private array $parameters = [],
+ ) {}
+
+ public function __toString(): string
+ {
+ return sprintf(
+ '##teamcity[%s %s]',
+ $this->name->value,
+ arr($this->parameters)->map(fn (string $value, string $key) => "{$key}='{$value}'")->implode(' '),
+ );
+ }
+}
diff --git a/packages/testing/src/Output/TeamcityMessageName.php b/packages/testing/src/Output/TeamcityMessageName.php
new file mode 100644
index 0000000000..69a6fb4bc4
--- /dev/null
+++ b/packages/testing/src/Output/TeamcityMessageName.php
@@ -0,0 +1,13 @@
+writeln($event->teamcityMessage);
+ }
+
+ public function onTestFailed(TestFailed $event): void
+ {
+ $this->writeln($event->teamcityMessage);
+ }
+
+ public function onTestSkipped(TestSkipped $event): void
+ {
+ $this->writeln($event->teamcityMessage);
+ }
+
+ public function onTestSucceeded(TestSucceeded $event): void
+ {
+ return;
+ }
+
+ public function onTestFinished(TestFinished $event): void
+ {
+ $this->writeln($event->teamcityMessage);
+ }
+
+ public function onTestRunStarted(TestRunStarted $event): void
+ {
+ $this->writeln($event->teamcityMessage);
+ }
+
+ public function onTestRunEnded(TestRunEnded $event): void
+ {
+ $this->writeln($event->teamcityMessage);
+ }
+}
diff --git a/packages/testing/src/Output/TestOutput.php b/packages/testing/src/Output/TestOutput.php
new file mode 100644
index 0000000000..651a901714
--- /dev/null
+++ b/packages/testing/src/Output/TestOutput.php
@@ -0,0 +1,35 @@
+get(Application::class) instanceof ConsoleApplication) {
+ return null;
+ }
+
+ $argumentBag = $container->get(ConsoleArgumentBag::class);
+
+ $teamcity = $argumentBag->has('teamcity');
+
+ if ($teamcity) {
+ $output = $container->get(TeamCityOutput::class);
+ } else {
+ $output = $container->get(DefaultOutput::class);
+ }
+
+ $output->verbose = $argumentBag->has('verbose');
+
+ return $output;
+ }
+}
diff --git a/packages/testing/src/Provide.php b/packages/testing/src/Provide.php
new file mode 100644
index 0000000000..0818e96df0
--- /dev/null
+++ b/packages/testing/src/Provide.php
@@ -0,0 +1,19 @@
+entries = $entries;
+ }
+}
diff --git a/packages/testing/src/Runner/TestResult.php b/packages/testing/src/Runner/TestResult.php
new file mode 100644
index 0000000000..7655bb067c
--- /dev/null
+++ b/packages/testing/src/Runner/TestResult.php
@@ -0,0 +1,54 @@
+ round($this->endTime - $this->startTime, 2);
+ }
+
+ public function startTime(): self
+ {
+ $this->startTime = microtime(true);
+
+ return $this;
+ }
+
+ public function endTime(): self
+ {
+ $this->endTime = microtime(true);
+
+ return $this;
+ }
+
+ public function addSucceeded(): self
+ {
+ $this->succeeded += 1;
+
+ return $this;
+ }
+
+ public function addFailed(): self
+ {
+ $this->failed += 1;
+
+ return $this;
+ }
+
+ public function addSkipped(): self
+ {
+ $this->skipped += 1;
+
+ return $this;
+ }
+}
diff --git a/packages/testing/src/Runner/TestRunner.php b/packages/testing/src/Runner/TestRunner.php
new file mode 100644
index 0000000000..3bb9eadf7f
--- /dev/null
+++ b/packages/testing/src/Runner/TestRunner.php
@@ -0,0 +1,54 @@
+ $tests */
+ public function run(ImmutableArray $tests): self
+ {
+ $tests = $tests->map(fn (Test $test) => '--tests="' . $test->name . '"');
+
+ $this->process = new Process([
+ PHP_BINDIR . '/php',
+ 'tempest',
+ 'test:run',
+ ...$tests,
+ ]);
+
+ $this->process->start(function (string $type, string $buffer) {
+ foreach (explode(PHP_EOL, trim($buffer)) as $line) {
+ if (str_starts_with($line, '[EVENT]')) {
+ $payload = json_decode(substr($line, strlen('[EVENT] ')), true);
+
+ $event = $payload['event']::deserialize($payload['data']);
+
+ event($event);
+ } else {
+ echo $line . PHP_EOL;
+ }
+ }
+ });
+
+ return $this;
+ }
+
+ public function wait(): self
+ {
+ $this->process->wait();
+
+ return $this;
+ }
+}
diff --git a/packages/testing/src/Test.php b/packages/testing/src/Test.php
new file mode 100644
index 0000000000..f51a335aad
--- /dev/null
+++ b/packages/testing/src/Test.php
@@ -0,0 +1,62 @@
+ $this->handler->getDeclaringClass()->getName() . '::' . $this->handler->getName();
+ }
+
+ public static function fromName(string $name): self
+ {
+ $reflector = new MethodReflector(new ReflectionMethod(...explode('::', $name)));
+
+ return self::fromReflector($reflector);
+ }
+
+ public static function fromReflector(MethodReflector $reflector): self
+ {
+ $self = new self();
+
+ $self->handler = $reflector;
+
+ $self->before = arr($reflector->getDeclaringClass()->getPublicMethods())
+ ->filter(fn (MethodReflector $otherMethod) => $otherMethod->hasAttribute(Before::class))
+ ->values()
+ ->toArray();
+
+ $self->after = arr($reflector->getDeclaringClass()->getPublicMethods())
+ ->filter(fn (MethodReflector $otherMethod) => $otherMethod->hasAttribute(After::class))
+ ->values()
+ ->reverse()
+ ->toArray();
+
+ $self->provide = $reflector->getAttribute(Provide::class)?->entries;
+
+ return $self;
+ }
+
+ public function matchesFilter(string $filter): bool
+ {
+ return str_contains($this->name, $filter);
+ }
+}
diff --git a/packages/testing/src/Testers/EventBusTester.php b/packages/testing/src/Testers/EventBusTester.php
new file mode 100644
index 0000000000..99d28f8ff5
--- /dev/null
+++ b/packages/testing/src/Testers/EventBusTester.php
@@ -0,0 +1,74 @@
+dispatched[$eventName][] = $event;
+
+ if ($this->allowPropagation) {
+ $this->eventBus->dispatch($event);
+ }
+ }
+
+ public function listen(Closure $handler, ?string $event = null): void
+ {
+ $this->eventBus->listen($handler, $event);
+ }
+
+ public function allowPropagation(): self
+ {
+ $this->allowPropagation = true;
+
+ return $this;
+ }
+
+ public function preventPropagation(): self
+ {
+ $this->allowPropagation = false;
+
+ return $this;
+ }
+
+ public function wasDispatched(
+ string $expectedEventClass,
+ ?Closure $eventTester = null,
+ ): self {
+ test($this->dispatched)->hasKey($expectedEventClass);
+
+ if ($eventTester) {
+ foreach ($this->dispatched[$expectedEventClass] as $event) {
+ $eventTester($event);
+ }
+ }
+
+ return $this;
+ }
+
+ public function wasNotDispatched(
+ string $expectedEventClass,
+ ): self {
+ test(fn () => $this->wasDispatched($expectedEventClass))->fails();
+
+ return $this;
+ }
+}
diff --git a/packages/testing/src/Testers/Tester.php b/packages/testing/src/Testers/Tester.php
new file mode 100644
index 0000000000..7894b02c96
--- /dev/null
+++ b/packages/testing/src/Testers/Tester.php
@@ -0,0 +1,658 @@
+subject);
+
+ return $this;
+ }
+
+ public function fail(?string $reason = null): never
+ {
+ throw new TestHasFailed($reason ?? 'test was marked as failed');
+ }
+
+ public function succeed(): void
+ {
+ return;
+ }
+
+ public function fails(?string $message = null): self
+ {
+ $exceptionTester = null;
+
+ if ($message) {
+ $exceptionTester = function (TestHasFailed $exception) use ($message) {
+ test($exception->getMessage())->is($message);
+ };
+ }
+
+ $this->exceptionThrown(
+ expectedExceptionClass: TestHasFailed::class,
+ exceptionTester: $exceptionTester,
+ );
+
+ return $this;
+ }
+
+ public function succeeds(): self
+ {
+ $this->isCallable();
+
+ ($this->subject)();
+
+ return $this;
+ }
+
+ public function is(mixed $expected): self
+ {
+ if ($expected !== $this->subject) {
+ throw new TestHasFailed('failed asserting that %s is %s', $this->subject, $expected);
+ }
+
+ return $this;
+ }
+
+ public function isNot(mixed $expected): self
+ {
+ if ($expected === $this->subject) {
+ throw new TestHasFailed('failed asserting that %s is not %s', $this->subject, $expected);
+ }
+
+ return $this;
+ }
+
+ public function isEqualTo(mixed $expected): self
+ {
+ if ($expected != $this->subject) { // @mago-expect lint:identity-comparison
+ throw new TestHasFailed('failed asserting that %s is equal to %s', $this->subject, $expected);
+ }
+
+ return $this;
+ }
+
+ public function isNotEqualTo(mixed $expected): self
+ {
+ if ($expected == $this->subject) { // @mago-expect lint:identity-comparison
+ throw new TestHasFailed('failed asserting that %s is not equal to %s', $this->subject, $expected);
+ }
+
+ return $this;
+ }
+
+ public function isCallable(): self
+ {
+ if (! is_callable($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is callable', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotCallable(): self
+ {
+ if (is_callable($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is not callable', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function hasCount(int $expected): self
+ {
+ $this->isCountable();
+
+ if ($expected !== count($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s has %s items', $this->subject, $expected);
+ }
+
+ return $this;
+ }
+
+ public function hasNotCount(int $expected): self
+ {
+ $this->isCountable();
+
+ if ($expected === count($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s does not have %s items', $this->subject, $expected);
+ }
+
+ return $this;
+ }
+
+ public function isCountable(): self
+ {
+ if (! is_countable($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is countable', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotCountable(): self
+ {
+ if (is_countable($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is not countable', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function contains(mixed $search): self
+ {
+ if (! is_string($this->subject) && ! is_array($this->subject)) {
+ throw new TestHasFailed('to check contains, the test subject must be a string or an array; instead got %s', $this->subject);
+ }
+
+ if (is_string($this->subject) && ! str_contains($this->subject, $search)) {
+ throw new TestHasFailed('failed asserting that %s contains %s', $this->subject, $search);
+ }
+
+ if (is_array($this->subject) && ! in_array($search, $this->subject, strict: true)) {
+ throw new TestHasFailed('failed asserting that %s contains %s', $this->subject, $search);
+ }
+
+ return $this;
+ }
+
+ public function containsNot(mixed $search): self
+ {
+ if (! is_string($this->subject) && ! is_array($this->subject)) {
+ throw new TestHasFailed('to check contains, the test subject must be a string or an array; instead got %s', $this->subject);
+ }
+
+ if (is_string($this->subject) && str_contains($this->subject, $search)) {
+ throw new TestHasFailed('failed asserting that %s does not contain %s', $this->subject, $search);
+ }
+
+ if (is_array($this->subject) && in_array($search, $this->subject, strict: true)) {
+ throw new TestHasFailed('failed asserting that %s does not contain %s', $this->subject, $search);
+ }
+
+ return $this;
+ }
+
+ public function hasKey(mixed $key): self
+ {
+ $this->isArray();
+
+ if (! array_key_exists($key, $this->subject)) {
+ throw new TestHasFailed('failed asserting that %s has key %s', $this->subject, $key);
+ }
+
+ return $this;
+ }
+
+ public function missesKey(mixed $key): self
+ {
+ $this->isArray();
+
+ if (array_key_exists($key, $this->subject)) {
+ throw new TestHasFailed('failed asserting that %s does not have key %s', $this->subject, $key);
+ }
+
+ return $this;
+ }
+
+ public function instanceOf(string $expectedClass): self
+ {
+ if (! $this->subject instanceof $expectedClass) {
+ throw new TestHasFailed('failed asserting that %s is an instance of %s', $this->subject, $expectedClass);
+ }
+
+ return $this;
+ }
+
+ public function isNotInstanceOf(string $expectedClass): self
+ {
+ if ($this->subject instanceof $expectedClass) {
+ throw new TestHasFailed('failed asserting that %s is not an instance of %s', $this->subject, $expectedClass);
+ }
+
+ return $this;
+ }
+
+ public function exceptionThrown(
+ string $expectedExceptionClass,
+ ?Closure $exceptionTester = null,
+ ): self {
+ if (! is_callable($this->subject)) {
+ throw new TestHasFailed('to test exceptions, the test subject must be a callable; instead got %s', $this->subject);
+ }
+
+ try {
+ ($this->subject)();
+ } catch (Throwable $throwable) {
+ if (! $throwable instanceof $expectedExceptionClass) {
+ throw new TestHasFailed('Expected exception %s was not thrown, instead got %s', $expectedExceptionClass, $throwable::class);
+ }
+
+ if ($exceptionTester) {
+ $exceptionTester($throwable);
+ }
+
+ return $this;
+ }
+
+ throw new TestHasFailed('Expected exception %s was not thrown', $expectedExceptionClass);
+
+ return $this;
+ }
+
+ public function exceptionNotThrown(string $expectedExceptionClass): self
+ {
+ if (! is_callable($this->subject)) {
+ return $this;
+ }
+
+ try {
+ ($this->subject)();
+ } catch (Throwable $throwable) {
+ if ($throwable instanceof $expectedExceptionClass) {
+ throw new TestHasFailed("Exception %s was thrown, while it shouldn't", $throwable::class);
+ }
+ }
+
+ return $this;
+ }
+
+ public function isList(): self
+ {
+ $this->isArray();
+
+ if (! array_is_list($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is a list', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotList(): self
+ {
+ $this->isArray();
+
+ if (array_is_list($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is not a list', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isEmpty(): self
+ {
+ if (! empty($this->subject)) { // @mago-expect lint:no-empty
+ throw new TestHasFailed('failed asserting that %s is empty', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotEmpty(): self
+ {
+ if (empty($this->subject)) { // @mago-expect lint:no-empty
+ throw new TestHasFailed('failed asserting that %s is not empty', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function greaterThan(int|float $minimum): self
+ {
+ $this->isNumeric();
+
+ if ($this->subject <= $minimum) {
+ throw new TestHasFailed('failed asserting that %s is greater than %s', $this->subject, $minimum);
+ }
+
+ return $this;
+ }
+
+ public function greaterThanOrEqual(int|float $minimum): self
+ {
+ $this->isNumeric();
+
+ if ($this->subject < $minimum) {
+ throw new TestHasFailed('failed asserting that %s is greater than or equal to %s', $this->subject, $minimum);
+ }
+
+ return $this;
+ }
+
+ public function lessThan(int|float $maximum): self
+ {
+ $this->isNumeric();
+
+ if ($this->subject >= $maximum) {
+ throw new TestHasFailed('failed asserting that %s is less than %s', $this->subject, $maximum);
+ }
+
+ return $this;
+ }
+
+ public function lessThanOrEqual(int|float $maximum): self
+ {
+ $this->isNumeric();
+
+ if ($this->subject > $maximum) {
+ throw new TestHasFailed('failed asserting that %s is less than or equal to %s', $this->subject, $maximum);
+ }
+
+ return $this;
+ }
+
+ public function isTrue(): self
+ {
+ if ($this->subject !== true) {
+ throw new TestHasFailed('failed asserting that %s is true', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isFalse(): self
+ {
+ if ($this->subject !== false) {
+ throw new TestHasFailed('failed asserting that %s is false', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isTrueish(): self
+ {
+ if ((bool) $this->subject !== true) {
+ throw new TestHasFailed('failed asserting that %s is trueish', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isFalseish(): self
+ {
+ if ((bool) $this->subject !== false) {
+ throw new TestHasFailed('failed asserting that %s is falseish', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNull(): self
+ {
+ if (! is_null($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is null', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotNull(): self
+ {
+ if (is_null($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is not null', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isArray(): self
+ {
+ if (! is_array($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is array', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotArray(): self
+ {
+ if (is_array($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is not array', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isBool(): self
+ {
+ if (! is_bool($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is bool', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotBool(): self
+ {
+ if (is_bool($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is not bool', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isFloat(): self
+ {
+ if (! is_float($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is float', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotFloat(): self
+ {
+ if (is_float($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is not float', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isInt(): self
+ {
+ if (! is_int($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is int', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotInt(): self
+ {
+ if (is_int($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is not int', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNumeric(): self
+ {
+ if (! is_numeric($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is numeric', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotNumeric(): self
+ {
+ if (is_numeric($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is not numeric', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isObject(): self
+ {
+ if (! is_object($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is object', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotObject(): self
+ {
+ if (is_object($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is not object', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isResource(): self
+ {
+ if (! is_resource($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is resource', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotResource(): self
+ {
+ if (is_resource($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is not resource', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isString(): self
+ {
+ if (! is_string($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is string', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotString(): self
+ {
+ if (is_string($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is not string', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isScalar(): self
+ {
+ if (! is_scalar($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is scalar', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotScalar(): self
+ {
+ if (is_scalar($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is not scalar', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isIterable(): self
+ {
+ if (! is_iterable($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is iterable', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotIterable(): self
+ {
+ if (is_iterable($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is not iterable', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function startsWith(string $prefix): self
+ {
+ $this->isString();
+
+ if (! str_starts_with($this->subject, $prefix)) {
+ throw new TestHasFailed('failed asserting that %s starts with %s', $this->subject, $prefix);
+ }
+
+ return $this;
+ }
+
+ public function startsNotWith(string $prefix): self
+ {
+ $this->isString();
+
+ if (str_starts_with($this->subject, $prefix)) {
+ throw new TestHasFailed('failed asserting that %s does not start with %s', $this->subject, $prefix);
+ }
+
+ return $this;
+ }
+
+ public function endsWith(string $suffix): self
+ {
+ $this->isString();
+
+ if (! str_ends_with($this->subject, $suffix)) {
+ throw new TestHasFailed('failed asserting that %s ends with %s', $this->subject, $suffix);
+ }
+
+ return $this;
+ }
+
+ public function endsNotWith(string $suffix): self
+ {
+ $this->isString();
+
+ if (str_ends_with($this->subject, $suffix)) {
+ throw new TestHasFailed('failed asserting that %s does not end with %s', $this->subject, $suffix);
+ }
+
+ return $this;
+ }
+
+ public function isJson(): self
+ {
+ $this->isString();
+
+ if (! json_validate($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is valid JSON', $this->subject);
+ }
+
+ return $this;
+ }
+
+ public function isNotJson(): self
+ {
+ if (! is_string($this->subject)) {
+ return $this;
+ }
+
+ if (json_validate($this->subject)) {
+ throw new TestHasFailed('failed asserting that %s is not valid JSON', $this->subject);
+ }
+
+ return $this;
+ }
+}
diff --git a/packages/testing/src/Testers/TestsEvents.php b/packages/testing/src/Testers/TestsEvents.php
new file mode 100644
index 0000000000..006f929907
--- /dev/null
+++ b/packages/testing/src/Testers/TestsEvents.php
@@ -0,0 +1,30 @@
+originalEventBus = $container->get(EventBus::class);
+
+ $this->events = new EventBusTester($this->originalEventBus);
+ $container->singleton(EventBus::class, $this->events);
+ }
+
+ #[After]
+ public function testsEventsAfter(Container $container): void
+ {
+ $container->singleton(EventBus::class, $this->originalEventBus);
+ }
+}
diff --git a/packages/testing/src/functions.php b/packages/testing/src/functions.php
new file mode 100644
index 0000000000..494906a777
--- /dev/null
+++ b/packages/testing/src/functions.php
@@ -0,0 +1,10 @@
+events->preventPropagation();
+
+ test()->succeed();
+ }
+}
diff --git a/packages/testing/tests/Fixtures/Dependency.php b/packages/testing/tests/Fixtures/Dependency.php
new file mode 100644
index 0000000000..e976a00772
--- /dev/null
+++ b/packages/testing/tests/Fixtures/Dependency.php
@@ -0,0 +1,10 @@
+migrate(
+ CreateMigrationsTable::class,
+ FooDatabaseMigration::class,
+ );
+
+ $foo = Foo::create(
+ bar: 'baz',
+ );
+
+ $this->assertSame('baz', $foo->bar);
+ $this->assertInstanceOf(PrimaryKey::class, $foo->id);
+
+ $foo = Foo::get($foo->id);
+
+ $this->assertSame('baz', $foo->bar);
+ $this->assertInstanceOf(PrimaryKey::class, $foo->id);
+
+ $foo->update(
+ bar: 'boo',
+ );
+
+ $foo = Foo::get($foo->id);
+
+ $this->assertSame('boo', $foo->bar);
+ }
+
+ public function test_get_with_non_id_object(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ FooDatabaseMigration::class,
+ );
+
+ Foo::create(
+ bar: 'baz',
+ );
+
+ $foo = Foo::get(1);
+
+ $this->assertSame(1, $foo->id->value);
+ }
+
+ public function test_creating_many_and_saving_preserves_model_id(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ FooDatabaseMigration::class,
+ );
+
+ $a = Foo::create(
+ bar: 'a',
+ );
+ $b = Foo::create(
+ bar: 'b',
+ );
+
+ $this->assertEquals(1, $a->id->value);
+ $a->save();
+ $this->assertEquals(1, $a->id->value);
+ }
+
+ public function test_complex_query(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreatePublishersTable::class,
+ CreateAuthorTable::class,
+ CreateBookTable::class,
+ );
+
+ $book = Book::new(
+ title: 'Book Title',
+ author: new Author(
+ name: 'Author Name',
+ type: AuthorType::B,
+ ),
+ );
+
+ $book = $book->save();
+
+ $book = Book::get($book->id, relations: ['author']);
+
+ $this->assertEquals(1, $book->id->value);
+ $this->assertSame('Book Title', $book->title);
+ $this->assertSame(AuthorType::B, $book->author->type);
+ $this->assertInstanceOf(Author::class, $book->author);
+ $this->assertSame('Author Name', $book->author->name);
+ $this->assertEquals(1, $book->author->id->value);
+ }
+
+ public function test_all_with_relations(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreatePublishersTable::class,
+ CreateAuthorTable::class,
+ CreateBookTable::class,
+ );
+
+ Book::new(
+ title: 'Book Title',
+ author: new Author(
+ name: 'Author Name',
+ type: AuthorType::B,
+ ),
+ )->save();
+
+ $books = Book::all(relations: [
+ 'author',
+ ]);
+
+ $this->assertCount(1, $books);
+
+ $book = $books[0];
+
+ $this->assertEquals(1, $book->id->value);
+ $this->assertSame('Book Title', $book->title);
+ $this->assertSame(AuthorType::B, $book->author->type);
+ $this->assertInstanceOf(Author::class, $book->author);
+ $this->assertSame('Author Name', $book->author->name);
+ $this->assertEquals(1, $book->author->id->value);
+ }
+
+ public function test_missing_relation_exception(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreateATable::class,
+ CreateBTable::class,
+ CreateCTable::class,
+ );
+
+ new A(
+ b: new B(
+ c: new C(name: 'test'),
+ ),
+ )->save();
+
+ $a = A::select()->first();
+
+ $this->expectException(RelationWasMissing::class);
+
+ $b = $a->b;
+ }
+
+ public function test_missing_value_exception(): void
+ {
+ $a = map([])->to(AWithValue::class);
+
+ $this->expectException(ValueWasMissing::class);
+
+ $name = $a->name;
+ }
+
+ public function test_nested_relations(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreateATable::class,
+ CreateBTable::class,
+ CreateCTable::class,
+ );
+
+ new A(
+ b: new B(
+ c: new C(name: 'test'),
+ ),
+ )->save();
+
+ $a = A::select()->with('b.c')->first();
+ $this->assertSame('test', $a->b->c->name);
+
+ $a = A::select()->with('b.c')->all()[0];
+ $this->assertSame('test', $a->b->c->name);
+ }
+
+ public function test_load_belongs_to(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreateATable::class,
+ CreateBTable::class,
+ CreateCTable::class,
+ );
+
+ new A(
+ b: new B(
+ c: new C(name: 'test'),
+ ),
+ )->save();
+
+ $a = A::select()->first();
+ $this->assertFalse(isset($a->b));
+
+ $a->load('b.c');
+ $this->assertTrue(isset($a->b));
+ $this->assertTrue(isset($a->b->c));
+ }
+
+ public function test_has_many_relations(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreatePublishersTable::class,
+ CreateAuthorTable::class,
+ CreateBookTable::class,
+ );
+
+ $author = Author::create(
+ name: 'Author Name',
+ type: AuthorType::B,
+ );
+
+ Book::create(
+ title: 'Book Title',
+ author: $author,
+ );
+
+ Book::create(
+ title: 'Timeline Taxi',
+ author: $author,
+ );
+
+ $author = Author::select()->with('books')->first();
+
+ $this->assertCount(2, $author->books);
+ }
+
+ public function test_has_many_through_relation(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreateHasManyParentTable::class,
+ CreateHasManyChildTable::class,
+ CreateHasManyThroughTable::class,
+ );
+
+ $parent = new ParentModel(name: 'parent')->save();
+
+ $childA = new ChildModel(name: 'A')->save();
+ $childB = new ChildModel(name: 'B')->save();
+
+ new ThroughModel(parent: $parent, child: $childA)->save();
+ new ThroughModel(parent: $parent, child: $childB)->save();
+
+ $parent = ParentModel::get($parent->id, ['through.child']);
+
+ $this->assertSame('A', $parent->through[0]->child->name);
+ $this->assertSame('B', $parent->through[1]->child->name);
+ }
+
+ public function test_empty_has_many_relation(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreatePublishersTable::class,
+ CreateAuthorTable::class,
+ CreateBookTable::class,
+ CreateChapterTable::class,
+ CreateHasManyChildTable::class,
+ );
+
+ Book::new(title: 'Timeline Taxi')->save();
+ $book = Book::select()->with('chapters')->first();
+ $this->assertEmpty($book->chapters);
+ }
+
+ public function test_has_one_relation(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreatePublishersTable::class,
+ CreateAuthorTable::class,
+ CreateBookTable::class,
+ CreateChapterTable::class,
+ CreateHasManyChildTable::class,
+ CreateIsbnTable::class,
+ );
+
+ $book = Book::new(title: 'Timeline Taxi')->save();
+ $isbn = Isbn::new(value: 'tt-1', book: $book)->save();
+
+ $isbn = Isbn::select()->with('book')->get($isbn->id);
+
+ $this->assertSame('Timeline Taxi', $isbn->book->title);
+ }
+
+ public function test_invalid_has_one_relation(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreateHasManyParentTable::class,
+ CreateHasManyChildTable::class,
+ CreateHasManyThroughTable::class,
+ );
+
+ $parent = new ParentModel(name: 'parent')->save();
+
+ $childA = new ChildModel(name: 'A')->save();
+ $childB = new ChildModel(name: 'B')->save();
+
+ new ThroughModel(parent: $parent, child: $childA, child2: $childB)->save();
+
+ $child = ChildModel::get($childA->id, ['through.parent']);
+ $this->assertSame('parent', $child->through->parent->name);
+
+ $child2 = ChildModel::select()->with('through2.parent')->get($childB->id);
+ $this->assertSame('parent', $child2->through2->parent->name);
+ }
+
+ public function test_lazy_load(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreateATable::class,
+ CreateBTable::class,
+ CreateCTable::class,
+ );
+
+ new AWithLazy(
+ b: new B(
+ c: new C(name: 'test'),
+ ),
+ )->save();
+
+ $a = AWithLazy::select()->first();
+
+ $this->assertFalse(isset($a->b));
+
+ /** @phpstan-ignore expr.resultUnused */
+ $a->b; // The side effect from accessing ->b will cause it to load
+
+ $this->assertTrue(isset($a->b));
+ }
+
+ public function test_eager_load(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreateATable::class,
+ CreateBTable::class,
+ CreateCTable::class,
+ );
+
+ new AWithLazy(
+ b: new B(
+ c: new C(name: 'test'),
+ ),
+ )->save();
+
+ $a = AWithEager::select()->first();
+ $this->assertTrue(isset($a->b));
+ $this->assertTrue(isset($a->b->c));
+ }
+
+ public function test_no_result(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreateATable::class,
+ CreateBTable::class,
+ CreateCTable::class,
+ );
+
+ $this->assertNull(A::select()->first());
+ }
+
+ public function test_create_with_virtual_property(): void
+ {
+ $this->database->migrate(
+ CreateMigrationsTable::class,
+ CreateATable::class,
+ CreateBTable::class,
+ CreateCTable::class,
+ );
+
+ $a = AWithVirtual::create(
+ b: new B(
+ c: new C(name: 'test'),
+ ),
+ );
+
+ $this->assertSame(-$a->id->value, $a->fake);
+ }
+
+ public function test_virtual_hooked_property(): void
+ {
+ $this->database->migrate(
+ CreateMigrationsTable::class,
+ CreateModelWithHookedVirtualPropertyTable::class,
+ );
+
+ $a = ModelWithHookedVirtualProperty::create(
+ name: 'a',
+ );
+
+ $this->assertSame('A', $a->hookedName);
+
+ $a = ModelWithHookedVirtualProperty::select()->first();
+ $this->assertSame('A', $a->hookedName);
+
+ $a->name = 'b';
+ $a->save();
+ $this->assertSame('B', $a->hookedName);
+ }
+
+ public function test_select_virtual_property(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreateATable::class,
+ CreateBTable::class,
+ CreateCTable::class,
+ );
+
+ new A(
+ b: new B(
+ c: new C(name: 'test'),
+ ),
+ )->save();
+
+ $a = AWithVirtual::select()->first();
+
+ $this->assertSame(-$a->id->value, $a->fake);
+ }
+
+ public function test_update_with_virtual_property(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreateATable::class,
+ CreateBTable::class,
+ CreateCTable::class,
+ );
+
+ $a = AWithVirtual::create(
+ b: new B(
+ c: new C(name: 'test'),
+ ),
+ );
+
+ $a->update(
+ b: new B(
+ c: new C(name: 'updated'),
+ ),
+ );
+
+ $updatedA = AWithVirtual::select()
+ ->with('b.c')
+ ->where('id', $a->id)
+ ->first();
+
+ $this->assertSame(-$updatedA->id->value, $updatedA->fake);
+ $this->assertSame('updated', $updatedA->b->c->name);
+ }
+
+ public function test_update_or_create(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreatePublishersTable::class,
+ CreateAuthorTable::class,
+ CreateBookTable::class,
+ );
+
+ Book::new(
+ title: 'A',
+ author: new Author(
+ name: 'Author Name',
+ type: AuthorType::B,
+ ),
+ )->save();
+
+ Book::updateOrCreate(
+ ['title' => 'A'],
+ ['title' => 'B'],
+ );
+
+ $this->assertNull(Book::select()->where('title', 'A')->first());
+ $this->assertNotNull(Book::select()->where('title', 'B')->first());
+ }
+
+ public function test_update_or_create_uses_initial_data_to_create(): void
+ {
+ $this->database->migrate(
+ CreateMigrationsTable::class,
+ CreatePublishersTable::class,
+ CreateAuthorTable::class,
+ );
+
+ Author::updateOrCreate(
+ find: ['name' => 'Brent'],
+ update: ['type' => AuthorType::B],
+ );
+
+ $this->assertNotNull(
+ Author::select()
+ ->where('name', 'Brent')
+ ->where('type', AuthorType::B)
+ ->first(),
+ );
+ }
+
+ public function test_delete(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ FooDatabaseMigration::class,
+ );
+
+ $foo = Foo::create(
+ bar: 'baz',
+ );
+
+ $bar = Foo::create(
+ bar: 'baz',
+ );
+
+ $foo->delete();
+
+ $this->assertNull(Foo::get($foo->id));
+ $this->assertNotNull(Foo::get($bar->id));
+ }
+
+ public function test_delete_via_model_class_with_where_conditions(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ FooDatabaseMigration::class,
+ );
+
+ $foo1 = Foo::create(bar: 'delete_me');
+ $foo2 = Foo::create(bar: 'keep_me');
+ $foo3 = Foo::create(bar: 'delete_me');
+
+ query(Foo::class)
+ ->delete()
+ ->where('bar', 'delete_me')
+ ->execute();
+
+ $this->assertNull(Foo::get($foo1->id));
+ $this->assertNotNull(Foo::get($foo2->id));
+ $this->assertNull(Foo::get($foo3->id));
+ }
+
+ public function test_delete_via_model_instance_with_primary_key(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ FooDatabaseMigration::class,
+ );
+
+ $foo1 = Foo::create(bar: 'first');
+ $foo2 = Foo::create(bar: 'second');
+ $foo1->delete();
+
+ $this->assertNull(Foo::get($foo1->id));
+ $this->assertNotNull(Foo::get($foo2->id));
+ $this->assertSame('second', Foo::get($foo2->id)->bar);
+ }
+
+ public function test_delete_with_uninitialized_primary_key(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ FooDatabaseMigration::class,
+ );
+
+ $foo = new Foo();
+ $foo->bar = 'unsaved';
+
+ $this->expectException(DeleteStatementWasInvalid::class);
+ $foo->delete();
+ }
+
+ public function test_delete_nonexistent_record(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ FooDatabaseMigration::class,
+ );
+
+ $foo = Foo::create(bar: 'test');
+ $fooId = $foo->id;
+
+ // Delete the record
+ $foo->delete();
+
+ // Delete again
+ $foo->delete();
+
+ $this->assertNull(Foo::get($fooId));
+ }
+
+ public function test_nullable_relations(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreateBNullableTable::class,
+ CreateANullableTable::class,
+ );
+
+ $a = ANullableModel::create(
+ name: 'a',
+ );
+
+ $a->load('b');
+
+ $this->assertNull($a->b);
+ }
+
+ public function test_nullable_relation_save(): void
+ {
+ $this->migrate(
+ CreateMigrationsTable::class,
+ CreateBNullableTable::class,
+ CreateANullableTable::class,
+ );
+
+ ANullableModel::create(
+ name: 'a',
+ b: BNullableModel::new(
+ name: 'b',
+ ),
+ );
+
+ $a = ANullableModel::select()->first();
+ $a->save();
+
+ $a = ANullableModel::select()->with('b')->first();
+
+ $this->assertNotNull($a->b);
+ $this->assertSame('b', $a->b->name);
+ }
+}
+
+final class Foo
+{
+ use IsDatabaseModel;
+
+ public string $bar;
+}
+
+final class FooDatabaseMigration implements MigratesUp
+{
+ private(set) string $name = 'foos';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement(
+ tableName: 'foos',
+ statements: [
+ new PrimaryKeyStatement(),
+ new TextStatement('bar'),
+ ],
+ );
+ }
+}
+
+final class CreateATable implements MigratesUp
+{
+ private(set) string $name = '100-create-a';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement(
+ 'a',
+ [
+ new PrimaryKeyStatement(),
+ new RawStatement('b_id INTEGER'),
+ ],
+ );
+ }
+}
+
+final class CreateBTable implements MigratesUp
+{
+ private(set) string $name = '100-create-b';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement(
+ 'b',
+ [
+ new PrimaryKeyStatement(),
+ new RawStatement('c_id INTEGER'),
+ ],
+ );
+ }
+}
+
+final class CreateCTable implements MigratesUp
+{
+ private(set) string $name = '100-create-c';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('c', [
+ new PrimaryKeyStatement(),
+ new TextStatement('name'),
+ ]);
+ }
+}
+
+final class CreateCarbonModelTable implements MigratesUp
+{
+ public string $name = '2024-12-17_create_users_table';
+
+ public function up(): QueryStatement
+ {
+ return CreateTableStatement::forModel(CarbonModel::class)
+ ->primary()
+ ->datetime('createdAt');
+ }
+}
+
+final class CreateCasterModelTable implements MigratesUp
+{
+ public string $name = '0000_create_caster_model_table';
+
+ public function up(): QueryStatement
+ {
+ return new CompoundStatement(
+ new DropEnumTypeStatement(CasterEnum::class),
+ new CreateEnumTypeStatement(CasterEnum::class),
+ CreateTableStatement::forModel(CasterModel::class)
+ ->primary()
+ ->datetime('date')
+ ->array('array_prop')
+ ->enum('enum_prop', CasterEnum::class),
+ );
+ }
+}
+
+final class CreateDateTimeModelTable implements MigratesUp
+{
+ public string $name = '0001_datetime_model_table';
+
+ public function up(): QueryStatement
+ {
+ return CreateTableStatement::forModel(DateTimeModel::class)
+ ->primary()
+ ->datetime('phpDateTime')
+ ->datetime('tempestDateTime');
+ }
+}
+
+final class CreateHasManyChildTable implements MigratesUp
+{
+ private(set) string $name = '100-create-has-many-child';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('child')
+ ->primary()
+ ->varchar('name');
+ }
+}
+
+final class CreateHasManyParentTable implements MigratesUp
+{
+ private(set) string $name = '100-create-has-many-parent';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('parent')
+ ->primary()
+ ->varchar('name');
+ }
+}
+
+final class CreateHasManyThroughTable implements MigratesUp
+{
+ private(set) string $name = '100-create-has-many-through';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('through')
+ ->primary()
+ ->belongsTo('through.parent_id', 'parent.id')
+ ->belongsTo('through.child_id', 'child.id')
+ ->belongsTo('through.child2_id', 'child.id', nullable: true);
+ }
+}
+
+#[Table('custom_attribute_table_name')]
+final class AttributeTableNameModel
+{
+ use IsDatabaseModel;
+}
+
+final class BaseModel
+{
+ use IsDatabaseModel;
+}
+
+final readonly class CarbonCaster implements Caster
+{
+ public function cast(mixed $input): mixed
+ {
+ return new Carbon($input);
+ }
+}
+
+final class CarbonModel
+{
+ use IsDatabaseModel;
+
+ public function __construct(
+ public Carbon $createdAt,
+ ) {}
+}
+
+final readonly class CarbonSerializer implements Serializer
+{
+ public function serialize(mixed $input): string
+ {
+ if (! $input instanceof Carbon) {
+ throw new ValueCouldNotBeSerialized(Carbon::class);
+ }
+
+ return $input->format('Y-m-d H:i:s');
+ }
+}
+
+enum CasterEnum: string
+{
+ case FOO = 'foo';
+ case BAR = 'bar';
+}
+
+final class CasterModel
+{
+ use IsDatabaseModel;
+
+ public function __construct(
+ public DateTimeImmutable $date,
+ public array $array_prop,
+ public CasterEnum $enum_prop,
+ ) {}
+}
+
+#[Table('child')]
+final class ChildModel
+{
+ use IsDatabaseModel;
+
+ #[HasOne]
+ public ThroughModel $through;
+
+ #[HasOne(ownerJoin: 'child2_id')]
+ public ThroughModel $through2;
+
+ public function __construct(
+ public string $name,
+ ) {}
+}
+
+final class DateTimeModel
+{
+ use IsDatabaseModel;
+
+ public function __construct(
+ public PrimaryKey $id,
+ public NativeDateTime $phpDateTime,
+ public DateTime $tempestDateTime,
+ ) {}
+}
+
+final class ModelWithValidation
+{
+ use IsDatabaseModel;
+
+ #[IsBetween(min: 1, max: 10)]
+ public int $index;
+
+ #[SkipValidation]
+ public int $skip;
+}
+
+#[Table('parent')]
+final class ParentModel
+{
+ use IsDatabaseModel;
+
+ public function __construct(
+ public string $name,
+
+ /** @var \Tests\Tempest\Integration\Database\Builder\ThroughModel[] */
+ public array $through = [],
+ ) {}
+}
+
+#[Table('custom_static_method_table_name')]
+final class StaticMethodTableNameModel
+{
+ use IsDatabaseModel;
+}
+
+#[Table('through')]
+final class ThroughModel
+{
+ use IsDatabaseModel;
+
+ public function __construct(
+ public ParentModel $parent,
+ public ChildModel $child,
+ #[BelongsTo(ownerJoin: 'child2_id')]
+ public ?ChildModel $child2 = null,
+ ) {}
+}
+
+final class TestUser
+{
+ use IsDatabaseModel;
+
+ /** @var \Tests\Tempest\Integration\Database\Builder\TestPost[] */
+ #[HasMany]
+ public array $posts = [];
+
+ public function __construct(
+ public string $name,
+ ) {}
+}
+
+final class TestPost
+{
+ use IsDatabaseModel;
+
+ public function __construct(
+ public string $title,
+ public string $body,
+ ) {}
+}
+
+final class CreateTestUserMigration implements MigratesUp
+{
+ public string $name = '010_create_test_users';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('test_users')
+ ->primary()
+ ->text('name');
+ }
+}
+
+final class CreateTestPostMigration implements MigratesUp
+{
+ public string $name = '011_create_test_posts';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('test_posts')
+ ->primary()
+ ->foreignId('test_user_id', constrainedOn: 'test_users')
+ ->string('title')
+ ->text('body');
+ }
+}
+
+final class ModelWithoutPrimaryKey
+{
+ public function __construct(
+ public string $name,
+ public string $description,
+ ) {}
+}
+
+final class CreateModelWithoutPrimaryKeyMigration implements MigratesUp
+{
+ private(set) string $name = '100-create-model-without-primary-key';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('model_without_primary_keys')
+ ->text('name')
+ ->text('description');
+ }
+}
+
+final class CreateANullableTable implements MigratesUp
+{
+ private(set) string $name = '100-create-a-nullable';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('a')
+ ->primary()
+ ->string('name')
+ ->belongsTo('a.b_id', 'b.id', nullable: true);
+ }
+}
+
+final class CreateBNullableTable implements MigratesUp
+{
+ private(set) string $name = '100-create-b-nullable';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('b')
+ ->primary()
+ ->string('name');
+ }
+}
+
+#[Table('a')]
+final class ANullableModel
+{
+ use IsDatabaseModel;
+
+ public ?BNullableModel $b = null;
+
+ public string $name;
+}
+
+#[Table('b')]
+final class BNullableModel
+{
+ use IsDatabaseModel;
+
+ public string $name;
+}
+
+final class CreateModelWithHookedVirtualPropertyTable implements MigratesUp
+{
+ public string $name = '100-create-model-with-hooked-virtual-property';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('model_with_hooked_virtual_property')
+ ->primary()
+ ->string('name');
+ }
+}
+
+#[Table('model_with_hooked_virtual_property')]
+final class ModelWithHookedVirtualProperty
+{
+ use IsDatabaseModel;
+
+ public string $name;
+
+ public string $hookedName {
+ get => strtoupper($this->name);
+ }
+}
diff --git a/packages/testing/tests/InjectionTest.php b/packages/testing/tests/InjectionTest.php
new file mode 100644
index 0000000000..3e0b0d39b3
--- /dev/null
+++ b/packages/testing/tests/InjectionTest.php
@@ -0,0 +1,50 @@
+instanceOf(Dependency::class);
+ }
+
+ #[Test]
+ public function injectInConstructor(): void
+ {
+ test($this->dependency)->instanceOf(Dependency::class);
+ }
+
+ #[
+ Test,
+ Provide(
+ ['foo' => 'foo'],
+ ),
+ ]
+ public function combinedWithProvider(Dependency $dependency, string $foo): void
+ {
+ test($dependency)->instanceOf(Dependency::class);
+ }
+
+ #[
+ Test,
+ Provide(
+ ['foo' => 'foo'],
+ ),
+ ]
+ public function combinedWithProviderInReverseOrder(string $foo, Dependency $dependency): void
+ {
+ test($dependency)->instanceOf(Dependency::class);
+ }
+}
diff --git a/packages/testing/tests/ProviderTest.php b/packages/testing/tests/ProviderTest.php
new file mode 100644
index 0000000000..761f6aa230
--- /dev/null
+++ b/packages/testing/tests/ProviderTest.php
@@ -0,0 +1,82 @@
+is(1);
+ test($two)->is(2);
+ }
+
+ #[
+ Test,
+ Provide(
+ ['two' => 2, 'one' => 1],
+ ['two' => 2, 'one' => 1],
+ ),
+ ]
+ public function provideWithNamedScalarValues(int $one, int $two): void
+ {
+ test($one)->is(1);
+ test($two)->is(2);
+ }
+
+ #[
+ Test,
+ Provide(
+ 'generatorData',
+ ),
+ ]
+ public function provideWithGenerator(int $one, int $two): void
+ {
+ test($one)->is(1);
+ test($two)->is(2);
+ }
+
+ #[
+ Test,
+ Provide(
+ 'generatorData',
+ [1, 2],
+ ),
+ ]
+ public function provideWithGeneratorAndArrays(int $one, int $two): void
+ {
+ test($one)->is(1);
+ test($two)->is(2);
+ }
+
+ #[
+ Test,
+ Provide(static function (): Generator {
+ yield [1, 2];
+ yield [1, 2];
+ }),
+ ]
+ public function provideWithClosure(int $one, int $two): void
+ {
+ test($one)->is(1);
+ test($two)->is(2);
+ }
+
+ public function generatorData(): Generator
+ {
+ yield [1, 2];
+ yield ['two' => 2, 'one' => 1];
+ }
+}
diff --git a/packages/testing/tests/PsrInjectionTest.php b/packages/testing/tests/PsrInjectionTest.php
new file mode 100644
index 0000000000..94a19f18ed
--- /dev/null
+++ b/packages/testing/tests/PsrInjectionTest.php
@@ -0,0 +1,67 @@
+container->get(RunTest::class);
+
+ $class = reflect($runTest);
+
+ $property = $class->getProperty('container');
+
+ $property->setValue($runTest, new PsrContainer());
+ }
+
+ #[After]
+ public function after(): void
+ {
+ $runTest = $this->container->get(RunTest::class);
+
+ $class = reflect($runTest);
+
+ $property = $class->getProperty('container');
+
+ $property->setValue($runTest, $this->container);
+ }
+
+ #[Test]
+ public function psrInjection(Dependency $dependency): void
+ {
+ test($dependency->name)->is('psr');
+ }
+}
+
+final class PsrContainer implements ContainerInterface
+{
+ public function get(string $id): Dependency
+ {
+ return new Dependency('psr');
+ }
+
+ public function has(string $id): bool
+ {
+ return true;
+ }
+}
diff --git a/packages/testing/tests/TesterTest.php b/packages/testing/tests/TesterTest.php
new file mode 100644
index 0000000000..3a45204de6
--- /dev/null
+++ b/packages/testing/tests/TesterTest.php
@@ -0,0 +1,554 @@
+ test()->fail())->fails();
+ }
+
+ #[Test]
+ public function succeed(): void
+ {
+ test(fn () => test()->succeed())->succeeds();
+ }
+
+ #[Test]
+ public function is(): void
+ {
+ test(fn () => test(1)->is(1))->succeeds();
+ test(fn () => test(1)->is(2))->fails('failed asserting that `1` is `2`');
+ test(fn () => test(1)->is('1'))->fails("failed asserting that `1` is `'1'`");
+ test(fn () => test(0)->is(''))->fails();
+
+ $a = (object) [];
+ $b = (object) [];
+ $c = (object) ['a' => 'a'];
+
+ test(fn () => test($a)->is($a))->succeeds();
+ test(fn () => test($a)->is($b))->fails('failed asserting that `stdClass` is `stdClass`');
+ test(fn () => test($a)->is($c))->fails('failed asserting that `stdClass` is `stdClass`');
+ }
+
+ #[Test]
+ public function isNot(): void
+ {
+ test(fn () => test(1)->isNot(1))->fails('failed asserting that `1` is not `1`');
+ test(fn () => test(1)->isNot(2))->succeeds();
+ test(fn () => test(1)->isNot('1'))->succeeds();
+ test(fn () => test(0)->isNot(''))->succeeds();
+
+ $a = (object) [];
+ $b = (object) [];
+ $c = (object) ['a' => 'a'];
+
+ test(fn () => test($a)->isNot($a))->fails('failed asserting that `stdClass` is not `stdClass`');
+ test(fn () => test($a)->isNot($b))->succeeds();
+ test(fn () => test($a)->isNot($c))->succeeds();
+ }
+
+ #[
+ Test,
+ Provide(
+ ['test' => 1, 'expected' => 1, 'succeeds' => true],
+ ['test' => 1, 'expected' => 2, 'succeeds' => false],
+ ['test' => 1, 'expected' => '1', 'succeeds' => true],
+ ['test' => false, 'expected' => '', 'succeeds' => true],
+ ),
+ ]
+ public function isEqualTo(mixed $test, mixed $expected, bool $succeeds): void
+ {
+ if ($succeeds) {
+ test(fn () => test($test)->isEqualTo($expected))->succeeds();
+ } else {
+ test(fn () => test($test)->isEqualTo($expected))->fails();
+ }
+ }
+
+ #[Test]
+ public function isEqualToObject(): void
+ {
+ $a = (object) [];
+ $b = (object) [];
+ $c = (object) ['a' => 'a'];
+
+ test(fn () => test($a)->isEqualTo($a))->succeeds();
+ test(fn () => test($a)->isEqualTo($b))->succeeds();
+ test(fn () => test($a)->isEqualTo($c))->fails();
+ }
+
+ #[
+ Test,
+ Provide(
+ ['test' => 1, 'expected' => 1, 'succeeds' => false],
+ ['test' => 1, 'expected' => 2, 'succeeds' => true],
+ ['test' => 1, 'expected' => '1', 'succeeds' => false],
+ ['test' => false, 'expected' => '', 'succeeds' => false],
+ ),
+ ]
+ public function isNotEqualTo(mixed $test, mixed $expected, bool $succeeds): void
+ {
+ if ($succeeds) {
+ test(fn () => test($test)->isNotEqualTo($expected))->succeeds();
+ } else {
+ test(fn () => test($test)->isNotEqualTo($expected))->fails();
+ }
+ }
+
+ #[Test]
+ public function isNotEqualToObject(): void
+ {
+ $a = (object) [];
+ $b = (object) [];
+ $c = (object) ['a' => 'a'];
+
+ test(fn () => test($a)->isNotEqualTo($a))->fails();
+ test(fn () => test($a)->isNotEqualTo($b))->fails();
+ test(fn () => test($a)->isNotEqualTo($c))->succeeds();
+ }
+
+ #[Test]
+ public function isCallable(): void
+ {
+ test(fn () => test(fn () => true)->isCallable())->succeeds();
+ test(fn () => test('a')->isCallable())->fails("failed asserting that `'a'` is callable");
+ }
+
+ #[Test]
+ public function isNotCallable(): void
+ {
+ test(fn () => test(fn () => true)->isNotCallable())->fails('failed asserting that `Closure` is not callable');
+ test(fn () => test('not_callable')->isNotCallable())->succeeds();
+ }
+
+ #[Test]
+ public function hasCount(): void
+ {
+ test(fn () => test([1, 2, 3])->hasCount(3))->succeeds();
+ test(fn () => test([1, 2, 3])->hasCount(4))->fails('failed asserting that `array` has `4` items');
+ test(fn () => test(1)->hasCount(4))->fails('failed asserting that `1` is countable');
+ }
+
+ #[Test]
+ public function hasNotCount(): void
+ {
+ test(fn () => test([1, 2, 3])->hasNotCount(3))->fails('failed asserting that `array` does not have `3` items');
+ test(fn () => test([1, 2, 3])->hasNotCount(4))->succeeds();
+ test(fn () => test(1)->hasNotCount(4))->fails('failed asserting that `1` is countable');
+ }
+
+ #[Test]
+ public function contains(): void
+ {
+ test(fn () => test([1, 2, 3])->contains(2))->succeeds();
+ test(fn () => test([1, 2, 3])->contains(4))->fails('failed asserting that `array` contains `4`');
+ test(fn () => test('abc')->contains('b'))->succeeds();
+ test(fn () => test('abc')->contains('d'))->fails("failed asserting that `'abc'` contains `'d'`");
+ test(fn () => test(1)->contains('d'))->fails('to check contains, the test subject must be a string or an array; instead got `1`');
+ }
+
+ #[Test]
+ public function containsNot(): void
+ {
+ test(fn () => test([1, 2, 3])->containsNot(2))->fails('failed asserting that `array` does not contain `2`');
+ test(fn () => test([1, 2, 3])->containsNot(4))->succeeds();
+ test(fn () => test('abc')->containsNot('b'))->fails("failed asserting that `'abc'` does not contain `'b'`");
+ test(fn () => test('abc')->containsNot('d'))->succeeds();
+ test(fn () => test(1)->containsNot('d'))->fails('to check contains, the test subject must be a string or an array; instead got `1`');
+ }
+
+ #[Test]
+ public function hasKey(): void
+ {
+ test(fn () => test([1, 2, 3])->hasKey(2))->succeeds();
+ test(fn () => test([1, 2, 3])->hasKey(4))->fails('failed asserting that `array` has key `4`');
+ test(fn () => test(1)->hasKey(4))->fails('failed asserting that `1` is array');
+ }
+
+ #[Test]
+ public function missesKey(): void
+ {
+ test(fn () => test([1, 2, 3])->missesKey(2))->fails('failed asserting that `array` does not have key `2`');
+ test(fn () => test([1, 2, 3])->missesKey(4))->succeeds();
+ test(fn () => test(1)->missesKey(4))->fails('failed asserting that `1` is array');
+ }
+
+ #[Test]
+ public function instanceOf(): void
+ {
+ test(fn () => test($this)->instanceOf(self::class))->succeeds();
+ test(fn () => test('')->instanceOf(self::class))->fails("failed asserting that `''` is an instance of `'Tempest\\\\Testing\\\\Tests\\\\TesterTest'`");
+ }
+
+ #[Test]
+ public function notInstanceOf(): void
+ {
+ test(fn () => test($this)->isNotInstanceOf(self::class))
+ ->fails("failed asserting that `Tempest\\Testing\\Tests\\TesterTest` is not an instance of `'Tempest\\\\Testing\\\\Tests\\\\TesterTest'`");
+ test(fn () => test('')->isNotInstanceOf(self::class))->succeeds();
+ }
+
+ #[Test]
+ public function exceptionThrown(): void
+ {
+ test(function () {
+ test(fn () => throw new Exception())->exceptionThrown(Exception::class);
+ })->succeeds();
+
+ test(function () {
+ test(fn () => throw new InvalidArgumentException())->exceptionThrown(Exception::class);
+ })->succeeds();
+
+ test(function () {
+ test(fn () => throw new Exception())->exceptionThrown(InvalidArgumentException::class);
+ })->fails("Expected exception `'Tempest\\\\DateTime\\\\Exception\\\\InvalidArgumentException'` was not thrown, instead got `'Exception'`");
+
+ test(function () {
+ test(fn () => true)->exceptionThrown(InvalidArgumentException::class);
+ })->fails("Expected exception `'Tempest\\\\DateTime\\\\Exception\\\\InvalidArgumentException'` was not thrown");
+
+ test(function () {
+ test()->exceptionThrown(InvalidArgumentException::class);
+ })->fails('to test exceptions, the test subject must be a callable; instead got `NULL`');
+ }
+
+ #[Test]
+ public function exceptionNotThrown(): void
+ {
+ test(function () {
+ test(fn () => throw new Exception())->exceptionNotThrown(Exception::class);
+ })->fails("Exception `'Exception'` was thrown, while it shouldn't");
+
+ test(function () {
+ test(fn () => throw new InvalidArgumentException())->exceptionNotThrown(Exception::class);
+ })->fails("Exception `'Tempest\\\\DateTime\\\\Exception\\\\InvalidArgumentException'` was thrown, while it shouldn't");
+
+ test(function () {
+ test(fn () => throw new Exception())->exceptionNotThrown(InvalidArgumentException::class);
+ })->succeeds();
+
+ test(function () {
+ test()->exceptionNotThrown(InvalidArgumentException::class);
+ })->succeeds();
+ }
+
+ #[Test]
+ public function isCountable(): void
+ {
+ test(fn () => test([1, 2])->isCountable())->succeeds();
+ test(fn () => test('a')->isCountable())->fails("failed asserting that `'a'` is countable");
+ }
+
+ #[Test]
+ public function isNotCountable(): void
+ {
+ test(fn () => test([1, 2])->isNotCountable())->fails('failed asserting that `array` is not countable');
+ test(fn () => test('a')->isNotCountable())->succeeds();
+ }
+
+ #[Test]
+ public function startsWith(): void
+ {
+ test(fn () => test('abc')->startsWith('ab'))->succeeds();
+ test(fn () => test('abc')->startsWith('zz'))->fails("failed asserting that `'abc'` starts with `'zz'`");
+ test(fn () => test(1)->startsWith('zz'))->fails('failed asserting that `1` is string');
+ }
+
+ #[Test]
+ public function endsWith(): void
+ {
+ test(fn () => test('abc')->endsWith('bc'))->succeeds();
+ test(fn () => test('abc')->endsWith('zz'))->fails("failed asserting that `'abc'` ends with `'zz'`");
+ test(fn () => test(1)->endsWith('zz'))->fails('failed asserting that `1` is string');
+ }
+
+ #[Test]
+ public function startsNotWith(): void
+ {
+ test(fn () => test('abc')->startsNotWith('ab'))->fails("failed asserting that `'abc'` does not start with `'ab'`");
+ test(fn () => test('abc')->startsNotWith('zz'))->succeeds();
+ test(fn () => test(1)->startsNotWith('zz'))->fails('failed asserting that `1` is string');
+ }
+
+ #[Test]
+ public function endsNotWith(): void
+ {
+ test(fn () => test('abc')->endsNotWith('bc'))->fails("failed asserting that `'abc'` does not end with `'bc'`");
+ test(fn () => test('abc')->endsNotWith('zz'))->succeeds();
+ test(fn () => test(1)->endsNotWith('zz'))->fails('failed asserting that `1` is string');
+ }
+
+ #[Test]
+ public function isList(): void
+ {
+ test(fn () => test([1, 2, 3])->isList())->succeeds();
+ test(fn () => test([1 => 'a'])->isList())->fails('failed asserting that `array` is a list');
+ test(fn () => test('a')->isList())->fails('failed asserting that `\'a\'` is array');
+ }
+
+ #[Test]
+ public function isNotList(): void
+ {
+ test(fn () => test([1, 2, 3])->isNotList())->fails('failed asserting that `array` is not a list');
+ test(fn () => test([1 => 'a'])->isNotList())->succeeds();
+ test(fn () => test('a')->isNotList())->fails('failed asserting that `\'a\'` is array');
+ }
+
+ #[Test]
+ public function isEmpty(): void
+ {
+ test(fn () => test([])->isEmpty())->succeeds();
+ test(fn () => test('a')->isEmpty())->fails("failed asserting that `'a'` is empty");
+ }
+
+ #[Test]
+ public function isNotEmpty(): void
+ {
+ test(fn () => test('a')->isNotEmpty())->succeeds();
+ test(fn () => test('')->isNotEmpty())->fails("failed asserting that `''` is not empty");
+ }
+
+ #[Test]
+ public function greaterThan(): void
+ {
+ test(fn () => test(5)->greaterThan(4))->succeeds();
+ test(fn () => test(5)->greaterThan(5))->fails('failed asserting that `5` is greater than `5`');
+ test(fn () => test('a')->greaterThan(4))->fails('failed asserting that `\'a\'` is numeric');
+ }
+
+ #[Test]
+ public function greaterThanOrEqual(): void
+ {
+ test(fn () => test(5)->greaterThanOrEqual(5))->succeeds();
+ test(fn () => test(4)->greaterThanOrEqual(5))->fails('failed asserting that `4` is greater than or equal to `5`');
+ test(fn () => test('a')->greaterThanOrEqual(4))->fails('failed asserting that `\'a\'` is numeric');
+ }
+
+ #[Test]
+ public function lessThan(): void
+ {
+ test(fn () => test(4)->lessThan(5))->succeeds();
+ test(fn () => test(5)->lessThan(5))->fails('failed asserting that `5` is less than `5`');
+ test(fn () => test('a')->lessThan(4))->fails('failed asserting that `\'a\'` is numeric');
+ }
+
+ #[Test]
+ public function lessThanOrEqual(): void
+ {
+ test(fn () => test(5)->lessThanOrEqual(5))->succeeds();
+ test(fn () => test(6)->lessThanOrEqual(5))->fails('failed asserting that `6` is less than or equal to `5`');
+ test(fn () => test('a')->lessThanOrEqual(4))->fails('failed asserting that `\'a\'` is numeric');
+ }
+
+ #[Test]
+ public function isTrue(): void
+ {
+ test(fn () => test(true)->isTrue())->succeeds();
+ test(fn () => test(false)->isTrue())->fails('failed asserting that `false` is true');
+ }
+
+ #[Test]
+ public function isFalse(): void
+ {
+ test(fn () => test(false)->isFalse())->succeeds();
+ test(fn () => test(true)->isFalse())->fails('failed asserting that `true` is false');
+ }
+
+ #[Test]
+ public function isTrueish(): void
+ {
+ test(fn () => test(1)->isTrueish())->succeeds();
+ test(fn () => test(0)->isTrueish())->fails('failed asserting that `0` is trueish');
+ }
+
+ #[Test]
+ public function isFalseish(): void
+ {
+ test(fn () => test(0)->isFalseish())->succeeds();
+ test(fn () => test(1)->isFalseish())->fails('failed asserting that `1` is falseish');
+ }
+
+ #[Test]
+ public function isNull(): void
+ {
+ test(fn () => test(null)->isNull())->succeeds();
+ test(fn () => test(0)->isNull())->fails('failed asserting that `0` is null');
+ }
+
+ #[Test]
+ public function isNotNull(): void
+ {
+ test(fn () => test(0)->isNotNull())->succeeds();
+ test(fn () => test(null)->isNotNull())->fails('failed asserting that `NULL` is not null');
+ }
+
+ #[Test]
+ public function isArray(): void
+ {
+ test(fn () => test([1])->isArray())->succeeds();
+ test(fn () => test(1)->isArray())->fails('failed asserting that `1` is array');
+ }
+
+ #[Test]
+ public function isNotArray(): void
+ {
+ test(fn () => test([1])->isNotArray())->fails('failed asserting that `array` is not array');
+ test(fn () => test(1)->isNotArray())->succeeds();
+ }
+
+ #[Test]
+ public function isBool(): void
+ {
+ test(fn () => test(true)->isBool())->succeeds();
+ test(fn () => test(1)->isBool())->fails('failed asserting that `1` is bool');
+ }
+
+ #[Test]
+ public function isNotBool(): void
+ {
+ test(fn () => test(true)->isNotBool())->fails('failed asserting that `true` is not bool');
+ test(fn () => test(1)->isNotBool())->succeeds();
+ }
+
+ #[Test]
+ public function isFloat(): void
+ {
+ test(fn () => test(1.2)->isFloat())->succeeds();
+ test(fn () => test(1)->isFloat())->fails('failed asserting that `1` is float');
+ }
+
+ #[Test]
+ public function isNotFloat(): void
+ {
+ test(fn () => test(1.2)->isNotFloat())->fails('failed asserting that `1.2` is not float');
+ test(fn () => test(1)->isNotFloat())->succeeds();
+ }
+
+ #[Test]
+ public function isInt(): void
+ {
+ test(fn () => test(1)->isInt())->succeeds();
+ test(fn () => test(1.1)->isInt())->fails('failed asserting that `1.1` is int');
+ }
+
+ #[Test]
+ public function isNotInt(): void
+ {
+ test(fn () => test(1)->isNotInt())->fails('failed asserting that `1` is not int');
+ test(fn () => test(1.1)->isNotInt())->succeeds();
+ }
+
+ #[Test]
+ public function isNumeric(): void
+ {
+ test(fn () => test('1')->isNumeric())->succeeds();
+ test(fn () => test('a')->isNumeric())->fails("failed asserting that `'a'` is numeric");
+ }
+
+ #[Test]
+ public function isNotNumeric(): void
+ {
+ test(fn () => test('1')->isNotNumeric())->fails("failed asserting that `'1'` is not numeric");
+ test(fn () => test('a')->isNotNumeric())->succeeds();
+ }
+
+ #[Test]
+ public function isObject(): void
+ {
+ test(fn () => test((object) [])->isObject())->succeeds();
+ test(fn () => test(1)->isObject())->fails('failed asserting that `1` is object');
+ }
+
+ #[Test]
+ public function isNotObject(): void
+ {
+ test(fn () => test((object) [])->isNotObject())->fails('failed asserting that `stdClass` is not object');
+ test(fn () => test(1)->isNotObject())->succeeds();
+ }
+
+ #[Test]
+ public function isResource(): void
+ {
+ $res = fopen('php://temp', 'r');
+ test(fn () => test($res)->isResource())->succeeds();
+ test(fn () => test(1)->isResource())->fails('failed asserting that `1` is resource');
+ fclose($res);
+ }
+
+ #[Test]
+ public function isNotResource(): void
+ {
+ $res = fopen('php://temp', 'r');
+ test(fn () => test($res)->isNotResource())->fails('failed asserting that `resource` is not resource');
+ test(fn () => test(1)->isNotResource())->succeeds();
+ fclose($res);
+ }
+
+ #[Test]
+ public function isString(): void
+ {
+ test(fn () => test('a')->isString())->succeeds();
+ test(fn () => test(1)->isString())->fails('failed asserting that `1` is string');
+ }
+
+ #[Test]
+ public function isNotString(): void
+ {
+ test(fn () => test('a')->isNotString())->fails("failed asserting that `'a'` is not string");
+ test(fn () => test(1)->isNotString())->succeeds();
+ }
+
+ #[Test]
+ public function isScalar(): void
+ {
+ test(fn () => test(1)->isScalar())->succeeds();
+ test(fn () => test([])->isScalar())->fails('failed asserting that `array` is scalar');
+ }
+
+ #[Test]
+ public function isNotScalar(): void
+ {
+ test(fn () => test(1)->isNotScalar())->fails('failed asserting that `1` is not scalar');
+ test(fn () => test([])->isNotScalar())->succeeds();
+ }
+
+ #[Test]
+ public function isIterable(): void
+ {
+ test(fn () => test([1])->isIterable())->succeeds();
+ test(fn () => test(1)->isIterable())->fails('failed asserting that `1` is iterable');
+ }
+
+ #[Test]
+ public function isNotIterable(): void
+ {
+ test(fn () => test([1])->isNotIterable())->fails('failed asserting that `array` is not iterable');
+ test(fn () => test(1)->isNotIterable())->succeeds();
+ }
+
+ #[Test]
+ public function isJson(): void
+ {
+ test(fn () => test('{"a":1}')->isJson())->succeeds();
+ test(fn () => test('not json')->isJson())->fails("failed asserting that `'not json'` is valid JSON");
+ test(fn () => test(1)->isJson())->fails('failed asserting that `1` is string');
+ }
+
+ #[Test]
+ public function isNotJson(): void
+ {
+ test(fn () => test('{"a":1}')->isNotJson())->fails("failed asserting that `'{\"a\":1}'` is not valid JSON");
+ test(fn () => test('not json')->isNotJson())->succeeds();
+ test(fn () => test(1)->isNotJson())->succeeds();
+ }
+}
diff --git a/packages/testing/tests/TestsEventsTest.php b/packages/testing/tests/TestsEventsTest.php
new file mode 100644
index 0000000000..18d789823f
--- /dev/null
+++ b/packages/testing/tests/TestsEventsTest.php
@@ -0,0 +1,32 @@
+events->preventPropagation();
+
+ event(new TestEvent());
+
+ $this->events->wasDispatched(TestEvent::class);
+ }
+
+ #[Test]
+ public function was_not_dispatched(): void
+ {
+ $this->events->preventPropagation();
+
+ $this->events->wasNotDispatched(TestEvent::class);
+ }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 9c6b4da34f..51b4e0d3ed 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -25,6 +25,7 @@
src/Tempest/**/tests
+ packages/testing
src
diff --git a/tests/Integration/Console/Fixtures/TestCommand.php b/tests/Integration/Console/Fixtures/FixtureCommand.php
similarity index 96%
rename from tests/Integration/Console/Fixtures/TestCommand.php
rename to tests/Integration/Console/Fixtures/FixtureCommand.php
index 1a8fefebe4..2afe70aa3e 100644
--- a/tests/Integration/Console/Fixtures/TestCommand.php
+++ b/tests/Integration/Console/Fixtures/FixtureCommand.php
@@ -7,7 +7,7 @@
use Tempest\Console\Console;
use Tempest\Console\ConsoleCommand;
-final readonly class TestCommand
+final readonly class FixtureCommand
{
public function __construct(
private Console $console,
diff --git a/tests/Integration/Console/Fixtures/MyConsole.php b/tests/Integration/Console/Fixtures/MyConsole.php
index d45b635e81..a42dddda32 100644
--- a/tests/Integration/Console/Fixtures/MyConsole.php
+++ b/tests/Integration/Console/Fixtures/MyConsole.php
@@ -8,7 +8,7 @@
final class MyConsole
{
- #[ConsoleCommand(name: 'test', description: 'description')]
+ #[ConsoleCommand(name: 'fixture', description: 'description')]
public function handle(
string $path,
TestStringEnum $type,
diff --git a/tests/Integration/Console/Middleware/ValidateNamedArgumentsMiddlewareTest.php b/tests/Integration/Console/Middleware/ValidateNamedArgumentsMiddlewareTest.php
index 4634486405..3ad30f860b 100644
--- a/tests/Integration/Console/Middleware/ValidateNamedArgumentsMiddlewareTest.php
+++ b/tests/Integration/Console/Middleware/ValidateNamedArgumentsMiddlewareTest.php
@@ -9,7 +9,7 @@ final class ValidateNamedArgumentsMiddlewareTest extends FrameworkIntegrationTes
public function test_invalid_parameters_throw_exception(): void
{
$this->console
- ->call('test:flags --unknown --foo --no-flag --help --force --no-interaction')
+ ->call('fixture:flags --unknown --foo --no-flag --help --force --no-interaction')
->assertError()
->assertContains('unknown')
->assertDoesNotContain('foo')
diff --git a/tests/Integration/Console/RenderConsoleCommandTest.php b/tests/Integration/Console/RenderConsoleCommandTest.php
index dcfab60805..bd421d50bd 100644
--- a/tests/Integration/Console/RenderConsoleCommandTest.php
+++ b/tests/Integration/Console/RenderConsoleCommandTest.php
@@ -55,7 +55,7 @@ public function test_render(): void
(new RenderConsoleCommand($this->testConsole))($consoleCommand);
$this->assertSame(
- 'test description',
+ 'fixture description',
trim($this->consoleOutput->getBufferWithoutFormatting()[0]),
);
}
@@ -76,7 +76,7 @@ public function test_render_arguments(): void
$renderConsoleCommand($consoleCommand);
$this->assertSame(
- 'test [fallback=a {a|b|c}] [nullable-enum=null {a|b|c}] [times=1] [--force=false] [optional=null] description',
+ 'fixture [fallback=a {a|b|c}] [nullable-enum=null {a|b|c}] [times=1] [--force=false] [optional=null] description',
trim($this->consoleOutput->getBufferWithoutFormatting()[0]),
);
}