---
title: "Testing — Tags for Laravel | Roundly"
description: "Tags::fake() records every operation and still performs it — every assertion and its negative twin, recorded operations, Pest matchers and the factory."
url: https://roundly-consulting.com/open-source/docs/tags-for-laravel/testing
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Tags for Laravel](https://roundly-consulting.com/open-source/docs/tags-for-laravel.md)

# Testing

Tags::fake() swaps the manager for a TagsFake and returns it:

```php
use RoundlyConsulting\Tags\Facades\Tags;
use RoundlyConsulting\Tags\Models\Tag;

it('labels a concert', function () {
    $tags = Tags::fake();

    $concert = Concert::factory()->create();
    $concert->attachTags(['live'], 'label');           // what your action does inside

    $tags->assertTagged($concert, ['live'], type: 'label');
    $tags->assertNothingUntagged();
    $tags->assertTagCreated(fn (Tag $tag) => $tag->slug === 'live');
});
```

- It is a recording, still-performing fake: every call runs for real — rows, counts, events — and each successful operation is recorded. A call that throws records nothing.
- It is swapped in behind the facade and in the container, and it extends TagsManager, so an injected manager is faked and still type-checks. It records the facade, an injected manager, a handle, a builder, a model method and the JSON API; calling an action class directly is not recorded.
- Because it performs, a test using the fake needs the database (the published migrations) like any other.
- It does not fake Laravel’s events — use Event::fake() next to it.

## Assertions

Assertions come in pairs: assert<Verb>() with assertNothing<Verb>() for the tagging verbs, assert<Noun><Verb>() with assertNo<Noun><Verb>() for the catalog. Every one is documented on the facade too, so Tags::assertTagged(…) works:

| Assertion | Negative | Meaning |
| --- | --- | --- |
| `assertTagged($model, ?array $tags = null, $type = null)` | `assertNothingTagged()` | Tags attached by an attach or a sync — optionally these (models, or names in the current locale) of this type. |
| `assertUntagged($model, ?array $tags = null, $type = null)` | `assertNothingUntagged()` | Tags detached by a detach, a sync or a clear. |
| `assertSynced($model, $type, ?array $tags = null)` | `assertNothingSynced()` | The type was synced — with tags, the model now carries exactly these. |
| `assertCleared($model, $type = null)` | `assertNothingCleared()` | A clear of the type (null: any) — the force-delete hook’s clear counts. |
| `assertTagCreated(?Closure $callback = null)` | `assertNoTagCreated()` | create(), a findOrCreate() that created, or a free-form name an attach or sync created. A restored tag counts as restored. |
| `assertTagUpdated(?Closure $callback = null)` | `assertNoTagUpdated()` | update(), rename() or edit()->save(). |
| `assertTagMoved(?Closure $callback = null)` | `assertNoTagMoved()` | move(). |
| `assertTagDeleted(?Closure $callback = null)` | `assertNoTagDeleted()` | delete(). |
| `assertTagRestored(?Closure $callback = null)` | `assertNoTagRestored()` | restore(), or a deleted tag a create, findOrCreate, attach or sync brought back. |
| `assertTagsReordered($type = null)` | `assertNoTagsReordered()` | reorder(), optionally of one type. |
| `assertRepaired(?Closure $callback = null)` | `assertNothingRepaired()` | Tags::repair() or tags:repair; the callback gets the RepairReport. |
| `assertNothingHappened()` | — | No operation was recorded at all. |

```php
$tags->assertTagged($concert);                              // any tag attached (an attach or a sync)
$tags->assertTagged($concert, [$jazz, 'live'], 'label');   // these tags — models, or names in the current locale
$tags->assertUntagged($concert, ['outdoor'], 'label');     // a detach, a sync or a clear
$tags->assertSynced($concert, 'genre', [$jazz]);           // synced, and the model now carries exactly these
$tags->assertCleared($concert, 'label');                   // null type = any clear (a force delete's included)

$tags->assertTagCreated(fn (Tag $tag) => $tag->type === 'genre');
$tags->assertTagUpdated();
$tags->assertTagMoved();
$tags->assertTagDeleted();
$tags->assertTagRestored();
$tags->assertTagsReordered('genre');
$tags->assertRepaired(fn (RepairReport $report) => $report->isClean());

$tags->assertNothingHappened();
```

## Recorded operations

$fake->recorded(?TagOperation $operation = null) returns the raw RecordedTagOperation records in call order. TagOperation lists every mutating verb: attach, detach, sync, clear, create\_tag, find\_or\_create\_tag, update\_tag, move\_tag, reorder\_tags, delete\_tag, restore\_tag and repair:

```php
use RoundlyConsulting\Tags\Enums\TagOperation;

foreach ($tags->recorded(TagOperation::Sync) as $operation) {
    $operation->operation;        // TagOperation::Sync
    $operation->context;          // ['taggable' => …, 'tags' => […], 'type' => 'genre']
    $operation->result;           // TagChanges
    $operation->changes();        // TagChanges, or null for a catalog operation
    $operation->concerns($concert);
    $operation->changesType();    // 'genre'
}
```

## Pest matchers

```php
// tests/Pest.php
use RoundlyConsulting\Tags\Testing\TagExpectations;

TagExpectations::register();

// any test
expect($concert)->toHaveTags(['live', 'outdoor'], 'label');
expect($concert)->toHaveNoTags('genre');
expect($concert)->toHaveNoTags();
```

toHaveTags() checks each tag with hasTag() — a model, a key, a name or a slug; toHaveNoTags() checks one type, or every type. register() defines nothing when Pest’s expect() does not exist.

## The tag factory

```php
use RoundlyConsulting\Tags\Models\Tag;

$music = Tag::factory()->ofType('genre')->named(['en' => 'Music', 'sk' => 'Hudba'])->create();
$jazz = Tag::factory()->under($music)->named('Jazz')->create();      // type, scope, path and depth from the parent
$ours = Tag::factory()->ofType('label')->within($team)->create();
$any = Tag::factory()->create();                                     // type tag, a unique name in the current locale
```

Factory rows skip the package’s actions — no events, no usage counts, no tree lock — so create through Tags::type() where a test needs those. The factory builds the configured tag model. For taggable models, tag in afterCreating (see Taggable models).

## Testing an app

- No special harness: the published migrations are ordinary migrations, so RefreshDatabase works on SQLite 3.38+ in memory as well as on MySQL or PostgreSQL.
- Keep the test locale in translatable.locales, or tagging by name throws.
- Define the Gate abilities your API tests rely on (tags.manage, tags.scope) — undefined abilities deny.
- The API is off unless TAGS\_ROUTES=on is set before the app boots — use on / off values in phpunit.xml, because PHPUnit turns false into an empty string, which means the default.

```xml
<php>
    <env name="TAGS_ROUTES" value="on"/>
</php>
```

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/tags-for-laravel.md)
