# Fakes, mocks en architectuurtests

Mail, queues, events, bestanden, Http en tijd onder controle in je tests, en regels voor je codebase die Pest bewaakt

Multimedia & Creatieve Technologie · 3.1 The Agency · Zelfstudie · versie 1.0.0 · 2026-09-30

Tags: laravel, php, testing, architecture

Slides: <https://slides.mctantwerp.be/nl/the-agency/17-testen-fakes>

<!-- slide 2 -->

## Agenda

1. Waarom faken
2. Mail en notificaties
3. Queues en events
4. Bestanden
5. Http
6. Tijd
7. Mocks
8. Architectuurtests
9. Coverage en mutation testing

> **Toelichting:** De eerste zes hoofdstukken gaan over alles wat je app verlaat, telkens met de fake die Laravel ervoor heeft. Mocks komen daarna, omdat je ze pas nodig hebt als een fake niet volstaat. De laatste twee hoofdstukken testen niet je app, maar je code en je tests zelf.

<!-- slide 3 -->

## Na deze les kan je

- Testen dat je app een mail, een notificatie of een job verstuurt, zonder dat er iets de deur uit gaat
- Een externe API faken, ook het geval waarin hij plat ligt, en elke echte request uit je tests weren
- De klok verzetten en stilzetten om deadlines te testen zonder flaky tests
- Een service uit de container mocken, en kiezen tussen een fake en een mock
- Afspraken over je codebase afdwingen met architectuurtests, en je tests zelf keuren met coverage en mutation testing

> **Toelichting:** Na de opdracht op het einde draait je testsuite zonder internet. Dat is een goede toets: een test die alleen slaagt met wifi, hangt af van iets wat je niet in de hand hebt.

<!-- slide 4 -->

## 1. Waarom faken

> **Toelichting:** Een app praat met de buitenwereld: een mailserver, een betaalprovider, een opslagdienst. In een test wil je weten dat je app het juiste vraagt, zonder dat die vraag echt vertrekt. Dat is wat een fake doet.

<!-- slide 5 -->

**Je testsuite mailt geen klanten.**

Stel: een testomgeving met een echte Postmark-token en een kopie van de klantdata. Eén testrun stuurt driehonderd klanten een herinnering voor een factuur die al betaald is.

> **Toelichting:** Behalve fakes helpt ook de omgeving: een testomgeving krijgt nooit productiekeys, en een kopie van klantdata hoort er niet in. Postmark werkt met aparte servers die elk hun eigen token hebben, zodat staging nooit met de token van productie kan sturen. De les over mails gaat daar dieper op in.

<!-- slide 6 -->

### Zonder fake

- Mail: phpunit.xml zet de mailer op array. Er vertrekt niets, maar je kan ook niet controleren wat er vertrokken zou zijn.
- Queue: phpunit.xml zet de queue op sync. Elke job draait meteen mee in je test, met alles wat hij doet.
- Http: niets houdt een request tegen. Je test belt de echte API, en faalt als die plat ligt.
- Storage: bestanden komen op je echte schijf terecht, of in de bucket van de klant.

> **Toelichting:** Mail op array en de queue op sync zijn al een halve bescherming: er vertrekt geen mail en er hangt geen job in Redis. Maar je kan er niets mee controleren, en een job op sync die zelf een API belt, doet dat gewoon. Daarom fake je expliciet wat je wil controleren.

<!-- slide 7 -->

### Vier woorden

- **Fake**: Een werkende vervanger die onthoudt wat er gebeurde en niets de deur uit stuurt. `Mail::fake()`
- **Stub**: Geeft een vast antwoord terug, zonder meer. `Http::response(['isValid' => true])`
- **Mock**: Een object met afspraken vooraf: deze methode, met dit argument. Komt de call niet, dan faalt de test.
- **Spy**: Registreert alles wat er gebeurt. Achteraf vraag je wat er aangeroepen werd.

