Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Snapshots #4

Merged
merged 16 commits into from
Nov 22, 2023
1 change: 1 addition & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jobs:
php src/bin/generate-docs.php src/web/BenchmarkResult.php docs/assertions/benchmark.md
php src/bin/generate-docs.php src/test/CookieState.php docs/cookies.md
php src/bin/generate-docs.php src/test/ActingAs.php docs/logging-in.md
php src/bin/generate-docs.php src/test/SnapshotAssertions.php docs/snapshots.md

- name: Add files
run: git add -A docs/
Expand Down
6 changes: 6 additions & 0 deletions docs/assertions/element.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,10 @@ Check that the element does not have its `dateDeleted` flag set

```php
Entry::factory()->create()->assertNotTrashed();
```

## assertMatchesSnapshot($args)
Check that an element matches a snapshot of its attributes.
```php
Entry::factory()->create()->assertMatchesSnapshot();
```
42 changes: 42 additions & 0 deletions docs/snapshots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Snapshots
A variety of snapshot assertions are available to help you test your HTML and DOM output in craft-pest. In
many places you can simply expect an object `->toMatchSnapshot()` and Pest will handle the rest for you.
The two entrypoints to snapshotting are,
- `expect($object)->toMatchSnapshot()`
- `$this->assertMatchesSnapshot()`
For example, responses, DOM Lists, and views are all snapshotable.
```php
it('matches responses')->get('/')->assertMatchesSnapshot();
it('matches dom lists')->get('/')->querySelector('h1')->assertMatchesSnapshot();
it('matches views')->renderTemplate('_news/entry', $variables)->assertMatchesSnapshot();
```
## Elements
Many elements can be snapshotted as well. When using assertions Pest will automatically handle the
conversion from an Element to a snapshot.
```php
Entry::factory()->title('foo')->create()->assertMatchesSnapshot();
```
Unfortunately, Pest is not smart enough to properly snapshot elements in an expectation so you must
call `->toSnapshot()` on them first.
```php
it('imports entries', function () {
$this->importEntries();
$entry = Entry::find()->section('news')->one();
expect($entry->toSnapshot())->toMatchSnapshot();
});
```
## Attributes
Only a subset of attributes from the element are snapshotted so dynamic fields do not create
unexpected failures. For example, the `$entry->postDate` defaults to the current time when
the entry was generated. That means running the test on Tuesday and again on Wednesday would
fail the test because the `postDate` would be Tuesday in the initial snapshot and Wednesday
during the comparison.
If you'd like to include additional attributes in the snapshot you can pass them as an array
to the `->toSnapshot()` method. For example,
```php
it('snapshots postDate', function () {
$entry = Entry::factory()->postDate('2022-01-01')->create();
expect($entry->toSnapshot(['postDate']))->toMatchSnapshot();
$entry->assertMatchesSnapshot(['postDate']);
});
```
2 changes: 2 additions & 0 deletions src/Pest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use markhuot\craftpest\actions\RenderCompiledClasses;
use markhuot\craftpest\behaviors\ExpectableBehavior;
use markhuot\craftpest\behaviors\FieldTypeHintBehavior;
use markhuot\craftpest\behaviors\SnapshotableBehavior;
use markhuot\craftpest\behaviors\TestableElementBehavior;
use markhuot\craftpest\behaviors\TestableElementQueryBehavior;
use markhuot\craftpest\console\PestController;
Expand All @@ -39,6 +40,7 @@ function bootstrap($app)
function (DefineBehaviorsEvent $event) {
$event->behaviors[] = ExpectableBehavior::class;
$event->behaviors[] = TestableElementBehavior::class;
$event->behaviors[] = SnapshotableBehavior::class;
}
);

Expand Down
51 changes: 51 additions & 0 deletions src/behaviors/SnapshotableBehavior.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace markhuot\craftpest\behaviors;

use craft\base\ElementInterface;
use craft\elements\db\ElementQuery;
use craft\elements\ElementCollection;
use Illuminate\Support\Collection;
use yii\base\Behavior;

