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..285504a486 --- /dev/null +++ b/src/Tempest/Framework/Testing/ModelFactory.php @@ -0,0 +1,120 @@ + The model class to create an instance of. */ + private readonly string $modelClass, + ) {} + + /** + * 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); + } + + /** + * Set up values that should be used for specific fields when creating a model instance + * + * @param 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, + ...$fields, + ], + ]); + } + + /** + * Make an instance of the model class. + * + * @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; + } + + /** + * Make an instance of the model class and save it to the database. + * + * @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..8af388aa74 --- /dev/null +++ b/src/Tempest/Framework/Testing/ModelFactoryCollection.php @@ -0,0 +1,51 @@ + */ + private ModelFactory $modelFactory, + private int|array $items, + ) {} + + /** + * Make instances of the model class. + * + * @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; + } + + /** + * Make instances of the model class and save them to the database. + * + * @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..ff4d350884 --- /dev/null +++ b/tests/Integration/Testing/ModelFactoryTest.php @@ -0,0 +1,155 @@ +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_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 + { + $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); + } +}