In de praktijk zegt iedereen mock voor alles. Laravel geeft je vooral fakes. Zie [Mocking](https://laravel.com/docs/13.x/mocking) in de docs.

> **Toelichting:** In boeken over testen heten ze samen test doubles, naar de stuntdubbel in een film. De namen hoef je niet uit het hoofd te kennen, het verschil wel: controleer je achteraf wat er gebeurde (fake, spy), of leg je vooraf vast wat er moet gebeuren (mock)?

<!-- slide 8 -->

### Waar een fake zit

Zet uitgangen aan en uit met een klik op hun rij, en kijk wat er nog echt draait. Neem zeker ook de laatste rij.

- Mail → Postmark. Met Mail::fake(): Er vertrekt niets. Je checkt met `assertQueued` of `assertSent`.
- Queue → Redis en een worker. Met Queue::fake(): De job wordt onthouden, niet uitgevoerd. `assertPushed`.
- Event → Listeners en observers. Met Event::fake(): Geen listener draait, ook geen observer. `assertDispatched`.
- Storage → S3-bucket van de klant. Met Storage::fake(): Een tijdelijke map. `assertExists`.
- Http → VIES (ec.europa.eu). Met Http::fake(): Het antwoord kies jij, ook een 503. `assertSent`.
- Mollie SDK → api.mollie.com. Http::fake() vangt dit niet: Niet gevangen: het SDK heeft een eigen HTTP-client. Zie slide 31.

> **Toelichting:** Het schema toont het principe: je code in het midden blijft echt, alleen de uitgangen worden vervangen. Hoe meer echte code een test draait, hoe meer fouten hij kan vinden. Daarom fake je aan de rand en niet in het midden.

<!-- slide 9 -->

### Het patroon

```php
it('mails the client when an invoice is paid', function () {
    Mail::fake();
    $invoice = Invoice::factory()->create();

    app(MarkInvoiceAsPaid::class)->handle($invoice);

    Mail::assertQueued(InvoicePaidMail::class);
});
```

1. Eerst de fake. Een fake die pas na de actie komt, heeft niets gezien.
2. Dan de actie, met je echte code. Alleen de mailer is vervangen.
3. Dan de assertions: vraag de fake wat er gebeurde.

> **Toelichting:** app(MarkInvoiceAsPaid::class) haalt de action uit de container, net zoals een controller dat zou doen. Zo krijgt de action ook zijn eigen dependencies. Een action is een class met één taak, zie de les over architectuur.

<!-- slide 10 -->

## 2. Mail en notificaties

> **Toelichting:** Mails zijn de uitgang waar klanten het snelst iets van merken: een mail die niet komt, of een mail die twee keer komt. Laravel maakt het testen ervan makkelijk, als je het verschil kent tussen versturen en in de queue zetten.

<!-- slide 11 -->

### Mail::fake

```php
it('mails the invoice to its client', function () {
    Mail::fake();
    $invoice = Invoice::factory()->create();

    app(MarkInvoiceAsPaid::class)->handle($invoice);

    Mail::assertQueued(
        InvoicePaidMail::class,
        fn ($mail) => $mail->hasTo($invoice->user->email),
    );
    Mail::assertQueuedCount(1);
});
```

1. Vanaf hier onthoudt Laravel elk mailable, en verstuurt het niets.
2. De closure zegt naar wie. Zonder weet je alleen dat er ergens zo een mail vertrok.
3. Precies één mail. Een bug die de mail twee keer stuurt, valt zo ook op.

> **Toelichting:** Wil je weten dat er niets vertrok, ook niet via de queue, dan is er Mail::assertNothingOutgoing(). assertNothingSent() kijkt alleen naar wat meteen verstuurd werd, en mist dus een mail met ShouldQueue.

<!-- slide 12 -->

### assertSent of assertQueued

```console
$ php artisan test --filter="mails the invoice"
  FAIL  Tests\Feature\MarkInvoiceAsPaidTest
 ⨯ it mails the invoice to its client                             0.05s
  FAILED  Tests\Feature\MarkInvoiceAsPaidTest > it mails the invoice to its client
  The expected [App\Mail\InvoicePaidMail] mailable was not sent.
  Did you mean to use assertQueued() instead?
```

`InvoicePaidMail` implementeert `ShouldQueue`, dus Laravel zet hem in de queue in plaats van hem meteen te versturen. Voor de fake is dat een ander lijstje. `assertSent` ziet alleen wat meteen vertrok. Laravel ziet dat en geeft de hint mee.

> **Toelichting:** Mail::to($user)->queue(...) zet een mail altijd in de queue, ook zonder ShouldQueue. Mail::to($user)->send(...) verstuurt meteen, behalve als het mailable ShouldQueue implementeert. Kijk dus naar de class, niet alleen naar de call.

<!-- slide 13 -->

### Wat er in de mail staat

```php
it('shows the number and the amount', function () {
    $invoice = Invoice::factory()->create(['amount' => 12_100]);

    $mail = new InvoicePaidMail($invoice);

    $mail->assertHasSubject("Factuur {$invoice->number} is betaald");
    $mail->assertSeeInHtml($invoice->number);
    $mail->assertSeeInHtml('€ 121,00');
});
```

1. Geen fake nodig: je maakt het mailable zelf en rendert het.
2. Onderwerp en inhoud. De test die checkt dat de mail vertrekt, hoeft de tekst niet te kennen.

Zie [Mail: Testing](https://laravel.com/docs/13.x/mail#testing-mailables) voor de assertions op bijlagen en afzender.

> **Toelichting:** Wil je de mail zien zoals de klant hem krijgt, geef dan het mailable terug uit een route: Laravel toont het als HTML in je browser. Samen met Mailpit of de mail catcher van Herd zie je lokaal alles wat vertrekt. Zie de les over mails.

<!-- slide 14 -->

### Notification::fake

```php
it('notifies the client of a new invoice', function () {
    Notification::fake();
    $client = User::factory()->create();

    app(IssueInvoice::class)->handle($client, amount: 12_100);

    Notification::assertSentTo(
        $client,
        function (InvoiceIssued $notification, array $channels) {
            return in_array('mail', $channels);
        },
    );
});
```

1. Geen enkel kanaal wordt aangesproken: geen mail, geen database, geen Slack.
2. Naar deze klant, en de class volgt uit het type in de closure.
3. Ook via mail. Zo test je dat de klant een mail krijgt en de admin alleen een melding in de app.

> **Toelichting:** Een notificatie gaat via de kanalen die via() teruggeeft. De closure test dat de juiste kanalen gekozen worden. Wil je de inhoud van de mail van een notificatie testen, roep dan toMail($client) aan op de notificatie en controleer wat terugkomt.

<!-- slide 15 -->

### InvoicePaidMail implementeert ShouldQueue. Welke assertion slaagt na Mail::fake()?

- A. Mail::assertSent()
- B. Mail::assertQueued()
- C. Queue::assertPushed()
- D. Geen, de mail vertrekt pas in de worker

**Antwoord:** B. Mail::assertQueued()

Een mailable met ShouldQueue komt op de lijst van queued mails van de fake. Queue::assertPushed lijkt logisch omdat het mailable in de queue gaat, maar Mail::fake vangt het al op voor het ooit een job wordt. assertSent faalt, met de vraag of je assertQueued bedoelde.

> **Toelichting:** Zonder Mail::fake zou Queue::fake het mailable wel zien, als een job met de naam SendQueuedMailable. Daar een assertion op schrijven is omslachtig en breekt bij de volgende versie van Laravel. Gebruik de fake die bij het onderwerp hoort.

<!-- slide 16 -->

## 3. Queues en events

> **Toelichting:** Jobs en events maken je code los van elkaar: de controller hoeft niet te weten wat er na een betaling allemaal gebeurt. Dat maakt testen makkelijker, als je elk stuk apart test. De fakes helpen je die stukken van elkaar te scheiden.

<!-- slide 17 -->

### Queue::fake

```php
it('generates the pdf in the background', function () {
    Queue::fake();

    $this->actingAs(User::factory()->admin()->create())
        ->post(route('invoices.store'), validInvoice());

    Queue::assertPushed(GenerateInvoicePdf::class);
});
```

1. Vanaf nu worden jobs onthouden, niet uitgevoerd.
2. De controller maakt een factuur en dispatcht de job. `validInvoice()` komt uit de vorige les.
3. De controller moet de job dispatchen, en meer test je hier niet.

Retries, backoff en de worker staan in de les over [queues](/nl/the-agency/13-queues).

> **Toelichting:** Queue::fake([GenerateInvoicePdf::class]) fakt alleen die job en laat de rest gewoon draaien. Voor jobs in een chain of een batch gebruik je Bus::fake() met Bus::assertChained() of Bus::assertBatched(), want Queue::fake ziet de volgorde niet.

<!-- slide 18 -->

### De job zelf

```php
it('stores the invoice as a pdf', function () {
    Storage::fake('invoices');
    $invoice = Invoice::factory()->create();

    GenerateInvoicePdf::dispatchSync($invoice);

    Storage::disk('invoices')
        ->assertExists("{$invoice->number}.pdf");
});
```

1. De pdf komt in een tijdelijke map, niet in je echte storage.
2. `dispatchSync` voert de job meteen uit, hier in de test.
3. Twee kleine tests: de controller dispatcht, de job doet zijn werk.

> **Toelichting:** Een job die een externe dienst belt, test je op dezelfde manier, met Http::fake erbij. Test ook wat er gebeurt als hij faalt: na $job->withFakeQueueInteractions() controleer je met assertReleased() of hij zichzelf opnieuw in de queue zet. Zie de les over queues.

<!-- slide 19 -->

### Event::fake

```php
it('announces that an invoice was paid', function () {
    $invoice = Invoice::factory()->create();
    Event::fake([InvoicePaid::class]);

    app(MarkInvoiceAsPaid::class)->handle($invoice);

    Event::assertDispatched(
        InvoicePaid::class,
        fn ($event) => $event->invoice->is($invoice),
    );
});
```

1. Eerst de factory, en pas dan de fake. Waarom, zie de volgende slide.
2. Met een lijst fake je alleen die events. Alle andere draaien normaal.
3. Het event is verstuurd, voor precies deze factuur. De listeners test je apart.

> **Toelichting:** Event::fakeFor(function () { ... }) fakt events alleen binnen de closure. Daarna draaien events en observers weer normaal. Met Event::assertListening(InvoicePaid::class, SendReceipt::class) test je dat een listener aan een event hangt, zonder hem uit te voeren.

<!-- slide 20 -->

### Event::fake te vroeg

```php
Event::fake();
$invoice = Invoice::factory()->create();
```

```console
  FAILED  Tests\Feature\InvoicePaidTest > it announces that an invoice…   QueryException
  SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint
  failed: invoices.number (Connection: sqlite, SQL: insert into "invoices" …)
```

Stel: `Invoice` zet zijn factuurnummer in een `creating`-hook, zodat de nummers altijd doorlopen. `Event::fake()` zonder lijst houdt ook model events tegen, dus die hook draait niet en de insert faalt. De docs waarschuwen er ook voor.

> **Toelichting:** Dit soort fout is verraderlijk, omdat de melding niets over events zegt. Zie je een NOT NULL constraint op een kolom die een observer of een hook invult, denk dan aan Event::fake. De oplossing is bijna altijd een lijst events meegeven.

<!-- slide 21 -->

### Je roept Event::fake() aan en maakt daarna een factuur met een factory. Wat gebeurt er met je InvoiceObserver?

- A. Die draait gewoon
- B. Die draait niet
- C. Die draait twee keer
- D. Die draait pas na de test

**Antwoord:** B. Die draait niet

Een observer hangt aan model events, en Event::fake() zonder lijst houdt die ook tegen. 'Die draait gewoon' klinkt logisch omdat je alleen je eigen events wou faken, maar dat doe je pas met een lijst: Event::fake(\[InvoicePaid::class\]).

> **Toelichting:** Wil je een observer juist testen, fake dan niets en controleer het resultaat in de database. Een observer die mails stuurt of jobs dispatcht, test je met Mail::fake of Queue::fake: dan draait hij wel, maar vertrekt er niets.

<!-- slide 22 -->

## 4. Bestanden

> **Toelichting:** Uploads zijn een klassieke plek voor bugs: een verkeerde map, een bestand dat verdwijnt bij een deploy, een naam die botst. Met een fake disk test je waar een bestand terechtkomt, zonder je schijf of de bucket van de klant te vervuilen.

<!-- slide 23 -->

### Een upload testen

```php
it('stores a signed contract', function () {
    Storage::fake('contracts');
    $project = Project::factory()->create();
    $file = UploadedFile::fake()->create('contract.pdf', 200);

    $this->actingAs($project->owner)
        ->post(route('projects.contracts.store', $project), [
            'contract' => $file,
        ])
        ->assertRedirect();

    Storage::disk('contracts')
        ->assertExists("{$project->id}/{$file->hashName()}");
});
```

1. De disk contracts wordt een tijdelijke map.
2. Een nep-pdf van 200 kB, zonder echt bestand op je schijf.
3. `store()` geeft het bestand een willekeurige naam. `hashName()` vertelt je welke.

> **Toelichting:** Test ook de validatie van een upload: een bestand dat te groot is of het verkeerde type heeft. Met UploadedFile::fake()->create('contract.exe', 200) test je of je mimes-regel werkt. In de les over security zie je wat er mis kan gaan met uploads.

<!-- slide 24 -->

### Wat Storage::fake doet

- De disk wijst naar een map onder storage/framework/testing, die bij de volgende fake leeggemaakt wordt
- Wil je de bestanden na de test bekijken, gebruik dan `Storage::persistentFake()`
- `UploadedFile::fake()->image()` maakt een echte afbeelding, maar vraagt de GD-extensie van PHP
- Zie [File Storage: Testing](https://laravel.com/docs/13.x/filesystem#testing) in de docs.

> **Toelichting:** Storage::fake() zonder naam fakt de standaarddisk. Geef altijd de naam mee van de disk die je code gebruikt, anders schrijft je code naar een disk die niet gefaket is. Een test die echte bestanden achterlaat in storage/app, verraadt dat.

<!-- slide 25 -->

## 5. Http

> **Toelichting:** Externe API's zijn de minst betrouwbare uitgang: ze liggen plat, ze zijn traag, ze veranderen. Een test die ervan afhangt, faalt om redenen die niets met jouw code te maken hebben. Met Http::fake bepaal jij wat de API antwoordt.

<!-- slide 26 -->

### De btw-check

*app/Services/VatValidator.php*

```php
public function isValid(string $vatNumber): bool
{
    $country = substr($vatNumber, 0, 2);
    $number = substr($vatNumber, 2);

    return Http::timeout(5)
        ->get("https://ec.europa.eu/taxation_customs/vies/rest-api/ms/{$country}/vat/{$number}")
        ->throw()
        ->json('isValid') === true;
}
```

VIES is de btw-databank van de Europese Commissie. Hij ligt geregeld plat, soms voor één land. Precies het soort API dat je nooit echt wil aanroepen vanuit een test. `throw()` maakt van een 4xx of 5xx een exception.

> **Toelichting:** Controleer altijd de documentatie van een API voor je hem gebruikt: de url en het veld hier zijn een voorbeeld. timeout(5) is belangrijk. Zonder wacht Laravel standaard 30 seconden op een API die hangt, en zolang wacht je gebruiker ook.

<!-- slide 27 -->

### Http::fake

```php
it('checks the vat number with VIES', function () {
    Http::fake([
        'ec.europa.eu/*' => Http::response(['isValid' => true]),
    ]);

    $isValid = app(VatValidator::class)->isValid('BE0123456789');

    expect($isValid)->toBeTrue();
    Http::assertSent(function (Request $request) {
        return str_ends_with($request->url(), '/BE/vat/0123456789');
    });
});
```

1. Elke url die op het patroon past, krijgt dit antwoord. Het sterretje is een wildcard.
2. Je eigen code parst het antwoord echt, en dat test je hier mee.
3. `assertSent` bewijst dat je code de juiste url bouwde.

> **Toelichting:** Http::sequence() geeft bij elk request het volgende antwoord uit een lijst, handig voor een retry: eerst een 503, dan een 200. Met Http::recorded() haal je alle requests en antwoorden op als je meer wil bekijken dan assertSent toelaat.

<!-- slide 28 -->

### Als VIES plat ligt

```php
it('saves the client when VIES is down', function () {
    Http::fake(['ec.europa.eu/*' => Http::failedConnection()]);

    $this->actingAs(User::factory()->admin()->create())
        ->post(route('clients.store'), [
            'name' => 'Bakkerij Peeters',
            'vat_number' => 'BE0123456789',
        ])
        ->assertRedirect(route('clients.index'));

    expect(Client::sole()->vat_verified_at)->toBeNull();
});
```

1. De API antwoordt niet: Laravel gooit een ConnectionException.
2. De admin krijgt geen 500, de klant wordt gewoon bewaard.
3. Zonder vinkje. De check volgt later, bijvoorbeeld in een job.

> **Toelichting:** failedConnection() gooit een ConnectionException, net als een echte timeout. Een 503 geeft met throw() een RequestException. De controller op de volgende slide vangt ze allebei op. Wat je niet opvangt, wordt een 500 voor de gebruiker.

<!-- slide 29 -->

### Het pad dat je lokaal nooit ziet

*ClientsController.php*

```php
try {
    $verified = $vat->isValid($request->vat_number);
} catch (ConnectionException|RequestException) {
    $verified = null;
}
```

Een API die plat ligt, kan je lokaal niet uitlokken. Met een fake wel, en daar leveren fakes het meest op. `failedConnection()` is een API die niet antwoordt, `Http::response(status: 503)` een API die antwoordt maar faalt. Wat er dan moet gebeuren, beslis je met de klant.

> **Toelichting:** De btw-check later opnieuw proberen is werk voor een job met retries, zie de les over queues. Met Http::sequence() test je een API die eerst faalt en daarna werkt: pushStatus(503) gevolgd door push() met een geldig antwoord.

<!-- slide 30 -->

### preventStrayRequests

*tests/Pest.php*

```php
pest()->beforeEach(function () {
    Http::preventStrayRequests();
});
```

```console
  FAILED  Tests\Feature\ClientTest > it creates a client   StrayRequestException
  Attempted request to [https://ec.europa.eu/taxation_customs/vies/rest-api/ms/BE/vat/
  0123456789] without a matching fake.
```

Eén hook in Pest.php, en geen enkele test belt nog per ongeluk een echte API. Een test die het toch probeert, faalt met de url erbij, zodat je ziet welke fake ontbreekt.

> **Toelichting:** Moet één lokale dienst toch bereikbaar blijven, zoals een pdf-generator op 127.0.0.1, dan zet je die op een lijst met Http::allowStrayRequests(['http://127.0.0.1:5000/*']). Alle andere requests blijven verboden.

<!-- slide 31 -->

### Een SDK met een eigen client

- `Http::fake` en `preventStrayRequests` werken alleen voor requests via de Http facade van Laravel
- Een SDK zoals mollie-api-php heeft zijn eigen HTTP-client. Die requests gaan eraan voorbij, ook met preventStrayRequests aan.
- Het SDK van Mollie heeft daarvoor een eigen fake: `MollieApiClient::fake()`. Zie [Testing with Mollie API Client](https://github.com/mollie/mollie-api-php/blob/main/docs/testing.md).
- Of je steekt het SDK in je eigen class en mockt die. Dat zie je op slide 41.

> [!WARNING]
> Check bij elke package die een API aanspreekt of hij de Http facade gebruikt. Zo niet, dan beschermt je fake je niet.

> **Toelichting:** Zo een SDK heeft vaak een eigen manier om te faken of een testmodus. Mollie heeft testkeys, maar die maken wel echte requests naar Mollie: prima om met de hand te testen, niet voor je testsuite. In je testsuite gaat er niets over het netwerk.

<!-- slide 32 -->

### Http::preventStrayRequests() staat aan. Je code doet een Http::get() waarvoor geen fake bestaat. Wat gebeurt er?

- A. Het request gaat gewoon door
- B. Je krijgt een lege 200 terug
- C. Er komt een exception en de test faalt
- D. Laravel slaat het request over

**Antwoord:** C. Er komt een exception en de test faalt

Een StrayRequestException, met de url erbij. Een lege 200 klinkt logisch, want dat is wat Http::fake() zonder argumenten doet. Maar zonder fake voor die url, en met preventStrayRequests aan, weigert Laravel het request.

> **Toelichting:** Http::fake() zonder argumenten geeft elk request een lege 200. Dat is handig als je alleen wil controleren dat er iets vertrok, maar het verbergt fouten in het parsen van het antwoord. Geef liever per url een antwoord dat op het echte lijkt.

<!-- slide 33 -->

## 6. Tijd

> **Toelichting:** Facturen vervallen, abonnementen lopen af, herinneringen gaan na zeven dagen. Tijd zit in veel klantprojecten, en je wil niet wachten tot volgende maand om te weten of het werkt. Laravel laat je de klok in je tests verzetten.

<!-- slide 34 -->

### Reizen in de tijd

```php
it('reminds clients of overdue invoices', function () {
    Notification::fake();
    $invoice = Invoice::factory()->create([
        'due_at' => now()->addDays(30),
    ]);

    $this->travel(31)->days();

    $this->artisan('invoices:send-reminders')->assertSuccessful();

    Notification::assertSentTo($invoice->user, InvoiceOverdue::class);
});
```

1. Een factuur die over dertig dagen vervalt.
2. De klok van Laravel springt 31 dagen vooruit. `now()` geeft vanaf hier de nieuwe datum.
3. De command die de scheduler elke ochtend draait. `assertSuccessful` wil exit code 0.
4. De klant kreeg een herinnering. Zonder te wachten tot volgende maand.

> **Toelichting:** travel() werkt op alles wat de tijd via Laravel of Carbon opvraagt: now(), today(), Carbon::now(). De database weet daar niets van: een query met NOW() in SQL gebruikt de echte klok. Gebruik in je queries dus now() van PHP, niet de functie van de database.

<!-- slide 35 -->

### De klok verzetten

```php
$this->travel(5)->days();
$this->travel(-2)->hours();
$this->travelTo(now()->addMonth());
$this->travelBack();

$this->travel(31)->days(function () {
    // hier is het 31 dagen later
});
```

Vooruit, achteruit, of naar een vast moment. Met een closure geldt de nieuwe tijd alleen daarbinnen. Na elke test zet Laravel de klok zelf terug. Hoe de command elke ochtend draait, staat in de les over [scheduling](/nl/the-agency/14-scheduling).

> **Toelichting:** Achter de schermen gebruikt Laravel Carbon::setTestNow(). Dat kan je ook zelf aanroepen, maar dan moet je de klok ook zelf terugzetten. De helpers van Laravel doen dat na elke test, dus gebruik die.

<!-- slide 36 -->

### freezeTime

```php
it('stores when an invoice was paid', function () {
    $this->freezeTime();
    $invoice = Invoice::factory()->create();

    app(MarkInvoiceAsPaid::class)->handle($invoice);

    $this->assertDatabaseHas('invoices', [
        'id' => $invoice->id,
        'paid_at' => now(),
    ]);
});
```

1. De action zet `paid_at` op `now()`.
2. De test vraagt even later nog eens `now()`. Verspringt de seconde daartussen, dan faalt hij, af en toe.
3. Met een stilstaande klok zijn beide `now()`s hetzelfde moment. Geen flaky test meer.

> **Toelichting:** freezeSecond() zet de klok stil op het begin van de huidige seconde. Dat helpt als je een tijdstip vergelijkt met wat uit de database terugkomt, want een datetime-kolom bewaart geen microseconden.

<!-- slide 37 -->

## 7. Mocks

> **Toelichting:** Een mock vervangt een van je eigen classes door een object dat je volledig zelf stuurt. Je hebt er minder vaak nood aan dan je denkt: de fakes van Laravel dekken het meeste. Dit hoofdstuk toont wanneer een mock wel de beste keuze is.

<!-- slide 38 -->

### Een service mocken

```php
it('refuses an invalid vat number', function () {
    $this->mock(VatValidator::class, function (MockInterface $mock) {
        $mock->expects('isValid')->andReturn(false);
    });

    $this->actingAs(User::factory()->admin()->create())
        ->post(route('clients.store'), [
            'name' => 'Bakkerij Peeters',
            'vat_number' => 'BE0123456789',
        ])
        ->assertInvalid('vat_number');
});
```

1. `$this->mock()` maakt een Mockery-mock en zet hem in de container.
2. `expects` betekent precies één keer. Komt de call niet, dan faalt de test.
3. De controller krijgt de mock in plaats van de echte VatValidator.

> **Toelichting:** Met ->with('BE0123456789') controleert de mock ook het argument. Laravel heeft ook $this->spy(): die neemt elke call aan en je vraagt achteraf met shouldHaveReceived() wat er gebeurde. Een spy leest vaak natuurlijker, want de assertion staat onderaan, zoals bij een fake.

<!-- slide 39 -->

### Waarom de mock aankomt

- *De container geeft hem, de mock werkt*

  ```php
  public function store(
      StoreClientRequest $request,
      VatValidator $vat,
  ): RedirectResponse
  ```
- *Zelf gemaakt, de mock komt nooit aan*

  ```php
  $vat = new VatValidator();
  ```

`$this->mock()` vervangt wat de container teruggeeft. Wie zelf `new` doet, vraagt de container niets en krijgt de echte class. Zie de les over de [service container](/nl/the-agency/04-service-container).

> **Toelichting:** Hetzelfde geldt voor een facade: Cache::expects('get') werkt omdat de facade zijn object uit de container haalt. Een statische methode op je eigen class, zoals VatValidator::check(), kan je niet mocken. Dat is een reden om services als object te laten injecteren.

<!-- slide 40 -->

### Fake of mock

#### Http::fake

- Je eigen VatValidator draait echt, ook het bouwen van de url en het parsen van het antwoord
- Werkt voor alles wat via de Http facade gaat
- Onze standaard: fake aan de rand, zo ver mogelijk van je eigen code

#### Een mock van VatValidator

- VatValidator draait niet, je test alleen de code die hem gebruikt
- Handig als de rand niet te faken is, zoals een SDK met een eigen client
- Mock je eigen classes. Een mock van een package breekt bij elke update ervan.

> **Toelichting:** Een vuistregel: een fake als Laravel er een heeft, Http::fake voor elke API via de Http facade, en een mock voor je eigen classes als de rand niet te faken is. Mock geen Eloquent-models of de Request: maak een echt model met een factory en stuur echte input mee. Dat raden ook de Laravel-docs aan.

<!-- slide 41 -->

### Een SDK inpakken

*app/Payments/PaymentGateway.php*

```php
interface PaymentGateway
{
    public function statusOf(string $paymentId): PaymentStatus;
}
```

*in een test*

```php
$this->mock(
    PaymentGateway::class,
    fn (MockInterface $mock) => $mock
        ->expects('statusOf')
        ->andReturn(PaymentStatus::Paid),
);
```

Eén class `MollieGateway` implementeert de interface en praat met het SDK. De rest van je app kent alleen `PaymentGateway`. In je tests mock je de interface, en een update van het SDK raakt maar één class.

> **Toelichting:** De binding zet je in een service provider: $this->app->bind(PaymentGateway::class, MollieGateway::class). Dit heet ook een adapter. Wil de klant later naar Stripe, dan schrijf je één nieuwe class en verandert de rest van je app niet.

<!-- slide 42 -->

### Te veel mocks

- *Test de implementatie*

  ```php
  $invoice = Mockery::mock(Invoice::class);
  $invoice->expects('update');

  $this->mock(Mailer::class)->expects('queue');

  app(MarkInvoiceAsPaid::class)->handle($invoice);
  ```
- *Test het resultaat*

  ```php
  Mail::fake();
  $invoice = Invoice::factory()->create();

  app(MarkInvoiceAsPaid::class)->handle($invoice);

  expect($invoice->fresh()->status)
      ->toBe(InvoiceStatus::Paid);
  Mail::assertQueued(InvoicePaidMail::class);
  ```

Links slaagt ook als de action de verkeerde status zet, zolang hij `update()` en `queue()` aanroept. Hernoem je een methode, dan faalt hij, terwijl er niets stuk is. Rechts test wat de klant merkt.

> **Toelichting:** Zo een test heet ook brittle: hij breekt bij elke refactor. Tests horen je vertrouwen te geven om code te herschrijven, niet om je tegen te houden. Pas je tests aan bij elke herschrijving zonder dat het gedrag veranderde, dan mock je te veel.

<!-- slide 43 -->

### De controller doet new VatValidator() in plaats van hem te laten injecteren. Wat doet $this->mock(VatValidator::class)?

- A. Hij vervangt de validator gewoon
- B. Niets, de controller vraagt de container nooit om een VatValidator
- C. Hij gooit een exception
- D. Hij maakt de test trager

**Antwoord:** B. Niets, de controller vraagt de container nooit om een VatValidator

$this->mock() zet de mock in de container. Wie zelf new doet, gaat aan de container voorbij en krijgt de echte class. Er komt geen exception: de test draait gewoon, belt de echte VIES, en de mock blijft ongebruikt. Met expects() faalt de test dan omdat de verwachte call nooit kwam.

> **Toelichting:** Dit is een van de redenen waarom Laravel alles via de container laat lopen: controllers, jobs, listeners en commands krijgen hun dependencies geïnjecteerd. Wie new gebruikt voor een service, maakt zijn code moeilijker te testen zonder dat hij het merkt.

<!-- slide 44 -->

## 8. Architectuurtests

> **Toelichting:** In een team met wisselende studenten of collega's sluipen er shortcuts in de code. Architectuurtests zijn afspraken die zichzelf controleren. Ze zijn snel geschreven en draaien in een fractie van een seconde.

<!-- slide 45 -->

### arch()

`tests/Unit/ArchTest.php`
```php
arch()->preset()->php();
arch()->preset()->security();

arch('controllers stay out of the database')
    ->expect('App\Http\Controllers')
    ->not->toUse('Illuminate\Support\Facades\DB');

arch('env() only in config')
    ->expect('App')
    ->not->toUse('env');
```

1. Kant-en-klare regels, onder meer geen `dd()` of `dump()` in je code. Zie de presets verder.
2. Een eigen afspraak: queries horen in models of actions, niet in controllers.
3. `env()` buiten config geeft null zodra de config gecachet is.

> **Toelichting:** Een namespace in expect() geldt voor alles eronder. Met ->ignoring('App\Http\Controllers\Admin') zet je een regel uit voor een deel. Andere handige regels: expect('App\Models')->toExtend(Model::class) of expect('App\Enums')->toBeEnums().

<!-- slide 46 -->

### Waarom architectuurtests

- Ze testen geen gedrag, ze testen afspraken over hoe de code in elkaar zit
- Die afspraken maak je als team, en ze staan hier in plaats van in een README die niemand leest
- Ze hebben geen Laravel nodig, dus `tests/Unit` is prima. Ze lezen alleen je code.
- In CI houden ze een dd() of een shortcut uit main, ook als niemand de pull request grondig leest
- Zie [Architecture Testing](https://pestphp.com/docs/arch-testing) in de Pest-docs.

> **Toelichting:** Architectuurtests lezen je code, ze voeren niets uit. Daarom vinden ze ook dingen in code die geen enkele andere test raakt, zoals een dd() in een controller die niemand test. Laat ze meedraaien in CI met de rest van je tests.

<!-- slide 47 -->

### Een regel breken

```console
$ php artisan test --filter=ArchTest
  FAIL  Tests\Unit\ArchTest
 ⨯ controllers stay out of the database                           0.21s
  FAILED  Tests\Unit\ArchTest > controllers stay out of the database
  Expecting 'App\Http\Controllers' not to use 'Illuminate\Support\Facades\DB'.

 at app/Http/Controllers/ReportsController.php:18
 ➜  18▕         $totals = DB::table('invoices')->sum('amount');
```

De test noemt de regel, het bestand en de lijn. Hier haalt een controller rechtstreeks cijfers op met `DB::table`. Verhuis die query naar het model of een action, dan wordt hij weer groen.

> **Toelichting:** De regel zegt niet dat DB fout is, wel dat het niet in een controller hoort. Hoort een query met DB::table echt bij een rapport, zet hem dan in een eigen class, zoals een query-object of een action. De les over architectuur toont waar zulke code in een Laravel-project past.

<!-- slide 48 -->

### Presets

| Preset | Controleert onder meer |
| --- | --- |
| `php()` | Geen `dd`, `dump`, `ray`, `var_dump` of `die` in je code |
| `security()` | Geen `eval`, `md5`, `rand`, `unserialize` en andere functies met risico |
| `laravel()` | Conventies: models in App\\Models, Form Requests eindigen op Request, jobs en mailables implementeren ShouldQueue |
| `strict()` | Overal `declare(strict_types=1)`, classes final |

De laravel-preset heeft meningen. Wil je niet elk mailable in de queue, zet die regel dan uit met `->ignoring('App\Mail')`. Lees de [bron van de preset](https://github.com/pestphp/pest/blob/5.x/src/ArchPresets/Laravel.php) voor je hem aanzet.

> **Toelichting:** Een eigen set regels die je in meerdere projecten gebruikt, kan je bundelen met pest()->presets()->custom('agency', fn () => [...]) in tests/Pest.php. Daarna schrijf je arch()->preset()->agency().

<!-- slide 49 -->

### Welke regels kies je

- Begin met de presets php en security. Die passen in elk project en kosten niets.
- Voeg een regel toe zodra het team een afspraak maakt, zoals waar queries horen
- Een regel die je telkens moet negeren, is een slechte regel. Schrap hem.
- Geen regel voor iets wat Pint of PHPStan al controleert

> **Toelichting:** Pint zorgt voor de opmaak van je code, PHPStan of Larastan zoekt typefouten. Architectuurtests vullen die aan met afspraken over structuur. Samen in CI houden ze een project leesbaar, ook als er elk semester nieuwe mensen aan werken.

<!-- slide 50 -->

## 9. Coverage en mutation testing

> **Toelichting:** Hoe weet je of je tests goed zijn? Coverage en mutation testing geven elk een ander antwoord. Het ene meet wat er uitgevoerd werd, het andere wat er echt gecontroleerd werd. Dat verschil telt voor wie een klant belooft dat de code getest is.

<!-- slide 51 -->

### Coverage

```console
$ php artisan test --coverage --min=90
  Actions/MarkInvoiceAsPaid ........................................ 100.0 %
 Http/Controllers/InvoicesController ....................... 41..48 81.3 %
 Services/VatValidator ............................................. 0.0 %
  Support/Shipping ................................................. 100.0 %
                                                            Total: 86.4 %

 FAIL  Code coverage below expected 90.0 %, currently 86.4 %.
```

Per bestand het percentage lijnen dat tijdens je tests uitgevoerd werd, en welke lijnen niet (41..48). `--min` maakt de run rood onder die grens. Mik niet op 100: de laatste procenten zijn getters en foutmeldingen die niemand ooit ziet.

> **Toelichting:** Een percentage is makkelijk om op te sturen: een test zonder assertions verhoogt het ook. Kijk daarom vooral naar wat er niet gedekt is. Staat daar een controller die geld verwerkt, dan heb je werk.

<!-- slide 52 -->

### Coverage aanzetten

```console
$ XDEBUG_MODE=coverage php artisan test --coverage
```

Coverage vraagt een driver in PHP: Xdebug of PCOV. Zonder krijg je een melding en geen cijfers. In Herd zet je Xdebug aan in de instellingen per PHP-versie. Zie [Test Coverage](https://pestphp.com/docs/test-coverage) in de Pest-docs.

> **Toelichting:** PCOV is sneller dan Xdebug, maar kan alleen coverage meten, niet debuggen. In GitHub Actions zet je coverage: xdebug of coverage: pcov bij setup-php. Met --coverage-html krijg je een rapport per bestand, met de ongeteste lijnen in het rood.

<!-- slide 53 -->

**Coverage meet wat er liep, niet wat je controleerde.**

Een test zonder één assertion haalt ook 100 procent.

> **Toelichting:** Een team dat op een percentage mikt, schrijft tests om het percentage te halen, niet om bugs te vinden. Gebruik coverage als zoeklicht om gaten te vinden, niet als doel op zich.

<!-- slide 54 -->

### Mutation testing

- *app/Support/Shipping.php*

  ```php
  public static function cost(int $totalInCents): int
  {
      if ($totalInCents > 50_000) {
          return 0;
      }

      return 695;
  }
  ```
- *tests/Unit/ShippingTest.php*

  ```php
  covers(Shipping::class);

  it('charges small orders', function () {
      expect(Shipping::cost(20_000))->toBe(695);
  });

  it('ships large orders free', function () {
      expect(Shipping::cost(80_000))->toBe(0);
  });
  ```

Pest verandert telkens één ding in `Shipping`, zoals groter dan in groter dan of gelijk aan, en draait de tests opnieuw. Faalt er een, dan is de mutatie gevangen. `covers()` zegt welke class deze tests aanpakken.

> **Toelichting:** covers() heeft nog een voordeel: Pest muteert alleen die class en draait alleen de tests die hem dekken, dus het gaat sneller. mutates() doet hetzelfde als covers(), maar zonder effect op het coveragerapport.

<!-- slide 55 -->

### Een mutatie die ontsnapt

```console
$ ./vendor/bin/pest --mutate --parallel
 UNTESTED  app/Support/Shipping.php  > Line 3: GreaterToGreaterOrEqual

 -        if ($totalInCents > 50_000) {
 +        if ($totalInCents >= 50_000) {

  Mutations: 1 untested, 7 tested
  Score:     87.50%
```

Pest maakte van groter dan een groter dan of gelijk aan, en alles bleef groen. Coverage zei 100 procent, maar geen enkele test kijkt naar precies € 500. Is die bestelling gratis of niet? Dat vraag je aan de klant.

> **Toelichting:** Naast untested zie je soms uncovered: een mutatie in code die geen enkele test uitvoert. Dat is hetzelfde probleem als lage coverage. Met --min=80 achter --mutate faalt de run onder die score, net als bij coverage.

<!-- slide 56 -->

### De grens testen

```php
it('charges shipping up to 500 euro', function (int $total, int $cost) {
    expect(Shipping::cost($total))->toBe($cost);
})->with([
    'small order' => [20_000, 695],
    'exactly 500 euro' => [50_000, 695],
    'just above' => [50_001, 0],
]);
```

```console
$ ./vendor/bin/pest --mutate --parallel
  Mutations: 8 tested
 Score:     100.00%
```

Een dataset op de grens en er net over vangt de mutatie. Mutation testing wijst je zo naar de gevallen die je vergat.

> **Toelichting:** Grenzen zijn de klassieke plek voor bugs: groter dan of groter dan of gelijk aan, vanaf of tot en met. Test altijd de grens zelf en de waarde er net naast. Staat de grens niet in de offerte, vraag hem dan na, anders beslist je code het voor de klant.

<!-- slide 57 -->

### Wat draai je wanneer

- Fakes en architectuurtests: altijd, bij elke run. Ze zijn goedkoop.
- Coverage met `--min` in CI, als ondergrens en niet als doel
- Mutation testing af en toe, lokaal met --parallel, op de classes waar geld door loopt. Het is traag: je tests draaien één keer per mutatie.
- Zie [Mutation Testing](https://pestphp.com/docs/mutation-testing) in de Pest-docs.

> **Toelichting:** Een goede volgorde voor een nieuw project: eerst fakes en de arch presets, dan coverage in CI met een lage grens die je stilaan optrekt. Mutation testing doe je als je een stuk code echt moet vertrouwen, zoals prijsberekening of facturatie.

<!-- slide 58 -->

### Coverage zegt 100 procent, mutation testing geeft een score van 60 procent. Wat betekent dat?

- A. Mutation testing is kapot
- B. Elke lijn liep, maar veel lijnen worden door geen enkele test gecontroleerd
- C. Er ontbreken tests voor 40 procent van de lijnen
- D. Je tests zijn te traag

**Antwoord:** B. Elke lijn liep, maar veel lijnen worden door geen enkele test gecontroleerd

Coverage telt wat er uitgevoerd werd, mutation testing telt wat een test opmerkt als het verandert. '40 procent van de lijnen' klinkt logisch, maar coverage zegt net dat elke lijn liep. Het verschil zijn tests zonder scherpe assertions, of grenzen die niemand test.

> **Toelichting:** Een mutation score van 100 procent is zelden haalbaar of nodig. Sommige mutaties veranderen niets aan wat de klant merkt, zoals een logregel die wegvalt. Kijk naar de mutaties in code die ertoe doet, en laat de rest.

<!-- slide 59 -->

### Opdracht: Je project offline testen

In het project van je team, verder op de tests van vorige les.

1. Zet Http::preventStrayRequests() aan in tests/Pest.php en fake elke externe API, ook het geval waarin hij plat ligt
2. Test je belangrijkste mail met Mail::fake, en de inhoud ervan apart
3. Test iets met een deadline met travel(), en schrijf een ArchTest met de php-preset en twee eigen regels
4. Klaar als php artisan test groen is met je wifi uit, en een dd() in een controller CI rood maakt
5. Vast? Http vanaf slide 26, tijd vanaf slide 34, arch tests vanaf slide 45, of [Mocking](https://laravel.com/docs/13.x/mocking) in de docs.

> **Toelichting:** Begin met preventStrayRequests en draai je hele testsuite. Elke test die nu faalt met een StrayRequestException, belde tot vandaag een echte API. Dat is je lijst van fakes die je nog moet schrijven.

## Begrippen

- **Mail::fake**: Vervangt de mailer in je test door een versie die onthoudt welke mailables vertrokken, zonder iets te versturen. Daarna controleer je met assertSent of assertQueued. (slide 11)
- **assertQueued**: De assertion voor een mailable die ShouldQueue implementeert. assertSent faalt dan, met de hint "Did you mean to use assertQueued() instead?". (slide 12)
- **Notification::fake**: Houdt notificaties tegen en onthoudt wie wat kreeg, via welk kanaal. assertSentTo controleert de ontvanger en de kanalen. (slide 14)
- **Queue::fake**: Onthoudt welke jobs gedispatcht werden, zonder ze uit te voeren. De job zelf test je apart, bijvoorbeeld met dispatchSync(). (slide 17)
- **Event::fake**: Onthoudt welke events gedispatcht werden en houdt de listeners tegen. Zonder lijst ook model events en observers, dus roep het aan na je factories of geef de events mee die je wil faken. (slide 19)
- **Storage::fake**: Vervangt een disk door een tijdelijke map die na de test weg is. Met UploadedFile::fake() test je een upload zonder echt bestand. (slide 23)
- **Http::fake**: Laat de Http facade een antwoord teruggeven dat jij kiest, per url-patroon. Ook een timeout of een 503, zodat je het pad test waar de API plat ligt. (slide 27)
- **preventStrayRequests**: Laat elke Http-request zonder bijhorende fake een exception gooien. Zet het voor je hele testsuite aan, dan belt geen enkele test per ongeluk een echte API. (slide 30)
- **freezeTime**: Zet de klok stil tijdens je test, zodat now() in je test en now() in je code hetzelfde moment zijn. Voorkomt tests die falen als de seconde net verspringt. (slide 36)
- **travel**: Verschuift de klok in je test, zoals $this->travel(31)->days(). Zo test je een deadline of een herinnering zonder een maand te wachten. (slide 34)
- **Mockery**: De library achter $this->mock() in Laravel. Je beschrijft welke methode aangeroepen moet worden en wat ze teruggeeft, zoals expects('isValid')->andReturn(false). (slide 38)
- **Wrapper**: Een eigen class of interface rond een SDK van iemand anders, zoals een PaymentGateway rond Mollie. In je tests mock je de wrapper, en een update van het SDK raakt maar één class. (slide 41)
- **Architectuurtest**: Een test op de structuur van je code in plaats van op wat ze doet. Bijvoorbeeld: geen dd() in app, controllers gebruiken de DB facade niet. Pest schrijft ze met arch(). (slide 45)
- **Arch preset**: Een kant-en-klare set architectuurregels in Pest: php, security, laravel, strict en relaxed. Met ->ignoring() zet je een regel uit die niet bij je project past. (slide 48)
- **Code coverage**: Het percentage lijnen code dat tijdens je tests uitgevoerd werd. Vraagt Xdebug of PCOV. Zegt wat er liep, niet wat er gecontroleerd werd. (slide 51)
- **Mutation testing**: Pest verandert telkens één ding in je code, zoals > in >=, en draait je tests opnieuw. Blijven ze groen, dan controleert geen enkele test dat stuk code. (slide 54)
- **Mutation score**: Het percentage mutaties dat je tests opmerkten. Een untested mutatie wijst naar een grens of een pad dat geen enkele test controleert. (slide 55)

## Links en bronnen

### In de presentatie

- [Mocking](https://laravel.com/docs/13.x/mocking) (slide 7, slide 59): $this->mock(), spy(), facades mocken en tijdreizen met travel, travelTo en freezeTime
- [Mail: Testing](https://laravel.com/docs/13.x/mail#testing-mailables) (slide 13): Mail::fake, assertSent tegenover assertQueued, en de inhoud van een mailable testen
- [File Storage: Testing](https://laravel.com/docs/13.x/filesystem#testing) (slide 24): Storage::fake, persistentFake en UploadedFile::fake()
- [mollie-api-php: Testing](https://github.com/mollie/mollie-api-php/blob/main/docs/testing.md) (slide 31): MollieApiClient::fake() met MockResponse
- [Pest: Architecture Testing](https://pestphp.com/docs/arch-testing) (slide 46): arch(), toUse, toBeUsed, ignoring() en de presets
- [Pest Laravel preset](https://github.com/pestphp/pest/blob/5.x/src/ArchPresets/Laravel.php) (slide 48): Wat arch()->preset()->laravel() precies controleert
- [Pest: Test Coverage](https://pestphp.com/docs/test-coverage) (slide 52): --coverage en --min, met Xdebug of PCOV
- [Pest: Mutation Testing](https://pestphp.com/docs/mutation-testing) (slide 57): --mutate, covers() en de mutation score

### Bronnen

- [Notifications: Testing](https://laravel.com/docs/13.x/notifications#testing): Notification::fake en assertSentTo met $channels
- [Queues: Testing](https://laravel.com/docs/13.x/queues#testing): Queue::fake, assertPushed en Bus::fake voor chains
- [Events: Testing](https://laravel.com/docs/13.x/events#testing): Event::fake met een lijst, en de waarschuwing over factories en model events
- [HTTP Client: Testing](https://laravel.com/docs/13.x/http-client#testing): Http::fake, failedConnection, assertSent en preventStrayRequests
- [Console Tests](https://laravel.com/docs/13.x/console-tests): $this->artisan() met assertSuccessful()
- [VIES REST API](https://ec.europa.eu/taxation_customs/vies/#/technical-information): De btw-check van de Europese Commissie, het voorbeeld van de externe API