/**
* @property ElementInterface $owner
*/
class SnapshotableBehavior extends Behavior
{
/**
* @param array $extraAttributes Any additional fields that should be included in the snapshot
* @param array $attributes The default list of attributes that should be included in a snapshot
*/
public function toSnapshot(array $extraAttributes=[], array $attributes=['title', 'slug', 'isDraft', 'isRevision', 'isNewForSite', 'isUnpublishedDraft', 'enabled', 'archived', 'uri', 'trashed', 'ref', 'status', 'url'])
{
$customFields = collect($this->owner->getFieldLayout()->getCustomFields())
->mapWithKeys(function ($field) {
return [$field->handle => $field];
})

// remove any ElementQueries from the element so we don't try to snapshot
// a serialized query. It will never match because it may have a dynamic `->where()`
// or an `->ownerId` that changes with each generated element.
->filter(fn ($field, $handle) => ! ($this->owner->{$handle} instanceof ElementQuery))

// snapshot any eager loaded element queries so nested elements are downcasted
// to a reproducible array
->map(function ($value, $handle) {
if ($this->owner->{$handle} instanceof ElementCollection) {
$value = $this->owner->{$handle};
return $value->map->toSnapshot(); // @phpstan-ignore-line can't get PHPStan to reason about the ->map higher order callable
}

return $value;
});

return $customFields->merge(
collect($attributes)->merge($extraAttributes)
->mapWithKeys(fn ($attribute) => [
$attribute => $this->owner->{$attribute} ?? null,
])
)
->all();
}
}
16 changes: 15 additions & 1 deletion src/behaviors/TestableElementBehavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*
* Elements, like entries, and be tested in Craft via the following assertions.
*
* @property \craft\base\Element $owner
* @property \craft\base\Element|SnapshotableBehavior $owner
*/
class TestableElementBehavior extends Behavior
{
Expand Down Expand Up @@ -96,4 +96,18 @@ function assertNotTrashed()

return $this->owner;
}

/**
* Check that an element matches a snapshot of its attributes.
*
* ```php
* Entry::factory()->create()->assertMatchesSnapshot();
* ```
*/
function assertMatchesSnapshot(...$args)
{
expect($this->owner->toSnapshot(...$args))->toMatchSnapshot();

return $this->owner;
}
}
15 changes: 15 additions & 0 deletions src/dom/NodeList.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace markhuot\craftpest\dom;

use markhuot\craftpest\http\RequestBuilder;
use markhuot\craftpest\test\SnapshotAssertions;
use Pest\Expectation;
use PHPUnit\Framework\Assert;

Expand All @@ -19,6 +20,8 @@
*/
class NodeList implements \Countable
{
use SnapshotAssertions;

/** @var \Symfony\Component\DomCrawler\Crawler */
public $crawler;

Expand Down Expand Up @@ -232,4 +235,16 @@ public function assertCount($expected) {

return $this;
}

public function toArray()
{
$result = [];

for ($i=0; $i<$this->crawler->count(); $i++) {
$node = $this->crawler->eq($i);
$result[] = $node->outerHtml();
}

return $result;
}
}
23 changes: 17 additions & 6 deletions src/factories/Section.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* @method self name(string $name)
* @method self handle(string $name)
* @method self type(string $type)
* @method \craft\models\Section|Collection create()
* @method \craft\models\Section|Collection<\craft\models\Section> create(array $definition = [])
*/
class Section extends Factory {

Expand Down Expand Up @@ -72,13 +72,23 @@ function newElement()
*/
function definition(int $index = 0) {
$name = $this->faker->words(2, true);
$handle = StringHelper::toCamelCase($name);

return [
'name' => $name,
'handle' => $handle,
'type' => 'channel',
'siteSettings' => collect(\Craft::$app->sites->getAllSites())->mapWithkeys(function ($site) use ($name, $handle) {
];
}

public function inferences(array $definition = [])
{
if (! empty($definition['name']) && empty($definition['handle'])) {
$definition['handle'] = StringHelper::toCamelCase($definition['name']);
}

$name = $definition['name'];
$handle = $definition['handle'];
$definition['siteSettings'] = collect(\Craft::$app->sites->getAllSites())
->mapWithkeys(function ($site) use ($name, $handle) {
$settings = new Section_SiteSettings();
$settings->siteId = $site->id;
$settings->hasUrls = $this->hasUrls;
Expand All @@ -90,8 +100,9 @@ function definition(int $index = 0) {
]);

return [$site->id => $settings];
})->toArray(),
];
})->toArray();

return $definition;
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/helpers/Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,7 @@ function dd(...$args)
dump(...$args);
die;
}

