From ae1d8ae39d0da3cc27de94cc69ec174bc6ae33f7 Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 23 Jun 2026 14:41:01 +0200 Subject: [PATCH 1/4] wip --- composer.json | 3 +- docs/1-essentials/07-testing.md | 59 ++++++++ .../Framework/Testing/ModelFactory.php | 100 +++++++++++++ .../Testing/ModelFactoryCollection.php | 43 ++++++ src/Tempest/Framework/Testing/functions.php | 13 ++ .../Integration/Testing/ModelFactoryTest.php | 140 ++++++++++++++++++ 6 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 src/Tempest/Framework/Testing/ModelFactory.php create mode 100644 src/Tempest/Framework/Testing/ModelFactoryCollection.php create mode 100644 src/Tempest/Framework/Testing/functions.php create mode 100644 tests/Integration/Testing/ModelFactoryTest.php diff --git a/composer.json b/composer.json index 3019af8f26..1efbf30236 100644 --- a/composer.json +++ b/composer.json @@ -203,7 +203,8 @@ "packages/support/src/Uri/functions.php", "packages/support/src/functions.php", "packages/view/src/functions.php", - "packages/vite/src/functions.php" + "packages/vite/src/functions.php", + "src/Tempest/Framework/Testing/functions.php" ] }, "autoload-dev": { diff --git a/docs/1-essentials/07-testing.md b/docs/1-essentials/07-testing.md index f4f98fe5ed..bb16483798 100644 --- a/docs/1-essentials/07-testing.md +++ b/docs/1-essentials/07-testing.md @@ -73,6 +73,65 @@ return new SQLiteConfig( ); ``` +## Model factories + +When you need a quick way to generate objects, you can use the `factory()` function to generate dummy data. + +```php +use function Tempest\Framework\Testing\factory; + +$book = factory(Book::class)->make(); +``` + +You can also use the `save()` method to directly save a model to the database: + +```php +$book = factory(Book::class)->save(); +``` + +Factories can be configured with additional data: + +```php +$book = factory(Book::class)->with(title: 'Timeline Taxi')->make(); +``` + +Fields with missing data will be randomly generated. + +You can also create multiple instances of objects at once: + +```php +$book = factory(Book::class)->times(3)->make(); +``` + +Which can also be saved directly to the database: + +```php +$book = factory(Book::class)->times(3)->save(); +``` + +You can specify a sequence of data to be used as well: + +```php +$book = factory(Book::class)->times([ + ['title' => 'Timeline Taxi'], + ['title' => 'Red Rising'], +])->make(); +``` + +Finally, you can pass in pre-configured factories as field values: + +```php +$authorFactory = factory(Author::class)->with(name: 'Brent'); + +$book = factory(Book::class) + ->with(author: $authorFactory) + ->make(); +``` + +:::info +Model factories are immutable, so you can create multiple variations of base factories using multiple `with()` calls. +::: + ## Spoofing the environment By default, Tempest provides a `phpunit.xml` that sets the `ENVIRONMENT` variable to `testing`. This is needed so that Tempest can adapt its boot process and load the proper configuration files for the testing environment. diff --git a/src/Tempest/Framework/Testing/ModelFactory.php b/src/Tempest/Framework/Testing/ModelFactory.php new file mode 100644 index 0000000000..613ea1e9e8 --- /dev/null +++ b/src/Tempest/Framework/Testing/ModelFactory.php @@ -0,0 +1,100 @@ + */ + private readonly string $modelClass, + ) {} + + /** @return ModelFactoryCollection */ + public function times(int|array $items): ModelFactoryCollection + { + return new ModelFactoryCollection($this, $items); + } + + /** @return self */ + public function with(mixed ...$properties): self + { + return clone($this, [ + 'fields' => [ + ...$this->fields, + ...$properties, + ], + ]); + } + + /** @return TModelClass */ + public function make() + { + $fields = $this->fields; + + foreach ($fields as $key => $value) { + if (! $value instanceof ModelFactory) { + continue; + } + + $fields[$key] = $value->make(); + } + + $model = map($fields)->to($this->modelClass); + + foreach (reflect($model)->getPublicProperties() as $property) { + if ($property->isInitialized($model)) { + continue; + } + + if ($property->hasDefaultValue()) { + continue; + } + + if ($property->isNullable()) { + $property->setValue($model, null); + + continue; + } + + $value = $this->generateValue($property); + + if ($value === null) { + continue; + } + + $property->setValue($model, $value); + } + + return $model; + } + + /** @return TModelClass */ + public function save() + { + $model = $this->make(); + + $model->save(); + + return $model; + } + + private function generateValue(PropertyReflector $property): mixed + { + return match ($property->getType()->getName()) { + 'string' => arr(['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet'])->random(), + 'int' => random_int(1, 100), + 'float' => random_int(100, 1000) / 10, + 'bool' => arr([true, false])->random(), + default => null, + }; + } +} diff --git a/src/Tempest/Framework/Testing/ModelFactoryCollection.php b/src/Tempest/Framework/Testing/ModelFactoryCollection.php new file mode 100644 index 0000000000..4c544cf13b --- /dev/null +++ b/src/Tempest/Framework/Testing/ModelFactoryCollection.php @@ -0,0 +1,43 @@ + */ + private ModelFactory $modelFactory, + private int|array $items, + ) {} + + /** @return TModelClass[] */ + public function make(): array + { + $items = []; + + if (is_int($this->items)) { + for ($i = 0; $i < $this->items; $i++) { + $items[] = $this->modelFactory->make(); + } + } else { + foreach ($this->items as $item) { + $items[] = $this->modelFactory->with(...$item)->make(); + } + } + + return $items; + } + + /** @return TModelClass[] */ + public function save(): array + { + $items = $this->make(); + + foreach ($items as $item) { + $item->save(); + } + + return $items; + } +} diff --git a/src/Tempest/Framework/Testing/functions.php b/src/Tempest/Framework/Testing/functions.php new file mode 100644 index 0000000000..6c958cc309 --- /dev/null +++ b/src/Tempest/Framework/Testing/functions.php @@ -0,0 +1,13 @@ + $modelClass + * @return ModelFactory + */ +function factory(string $modelClass): ModelFactory +{ + return new ModelFactory($modelClass); +} diff --git a/tests/Integration/Testing/ModelFactoryTest.php b/tests/Integration/Testing/ModelFactoryTest.php new file mode 100644 index 0000000000..e560599616 --- /dev/null +++ b/tests/Integration/Testing/ModelFactoryTest.php @@ -0,0 +1,140 @@ +make(); + + $this->assertInstanceOf(Book::class, $book); + // @phpstan-ignore-next-line + $this->assertNotNull($book->title); + } + + #[Test] + public function test_make_with_data(): void + { + $author = new Author(name: 'Brent'); + + $factory = factory(Book::class)->with(author: $author); + + $a = $factory->with(title: 'A')->make(); + $b = $factory->with(title: 'B')->make(); + + $this->assertSame('A', $a->title); + $this->assertSame($author, $a->author); + $this->assertSame('B', $b->title); + $this->assertSame($author, $b->author); + } + + #[Test] + public function test_with_is_immutable(): void + { + $factory = factory(Book::class)->with(title: 'A'); + + $factory->with(title: 'B'); + + $a = $factory->make(); + + $this->assertSame('A', $a->title); + } + + #[Test] + public function test_with_nested_factories(): void + { + $bookFactory = factory(Book::class); + + $authorFactory = factory(Author::class)->with(name: 'Brent'); + + $bookFactory = $bookFactory->with(author: $authorFactory); + + $book = $bookFactory->make(); + + $this->assertSame('Brent', $book->author->name); + } + + #[Test] + public function test_times(): void + { + $books = factory(Book::class)->times(3)->make(); + + $this->assertCount(3, $books); + } + + #[Test] + public function test_times_with_items(): void + { + $books = factory(Book::class)->times([ + ['title' => 'A'], + ['title' => 'B'], + ['title' => 'C'], + ])->make(); + + $this->assertCount(3, $books); + + $this->assertSame('A', $books[0]->title); + $this->assertSame('B', $books[1]->title); + $this->assertSame('C', $books[2]->title); + } + + #[Test] + public function test_times_with_nested_factories(): void + { + $authorFactory = factory(Author::class)->with(name: 'Brent'); + + $books = factory(Book::class)->times([ + ['author' => $authorFactory->with(name: 'Roose')], + ['author' => $authorFactory], + ['author' => $authorFactory], + ])->make(); + + $this->assertSame('Roose', $books[0]->author->name); + $this->assertSame('Brent', $books[1]->author->name); + $this->assertSame('Brent', $books[2]->author->name); + } + + #[Test] + public function test_save_to_the_database(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $book = factory(Book::class)->save(); + + $this->database->assertTableHasRow('books', id: $book->id, title: $book->title); + } + + #[Test] + public function test_items_save_to_the_database(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + [$a, $b] = factory(Book::class)->times(2)->save(); + + $this->database->assertTableHasRow('books', id: $a->id, title: $a->title); + $this->database->assertTableHasRow('books', id: $b->id, title: $b->title); + } +} From adeec9fc185adf6b378860eae2ee83097ae51b25 Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 23 Jun 2026 14:54:28 +0200 Subject: [PATCH 2/4] wip --- tests/Integration/Testing/ModelFactoryTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/Integration/Testing/ModelFactoryTest.php b/tests/Integration/Testing/ModelFactoryTest.php index e560599616..ff4d350884 100644 --- a/tests/Integration/Testing/ModelFactoryTest.php +++ b/tests/Integration/Testing/ModelFactoryTest.php @@ -122,6 +122,21 @@ public function test_save_to_the_database(): void $this->database->assertTableHasRow('books', id: $book->id, title: $book->title); } + #[Test] + public function test_save_to_the_database_with_nested_relation(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + factory(Book::class)->with(author: factory(Author::class)->with(name: 'Brent'))->save(); + + $this->database->assertTableHasRow('authors', name: 'Brent'); + } + #[Test] public function test_items_save_to_the_database(): void { From c13d807fa3cd825f29ab9820533e690b622b2a90 Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 23 Jun 2026 15:00:24 +0200 Subject: [PATCH 3/4] wip --- .../Framework/Testing/ModelFactory.php | 34 +++++++++++++++---- .../Testing/ModelFactoryCollection.php | 12 +++++-- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/Tempest/Framework/Testing/ModelFactory.php b/src/Tempest/Framework/Testing/ModelFactory.php index 613ea1e9e8..af0872aff0 100644 --- a/src/Tempest/Framework/Testing/ModelFactory.php +++ b/src/Tempest/Framework/Testing/ModelFactory.php @@ -14,28 +14,44 @@ final class ModelFactory private array $fields = []; public function __construct( - /** @var class-string */ + /** @var class-string The model class to create an instance of. */ private readonly string $modelClass, ) {} - /** @return ModelFactoryCollection */ + /** + * Make multiple instances of the model class. + * + * @param int|array $items The number of instances to make, or an array with field values used as a sequence to generate multiple instances. + * + * @return ModelFactoryCollection + */ public function times(int|array $items): ModelFactoryCollection { return new ModelFactoryCollection($this, $items); } - /** @return self */ - public function with(mixed ...$properties): self + /** + * Set up values that should be used for specific fields when creating a model instance + * + * @var mixed ...$fields If another instance of a ModelFactory is passed, it will be used to create the value for that property. + * + * @return self + */ + public function with(mixed ...$fields): self { return clone($this, [ 'fields' => [ ...$this->fields, - ...$properties, + ...$fields, ], ]); } - /** @return TModelClass */ + /** + * Make an instance of the model class. + * + * @return TModelClass + */ public function make() { $fields = $this->fields; @@ -77,7 +93,11 @@ public function make() return $model; } - /** @return TModelClass */ + /** + * Make an instance of the model class and save it to the database. + * + * @return TModelClass + */ public function save() { $model = $this->make(); diff --git a/src/Tempest/Framework/Testing/ModelFactoryCollection.php b/src/Tempest/Framework/Testing/ModelFactoryCollection.php index 4c544cf13b..8af388aa74 100644 --- a/src/Tempest/Framework/Testing/ModelFactoryCollection.php +++ b/src/Tempest/Framework/Testing/ModelFactoryCollection.php @@ -11,7 +11,11 @@ public function __construct( private int|array $items, ) {} - /** @return TModelClass[] */ + /** + * Make instances of the model class. + * + * @return TModelClass[] + */ public function make(): array { $items = []; @@ -29,7 +33,11 @@ public function make(): array return $items; } - /** @return TModelClass[] */ + /** + * Make instances of the model class and save them to the database. + * + * @return TModelClass[] + */ public function save(): array { $items = $this->make(); From 00eb53e5bfa4f4890162e41aca721a8eb9998871 Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 23 Jun 2026 15:07:15 +0200 Subject: [PATCH 4/4] wip --- src/Tempest/Framework/Testing/ModelFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tempest/Framework/Testing/ModelFactory.php b/src/Tempest/Framework/Testing/ModelFactory.php index af0872aff0..285504a486 100644 --- a/src/Tempest/Framework/Testing/ModelFactory.php +++ b/src/Tempest/Framework/Testing/ModelFactory.php @@ -33,7 +33,7 @@ public function times(int|array $items): ModelFactoryCollection /** * Set up values that should be used for specific fields when creating a model instance * - * @var mixed ...$fields If another instance of a ModelFactory is passed, it will be used to create the value for that property. + * @param mixed ...$fields If another instance of a ModelFactory is passed, it will be used to create the value for that property. * * @return self */