Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
59 changes: 59 additions & 0 deletions docs/1-essentials/07-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
120 changes: 120 additions & 0 deletions src/Tempest/Framework/Testing/ModelFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

namespace Tempest\Framework\Testing;

use Tempest\Reflection\PropertyReflector;

use function Tempest\Mapper\map;
use function Tempest\Reflection\reflect;
use function Tempest\Support\arr;

/** @template TModelClass */
final class ModelFactory
{
private array $fields = [];

public function __construct(
/** @var class-string<TModelClass> 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<TModelClass>
*/
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<TModelClass>
*/
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,
};
}
}
51 changes: 51 additions & 0 deletions src/Tempest/Framework/Testing/ModelFactoryCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace Tempest\Framework\Testing;

/** @template TModelClass */
final readonly class ModelFactoryCollection
{
public function __construct(
/** @var ModelFactory<TModelClass> */
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;
}
}
13 changes: 13 additions & 0 deletions src/Tempest/Framework/Testing/functions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Tempest\Framework\Testing;

/**
* @template TModelClass
* @param class-string<TModelClass> $modelClass
* @return ModelFactory<TModelClass>
*/
function factory(string $modelClass): ModelFactory
{
return new ModelFactory($modelClass);
}
Loading
Loading