expect()->extend('toMatchElementSnapshot', function (...$args) {
$this->toSnapshot(...$args)->toMatchSnapshot(); // @phpstan-ignore-line
});
72 changes: 72 additions & 0 deletions src/test/SnapshotAssertions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

namespace markhuot\craftpest\test;

/**
* # Snapshots
*
* A variety of snapshot assertions are available to help you test your HTML and DOM output in craft-pest. In
* many places you can simply expect an object `->toMatchSnapshot()` and Pest will handle the rest for you.
*
* The two entrypoints to snapshotting are,
* - `expect($object)->toMatchSnapshot()`
* - `$this->assertMatchesSnapshot()`
*
* For example, responses, DOM Lists, and views are all snapshotable.
*
* ```php
* it('matches responses')->get('/')->assertMatchesSnapshot();
* it('matches dom lists')->get('/')->querySelector('h1')->assertMatchesSnapshot();
* it('matches views')->renderTemplate('_news/entry', $variables)->assertMatchesSnapshot();
* ```
*
* ## Elements
*
* Many elements can be snapshotted as well. When using assertions Pest will automatically handle the
* conversion from an Element to a snapshot.
*
* ```php
* Entry::factory()->title('foo')->create()->assertMatchesSnapshot();
* ```
*
* Unfortunately, Pest is not smart enough to properly snapshot elements in an expectation so you must
* call `->toSnapshot()` on them first.
*
* ```php
* it('imports entries', function () {
* $this->importEntries();
* $entry = Entry::find()->section('news')->one();
*
* expect($entry->toSnapshot())->toMatchSnapshot();
* });
* ```
*
* ## Attributes
*
* Only a subset of attributes from the element are snapshotted so dynamic fields do not create
* unexpected failures. For example, the `$entry->postDate` defaults to the current time when
* the entry was generated. That means running the test on Tuesday and again on Wednesday would
* fail the test because the `postDate` would be Tuesday in the initial snapshot and Wednesday
* during the comparison.
*
* If you'd like to include additional attributes in the snapshot you can pass them as an array
* to the `->toSnapshot()` method. For example,
*
* ```php
* it('snapshots postDate', function () {
* $entry = Entry::factory()->postDate('2022-01-01')->create();
*
* expect($entry->toSnapshot(['postDate']))->toMatchSnapshot();
* $entry->assertMatchesSnapshot(['postDate']);
* });
* ```
*/
trait SnapshotAssertions
{
public function assertMatchesSnapshot(): self
{
expect($this)->toMatchSnapshot();

return $this;
}
}
11 changes: 10 additions & 1 deletion src/test/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use markhuot\craftpest\actions\CallSeeders;
use markhuot\craftpest\actions\RenderCompiledClasses;
use markhuot\craftpest\console\TestableResponse;
use markhuot\craftpest\web\ViewResponse;
use Symfony\Component\Process\Process;

class TestCase extends \PHPUnit\Framework\TestCase {
Expand All @@ -17,7 +18,8 @@ class TestCase extends \PHPUnit\Framework\TestCase {
Benchmark,
CookieState,
Dd,
WithExceptionHandling;
WithExceptionHandling,
SnapshotAssertions;

public Collection $seedData;

Expand Down Expand Up @@ -234,4 +236,11 @@ public function console(array|string $command)
return new TestableResponse($exitCode, $stdout, $stderr);
}

public function renderTemplate(...$args)
{
$content = \Craft::$app->getView()->renderTemplate(...$args);

return new \markhuot\craftpest\web\TestableResponse(['content' => $content]);
}

}
7 changes: 7 additions & 0 deletions src/web/TestableResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use markhuot\craftpest\behaviors\ExpectableBehavior;
use markhuot\craftpest\behaviors\TestableResponseBehavior;
use markhuot\craftpest\test\Dd;
use markhuot\craftpest\test\SnapshotAssertions;

/**
* @mixin ExpectableBehavior
Expand All @@ -13,6 +14,7 @@
class TestableResponse extends \craft\web\Response
{
use Dd;
use SnapshotAssertions;

public function behaviors(): array
{
Expand All @@ -35,4 +37,9 @@ public function prepare(): void
{
parent::prepare();
}

public function toString()
{
return $this->content;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[
"<ul>\n <li>one<\/li>\n <li>two<\/li>\n <li>three<\/li>\n <\/ul>",
"<ul>\n <li>one<\/li>\n <li>two<\/li>\n <li>three<\/li>\n <\/ul>"
]
Loading