---
title: "Testing — Contacts for Laravel | Roundly"
description: "Swap in the recording Contacts::fake() and assert every write, build fixtures with per-kind factory states, and assert owners with Pest matchers."
url: https://roundly-consulting.com/open-source/docs/contacts-for-laravel/testing
language: en
---

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

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

# Testing

## The recording fake

Contacts::fake() swaps the manager for ContactsFake and returns it — constructor-injected managers get it too. Nothing is written and no event fires: added contacts come back unsaved (a structured address is rendered into the value, never stored). Adds and syncs are still checked like the real ones — an invalid value or a structured address on a non-address kind throws the same InvalidContactValue, and nothing is recorded — and the unsaved contacts carry what the real ones would store: the normalized value, the kind (a sync’s own kind for every item), the position and the primary flag the real write would leave (an added contact is primary when first of its kind while auto\_primary is on). Every write is recorded — through the facade, an injected manager, a contact book, verification(), the HasContacts trait or $contact->requestVerification() / confirmVerification(). Reads still hit the database:

```php
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Contacts\DataTransferObjects\ContactData;
use RoundlyConsulting\Contacts\Facades\Contacts;

$fake = Contacts::fake();

$user->addEmail('a@b.com');
$contact->requestVerification();

$fake->assertAdded(fn (ContactData $data, Model $owner) => $data->value === 'a@b.com');
$fake->assertVerificationRequested($contact);   // the Contact argument is optional
$fake->assertNothingDeleted();
```

| Records | Assert | Assert none |
| --- | --- | --- |
| `for()->…->add(), for()->add(), trait add*()` | `assertAdded(?Closure(ContactData, Model))` | `assertNothingAdded()` |
| `for()->sync()` | `assertSynced(ContactType\|string\|null, ?Closure(list<ContactData>, Model))` | `assertNothingSynced()` |
| `update()` | `assertUpdated(?Contact, ?Closure(ContactData))` | `assertNothingUpdated()` |
| `delete()` | `assertDeleted(?Contact)` | `assertNothingDeleted()` |
| `setPrimary()` | `assertPrimarySet(?Contact)` | `assertNothingPrimarySet()` |
| `verification()->markVerified()` | `assertVerified(?Contact)` | `assertNothingVerified()` |
| `verification()->request(), $contact->requestVerification()` | `assertVerificationRequested(?Contact)` | `assertNoVerificationRequested()` |
| `verification()->confirm(), $contact->confirmVerification()` | `assertVerificationConfirmed(?Contact)` | `assertNoVerificationConfirmed()` |

- Every Contact argument is optional — pass one to match a specific contact.
- Closures receive the recorded ContactData (before normalization) and the owner; assertUpdated() passes the ContactData only.
- verification()->request() returns fake-token under the fake.

```php
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Contacts\DataTransferObjects\ContactData;
use RoundlyConsulting\Contacts\Enums\ContactType;
use RoundlyConsulting\Contacts\Facades\Contacts;
use RoundlyConsulting\Contacts\Models\Contact;

it('stores the signup email as a contact', function () {
    $fake = Contacts::fake();

    $user = User::factory()->create();
    $user->addEmail('jane@example.com', label: 'Work');

    $fake->assertAdded(fn ($data) => $data->value === 'jane@example.com');
});

it('requests verification and promotes the email', function () {
    $fake = Contacts::fake();
    $contact = Contact::factory()->email()->create();

    Contacts::verification()->request($contact);   // the fake returns 'fake-token'
    Contacts::setPrimary($contact);

    $fake->assertVerificationRequested($contact);
    $fake->assertPrimarySet($contact);
    $fake->assertNothingAdded();
});

it('replaces the phone list', function () {
    $fake = Contacts::fake();
    $user = User::factory()->create();

    Contacts::for($user)->sync(ContactType::Phone, [
        new ContactData(ContactType::Phone, '+421900000000', isPrimary: true),
    ]);

    $fake->assertSynced(ContactType::Phone, fn (array $items, Model $owner) => count($items) === 1);
    $fake->assertNothingUpdated();
});
```

## Factories

The model factory ships states for ergonomic setup — email(), phone(), url(), address(), primary(), verified(), pendingVerification(), forOwner($model) and ofType($type). It builds the model configured in contacts.model, so Contact::factory() and your subclass’s factory() both create your model, with its casts, events and observers:

```php
use RoundlyConsulting\Contacts\Enums\ContactType;
use RoundlyConsulting\Contacts\Models\Contact;

Contact::factory()->email()->primary()->verified()->forOwner($user)->create();
Contact::factory()->phone()->forOwner($user)->create();
Contact::factory()->url()->create();
Contact::factory()->address()->create();
Contact::factory()->ofType(ContactType::Social)->create();

$pending = Contact::factory()->email()->pendingVerification()->create();
$pending->confirmVerification('123456');   // the state stores the hash of '123456'
```

## Pest expectations

Register the package’s custom Pest matchers from your app’s tests/Pest.php:

```php
// tests/Pest.php
use RoundlyConsulting\Contacts\Testing\ContactExpectations;

ContactExpectations::register();
```

Then assert against any owner model. Expected values are normalized for their kind before comparison:

```php
use RoundlyConsulting\Contacts\Enums\ContactType;

expect($company)
    ->toHaveContactOfType(ContactType::Email)
    ->toHavePrimaryEmail('hello@example.com')
    ->toHavePrimaryContact(ContactType::Phone, '+421900000000')
    ->toHaveVerifiedContact('hello@example.com');
```

## The package’s own suite

```bash
composer test
```

## 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/contacts-for-laravel.md)
