# Testen met Pest

Feature tests, factories en HTTP-tests in Laravel, en een pipeline die rood wordt als je iets breekt

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

Tags: laravel, php, testing, github

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

<!-- slide 2 -->

## Agenda

1. Waarom testen
2. Pest
3. Feature en unit
4. Testdata
5. HTTP-tests
6. Wat test je eerst
7. Tests in CI

> **Toelichting:** De eerste drie hoofdstukken zijn de basis: waarom, hoe Pest werkt en welk soort test je schrijft. Testdata en HTTP-tests zijn het grootste stuk, daar schrijf je de tests die je in elk project nodig hebt. De laatste twee gaan over keuzes: wat eerst, en hoe je tests vanzelf laat draaien. Fakes voor mail, queues en externe API's volgen in de volgende les.

<!-- slide 3 -->

## Na deze les kan je

- Een feature test en een unit test schrijven met Pest, en weten welke van de twee je nodig hebt
- Testdata maken met factories, states en relaties, op een database die elke test proper begint
- Een request testen als ingelogde user, met assertions op de response en op de database
- Eén test tien keer draaien met een dataset, en de output van een rode test lezen
- Kiezen wat je eerst test in een klantproject, en je tests laten draaien in GitHub Actions

> **Toelichting:** Dit is ook de checklist voor de opdracht op het einde. Lukt een punt niet, ga dan terug naar dat hoofdstuk voor je aan de opdracht begint. Testen leer je vooral door het te doen: schrijf bij elk hoofdstuk één test in je eigen project.

<!-- slide 4 -->

## 1. Waarom testen

> **Toelichting:** In een agency lever je code op die je zelf maanden niet meer ziet, terwijl de klant ermee verder werkt. Tests zijn de manier om te weten dat een wijziging van vandaag niets breekt van vorig jaar. Klanten vragen er zelden naar, maar ze merken het verschil bij de eerste bug die niet in productie raakt.

<!-- slide 5 -->

**Deploy je vrijdag om vier uur?**

Stel: je past de btw-berekening in de checkout aan. Zonder tests weet je pas maandag of je iets anders brak, als de boekhouder van de klant belt.

> **Toelichting:** Vrijdag deployen heeft een slechte naam omdat er in het weekend niemand is om een fout op te vangen. Met een goede testsuite en een pipeline die rood wordt, verdwijnt een groot deel van dat risico. Je deployt dan niet op goed geluk, maar omdat honderd tests zeggen dat de belangrijkste stukken nog werken.

<!-- slide 6 -->

### Waarom een agency test

- De klant belt niet als het werkt. Hij belt als de betaling faalt, en dan wil hij weten sinds wanneer.
- Na zes maanden kom je terug in een project dat je vergeten bent. De tests vertellen wat de code moet doen.
- Een collega verandert code die jij schreef. Zonder tests weet hij niet wat hij allemaal kan breken.
- Een upgrade van Laravel zonder tests is gokken. Met tests draai je ze, en je ziet wat er stuk is.

> [!NOTE]
> **Tests kosten tijd**
>
> Die tijd hoort in de offerte. Een agency die niet test, betaalt hem later toch, in bugfixes die ze niet kan factureren.

> **Toelichting:** Een nieuwe versie van Laravel komt elk jaar. Zonder tests klik je na een upgrade het hele project door en hoop je dat je niets vergeet. Met tests draai je php artisan test en zie je binnen een minuut wat er brak. Dat is ook een argument voor de klant: een project met tests is goedkoper om bij te houden.

<!-- slide 7 -->

### Klikken of testen

#### Zelf klikken

- Je test wat je net veranderde, niet wat je daarmee brak op een andere pagina
- Twintig minuten per keer, dus je doet het niet na elke kleine wijziging
- Wat je controleerde, staat nergens. Een collega moet het opnieuw doen.

#### Een test

- Honderd tests in een paar seconden, ook voor wat je allang vergeten was
- Hij draait bij elke push, ook als niemand eraan denkt
- De test zelf beschrijft wat je controleert, voor iedereen in het team

> **Toelichting:** Klikken blijft nodig: een test ziet niet dat een knop lelijk staat of dat een tekst niet klopt. Tests nemen het saaie en herhaalbare werk over, zodat jouw tijd naar wat alleen een mens ziet gaat. Een goede regel: alles wat je twee keer met de hand controleerde, wordt een test.

<!-- slide 8 -->

### Arrange, act, assert

```php
it('marks an invoice as paid', function () {
    $invoice = Invoice::factory()->create();

    $invoice->markAsPaid();

    expect($invoice->status)->toBe(InvoiceStatus::Paid);
});
```

1. Arrange: zet klaar wat de test nodig heeft. Hier een factuur in de database.
2. Act: doe één ding. Een methode, of een request. Heb je er twee, dan zijn het twee tests.
3. Assert: controleer wat er moet zijn. Faalt dit, dan is de test rood.

> **Toelichting:** Laat een lege lijn tussen de drie delen, zoals in het voorbeeld. Dan zie je in één oogopslag waar de actie zit. Wordt het arrange-stuk lang, dan is dat vaak een teken dat je een factory state of een helper nodig hebt. Die zie je verder in deze les.

<!-- slide 9 -->

## 2. Pest

> **Toelichting:** Pest staat al een paar versies als eerste keuze in de installer van Laravel, en de meeste nieuwe packages en tutorials gebruiken het. Wat je in dit hoofdstuk leert, werkt ook in PHPUnit: de assertions van Laravel zijn dezelfde. Het verschil zit vooral in hoe een test eruitziet.

<!-- slide 10 -->

### Pest

- Een testframework voor PHP, gebouwd bovenop PHPUnit. Alle assertions van Laravel werken erin.
- `laravel new` vraagt welk framework je wil. Kies Pest.
- Een test is een closure met een zin als naam, geen class met methodes
- Pest 4 en 5 schrijven een test op dezelfde manier. Pest 5 vraagt PHP 8.4. Zie de [Pest-docs](https://pestphp.com/docs/writing-tests).

```console
$ laravel new klantenportaal
...
 Which testing framework do you prefer?
› ● Pest
   ○ PHPUnit
```

> **Toelichting:** Pest is gemaakt door Nuno Maduro, die ook in het team van Laravel werkt. Pest 5 kwam met extra's zoals test impact analysis (de vlag --tia), die alleen de tests draait die door je wijziging geraakt worden. Voor deze les heb je die niet nodig. Welke versie je hebt, zie je met ./vendor/bin/pest --version.

<!-- slide 11 -->

### Pest in een bestaand project

```console
$ composer remove phpunit/phpunit
$ composer require pestphp/pest --dev --with-all-dependencies
$ ./vendor/bin/pest --init
```

Een project dat met PHPUnit begon, zet je zo om. `--init` maakt `tests/Pest.php` aan. Je oude PHPUnit-tests blijven gewoon draaien naast de nieuwe, dus je hoeft niets in één keer te herschrijven. Zie [Installation](https://pestphp.com/docs/installation) in de Pest-docs.

> **Toelichting:** Gebruik je een starter kit met PHPUnit, dan kan je ook gewoon bij PHPUnit blijven: alles in deze les over factories, HTTP-tests en de database werkt daar ook, alleen de schrijfwijze verschilt. Kies wel één stijl voor nieuwe tests in een project. Pest heeft ook een drift-plugin die PHPUnit-tests automatisch omzet.

<!-- slide 12 -->

### PHPUnit en Pest

- *PHPUnit*

  ```php
  class InvoiceTest extends TestCase
  {
      use RefreshDatabase;

      public function test_guests_are_redirected(): void
      {
          $this->get('/invoices')
              ->assertRedirect('/login');
      }
  }
  ```
- *Pest*

  ```php
  it('redirects guests', function () {
      $this->get('/invoices')
          ->assertRedirect('/login');
  });
  ```

Dezelfde test, dezelfde assertions. In Pest is `$this` nog altijd de TestCase van Laravel, daarom werken `get()` en `assertRedirect()` in allebei.

> **Toelichting:** Een PHPUnit-test hoort in een class, en elke methode die met test_ begint is een test. Pest maakt die class achter de schermen zelf aan. Daarom kan je de twee stijlen door elkaar gebruiken in één project, en werkt ./vendor/bin/pest ook op je oude tests.

<!-- slide 13 -->

### Een test aanmaken

```console
$ php artisan make:test InvoiceTest
 INFO  Test [tests/Feature/InvoiceTest.php] created successfully.
$ php artisan make:test VatTest --unit
 INFO  Test [tests/Unit/VatTest.php] created successfully.
```

*tests/Feature/InvoiceTest.php*

```php
<?php

test('example', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
});
```

`make:test` ziet dat Pest geïnstalleerd is en maakt een Pest-test. Zonder `--unit` komt hij in `tests/Feature`, en dat is bijna altijd wat je wil.

> **Toelichting:** make:test maakt alleen een bestand, je kan het ook met de hand aanmaken. Wat telt: de bestandsnaam eindigt op Test.php en het bestand staat in tests/Feature of tests/Unit, anders vindt phpunit.xml het niet. Een tip voor structuur: één testbestand per controller of per action, met dezelfde naam.

<!-- slide 14 -->

### test() en it()

```php
test('guests are redirected to the login page', function () {
    // ...
});

it('redirects guests to the login page', function () {
    // ...
});

describe('invoices', function () {
    it('can be paid', function () { /* ... */ });
});
```

`it()` zet "it" voor je zin, zodat de output leest als een zin. `describe()` groepeert tests onder een kop. Kies één stijl per project, de rest van deze les gebruikt `it()`.

> **Toelichting:** In de output staat een test van it() als 'it redirects guests'. Schrijf je naam dus zo dat hij na 'it' een zin vormt. Een naam als 'it works' zegt niets als hij rood wordt. Binnen een describe() kan je ook een beforeEach() zetten die alleen voor die groep geldt.

<!-- slide 15 -->

### expect()

```php
expect($invoice->amount)->toBe(12100);

expect($invoice->status)
    ->toBe(InvoiceStatus::Paid)
    ->and($invoice->paid_at)->not->toBeNull();

expect($client->invoices)->toHaveCount(3);

expect($invoice->number)->toStartWith('2026-');
```

1. `toBe` vergelijkt strikt, met ===. `12100` en `"12100"` zijn dus niet hetzelfde: dan ontbreekt er een cast op je model.
2. `and()` start een tweede expectation in dezelfde ketting, `not` draait de volgende om.
3. `toHaveCount` telt een array of een collection, zoals een relatie.
4. Er zijn tientallen expectations. De lijst staat in de Pest-docs onder Expectations.

> **Toelichting:** Naast toBe is er toEqual, dat losser vergelijkt, zoals ==. Gebruik het zelden: een test die slaagt omdat '12100' gelijk is aan 12100, verbergt een ontbrekende cast. Pest laat je ook eigen expectations maken met expect()->extend() in tests/Pest.php, handig voor iets wat je in veel tests controleert.

<!-- slide 16 -->

### Tests draaien

```console
$ php artisan test
  PASS  Tests\Unit\VatTest
 ✓ it adds 21 percent vat                                       0.01s

  PASS  Tests\Feature\InvoiceTest
 ✓ it redirects guests to the login page                        0.12s
 ✓ it hides invoices of others                                  0.04s

 Tests:    3 passed (5 assertions)
```

Per testbestand een PASS of FAIL, per test een vinkje en hoe lang hij duurde. `php artisan test` en `./vendor/bin/pest` doen hetzelfde en kennen dezelfde vlaggen.

> **Toelichting:** Het aantal assertions staat erbij omdat een test zonder assertion ook slaagt, en dus niets bewijst. PHPUnit noemt zo een test risky. Draai je tests vaak, na elke kleine stap. Een test die je na drie uur werk pas draait, geeft je tien rode regels in plaats van één.

<!-- slide 17 -->

### Minder draaien

| Vlag | Wat het doet |
| --- | --- |
| `--filter=Invoice` | Alleen tests waarvan de naam of het bestand matcht |
| `--dirty` | Alleen tests in bestanden met uncommitted changes, handig tijdens het werken |
| `--bail` | Stoppen bij de eerste rode test |
| `--parallel` | Over al je cores tegelijk, met `brianium/paratest` |
| `--profile` | De tien traagste tests |

> [!TIP]
> Eén test even alleen draaien? Zet `->only()` achter de test. Vergeet hem niet weg te halen. Zie [Filtering Tests](https://pestphp.com/docs/filtering-tests).

> **Toelichting:** Met --parallel maakt Laravel per proces een eigen testdatabase aan, zoals klantenportaal_test_1 en klantenportaal_test_2. Die blijven bestaan tussen runs. Na een nieuwe migratie gebruik je --recreate-databases. In CI zorgt --ci ervoor dat een vergeten only() de rest van je tests niet overslaat.

<!-- slide 18 -->

## 3. Feature en unit

> **Toelichting:** De grens tussen de twee is in een Laravel-project minder scherp dan de namen doen vermoeden. Wat telt, is of je test Laravel nodig heeft. Kies je verkeerd, dan krijg je een test die faalt om een reden die niets met je code te maken heeft, zoals je verderop ziet.

<!-- slide 19 -->

### Feature en unit

#### Feature test

- Heel Laravel draait mee: routes, database, sessie, mail
- Meestal gaat er een request in en komt er een response en een database uit
- Staat in `tests/Feature`
- Trager, maar hij test wat de klant ziet

#### Unit test

- Eén class of functie, zonder Laravel eromheen
- Geen database, geen facades, geen `config()`
- Staat in `tests/Unit`
- Duurt milliseconden, ideaal voor berekeningen

> **Toelichting:** Een feature test start elke keer een verse app, en dat kost een paar milliseconden per test. Met duizend tests merk je dat, maar een suite van een paar honderd tests draait nog altijd in enkele seconden, zeker met --parallel. Snelheid is dus zelden een reden om voor unit tests te kiezen.

<!-- slide 20 -->

**Schrijf vooral feature tests.**

Ze testen de app zoals de klant hem gebruikt, dus ze geven het meeste vertrouwen. Unit tests hou je voor pure logica, zoals een btw-berekening. Dat raadt ook de Laravel-documentatie aan.

> **Toelichting:** Boeken en andere frameworks spreken van een testpiramide, met veel unit tests onderaan. In een Laravel-project met weinig eigen logica en veel database werkt het vaak omgekeerd. De meeste bugs zitten in hoe de stukken samenwerken: een route, een policy, een query. Dat vangt alleen een feature test.

<!-- slide 21 -->

### Een unit test

*app/Support/Vat.php*

```php
public static function add(int $cents, int $rate = 21): int
{
    return (int) round($cents * (100 + $rate) / 100);
}
```

*tests/Unit/VatTest.php*

```php
it('adds 21 percent vat', function () {
    expect(Vat::add(10_000))->toBe(12_100);
});

it('rounds to whole cents', function () {
    expect(Vat::add(999))->toBe(1_209);
});
```

Getallen erin, een getal eruit. Geen database, geen app: dit soort test draait honderd keer per seconde. Bedragen staan in centen, zodat er nergens een float afrondt.

> **Toelichting:** Werk met centen als integer, nooit met euro's als float. 0.1 + 0.2 is in PHP niet exact 0.3, en een afrondingsfout van één cent op een factuur valt een boekhouder altijd op. Een unit test met randgevallen zoals 999 cent vangt dat soort fouten.

<!-- slide 22 -->

### Laravel in een unit test

```console
$ php artisan test --filter=VatTest
  FAIL  Tests\Unit\VatTest
 ⨯ it adds 21 percent vat                                       0.01s
  FAILED  Tests\Unit\VatTest > it adds 21 percent vat   BindingResolutionException
  Target class [config] does not exist.

 at app/Support/Vat.php:3
 ➜  3▕     return (int) round($cents * (100 + config('invoices.vat_rate')) / 100);
```

Stel: iemand laat `Vat` het tarief uit de config lezen. De unit test ontploft, want in `tests/Unit` draait geen Laravel. Er is geen container, dus ook geen `config`.

> **Toelichting:** Een facade zoals Cache of Log geeft in een unit test een andere melding: A facade root has not been set. config() en app() geven Target class [...] does not exist. Allebei betekenen hetzelfde: er draait geen Laravel in deze test. Zie je een van de twee, kijk dan eerst in welke map je test staat.

<!-- slide 23 -->

### Twee uitwegen

- *De test verhuist naar tests/Feature*

  ```php
  it('adds the configured vat', function () {
      config(['invoices.vat_rate' => 21]);

      expect(Vat::add(10_000))->toBe(12_100);
  });
  ```
- *Het tarief wordt een argument*

  ```php
  $total = Vat::add(
      $cents,
      rate: config('invoices.vat_rate'),
  );
  ```

Links werkt, maar de test wordt trager en hangt af van de config. Rechts leest de code die `Vat` gebruikt de config, en blijft `Vat` zelf pure PHP. Die tweede heeft onze voorkeur: de class wordt er ook beter van.

> **Toelichting:** Dit is een klein voorbeeld van dependency injection: geef een class wat ze nodig heeft, in plaats van dat ze het zelf gaat halen. De les over de service container gaat daar veel verder in. Code die makkelijk te testen is, is vaak ook makkelijker te lezen en te hergebruiken.

<!-- slide 24 -->

### Je test een action die een factuur in de database zet. Waar hoort die test?

- A. tests/Unit, het is maar één class
- B. tests/Feature
- C. In de action zelf
- D. Nergens, Eloquent is al getest

**Antwoord:** B. tests/Feature

De action heeft een database nodig, en dus Laravel. tests/Unit lijkt logisch omdat het één class is, maar daar is geen app en geen RefreshDatabase, dus de eerste query faalt. Eloquent is inderdaad getest, jouw action niet.

> **Toelichting:** In een PHPUnit-test bestaat er ook een attribuut #[UnitTest] voor één testmethode die geen app nodig heeft, in een class die wel de TestCase van Laravel gebruikt. In Pest kom je dat zelden tegen: daar beslist de map, via tests/Pest.php.

<!-- slide 25 -->

## 4. Testdata

> **Toelichting:** De meeste tijd in een test gaat naar het klaarzetten van data. Hoe vlotter dat gaat, hoe meer tests je schrijft. Daarom zijn factories en een propere database per test het fundament van alles wat volgt.

<!-- slide 26 -->

### tests/Pest.php

```php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

pest()
    ->extend(TestCase::class)
    ->use(RefreshDatabase::class)
    ->in('Feature');
```

1. `extend`: elke test krijgt de TestCase van Laravel, en dus de app, `$this->get()` en `actingAs()`.
2. `use`: elke test krijgt een propere database. In een vers project staat deze lijn in commentaar. Haal dat weg.
3. `in`: dit geldt voor `tests/Feature`. Daarom draait er in `tests/Unit` geen Laravel.

> **Toelichting:** Je kan in Pest.php ook eigen expectations en helperfuncties zetten, en hooks zoals beforeEach() die voor elke test draaien. Hou het bestand klein: alles wat daar staat, geldt voor elke test, en een collega die één test leest, ziet het niet.

<!-- slide 27 -->

### Wat RefreshDatabase doet

- De migraties draaien één keer per testrun, niet per test
- Daarna start elke test een transactie, die op het einde teruggedraaid wordt. Wat de ene test aanmaakt, ziet de volgende nooit.
- Zonder: een test slaagt of faalt afhankelijk van welke tests er voor hem draaiden. Dat vind je pas na uren.
- Zie [Database Testing](https://laravel.com/docs/13.x/database-testing) in de Laravel-docs.

> **Toelichting:** Er zijn nog twee traits. DatabaseMigrations draait voor elke test alle migraties opnieuw, DatabaseTruncation maakt elke tabel leeg. Allebei veel trager. Je hebt ze alleen nodig als een test zelf met transacties werkt op een manier die RefreshDatabase in de weg zit, of voor browser tests met een echte server.

<!-- slide 28 -->

### phpunit.xml

*phpunit.xml*

```xml
<php>
    <env name="APP_ENV" value="testing"/>
    <env name="DB_CONNECTION" value="sqlite"/>
    <env name="DB_DATABASE" value=":memory:"/>
    <env name="MAIL_MAILER" value="array"/>
    <env name="QUEUE_CONNECTION" value="sync"/>
</php>
```

Deze waarden overschrijven je `.env` tijdens een testrun. De database is sqlite in het geheugen, dus snel en na elke run weg. Mails blijven in een array, en jobs draaien meteen in plaats van in een queue.

> **Toelichting:** Sommige projecten gebruiken MySQL of Postgres in tests, omdat productie dat ook doet. Dan zet je DB_CONNECTION en DB_DATABASE hier naar een aparte testdatabase, nooit naar je lokale ontwikkeldatabase. In dezelfde lijst staat BCRYPT_ROUNDS op 4, zodat het hashen van wachtwoorden je tests niet vertraagt.

<!-- slide 29 -->

### De config cache

```console
$ php artisan config:clear
 INFO  Configuration cache cleared successfully.
```

Stel: je draaide `php artisan config:cache` om iets te testen. Dan leest Laravel de gecachte config, en negeert het de waarden uit phpunit.xml. Je tests draaien op je lokale MySQL, en RefreshDatabase veegt je dev-database leeg.

> [!WARNING]
> Lokaal geen config cache, en `php artisan config:clear` voor je test. Een `.env.testing` zet je testconfig in een eigen bestand.

> **Toelichting:** Hetzelfde geldt voor route:cache en event:cache: die zijn voor productie. Lokaal draai je ze niet, of je ruimt alles op met php artisan optimize:clear. Een .env.testing wordt ook gebruikt door artisan-commando's met --env=testing, handig om een aparte testdatabase te migreren.

<!-- slide 30 -->

### Model factories

`database/factories/InvoiceFactory.php`
```php
public function definition(): array
{
    return [
        'user_id' => User::factory(),
        'number' => fake()->unique()->numerify('2026-####'),
        'amount' => fake()->numberBetween(1_000, 500_000),
        'status' => InvoiceStatus::Open,
        'due_at' => now()->addDays(30),
    ];
}
```

1. Een factory in een factory: zonder eigen user maakt hij er een aan.
2. `fake()` geeft nepdata. `unique()` voorkomt twee keer hetzelfde nummer.
3. Een geldige standaard. In je test overschrijf je alleen wat voor die test telt.

> **Toelichting:** Een factory maak je met php artisan make:factory InvoiceFactory, of samen met het model: php artisan make:model Invoice -mf. Het model heeft de trait HasFactory nodig, dan vindt Invoice::factory() de juiste class op basis van de naam.

<!-- slide 31 -->

### Factory, seeder of factory pattern

|   | Wat het is | Waar je het gebruikt |
| --- | --- | --- |
| **Model factory** | Weet hoe één geldig record eruitziet | In tests, en in seeders |
| **Seeder** | Bepaalt welke data er in een database zit | Lokaal en op staging |
| **Factory pattern** | Een design pattern om objecten te maken | Zie de les over [design patterns](/nl/the-agency/05-design-patterns) |

Een model factory is eigenlijk een test data builder, de naam is wat ongelukkig gekozen. Een seeder gebruikt vaak factories, niet omgekeerd.

> **Toelichting:** Een seeder die factories gebruikt, geeft je lokaal snel een project vol realistische data om mee te klikken. Maak een seeder nooit afhankelijk van echte klantdata. Geef in een factory elk verplicht veld een geldige standaard, anders faalt elke test die dat veld vergeet.

<!-- slide 32 -->

### Factories gebruiken

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

$invoice = Invoice::factory()->create(['amount' => 12_100]);

$invoices = Invoice::factory()->count(3)->create();

$draft = Invoice::factory()->make();
```

1. `create()` maakt het model en slaat het op in de database.
2. Een array overschrijft velden uit de definition. De rest blijft nepdata.
3. `count(3)` geeft een collection van drie facturen terug.
4. `make()` maakt het model alleen in het geheugen. Sneller, als je de database niet nodig hebt.

> **Toelichting:** Er zijn nog handige methodes. sequence() wisselt waarden af over meerdere records, recycle() hergebruikt één model voor alle relaties in plaats van telkens een nieuw te maken, en raw() geeft alleen de array met attributen terug. createQuietly() maakt een model aan zonder model events, die zie je als je observers hebt.

<!-- slide 33 -->

### Relaties in een factory

```php
$invoice = Invoice::factory()->for($client)->create();

$client = User::factory()
    ->has(Invoice::factory()->count(3))
    ->create();
```

1. `for()` hangt de factuur aan een model dat al bestaat, via de belongsTo. Er komt geen extra user bij.
2. `has()` maakt kinderen aan: een klant met drie facturen, in één keer.

Zie [Eloquent: Factories](https://laravel.com/docs/13.x/eloquent-factories) voor sequences, `recycle()` en de rest.

> **Toelichting:** has() en for() hebben ook magische varianten: User::factory()->hasInvoices(3)->create() doet hetzelfde als has(Invoice::factory()->count(3)). Handig, maar je editor kan ze niet aanvullen, dus de expliciete vorm leest voor een collega vaak duidelijker.

<!-- slide 34 -->

### States

- *InvoiceFactory.php*

  ```php
  public function overdue(): static
  {
      return $this->state(fn (array $attributes) => [
          'status' => InvoiceStatus::Open,
          'due_at' => now()->subDays(10),
      ]);
  }
  ```
- *in een test*

  ```php
  Invoice::factory()->paid()->create();

  Invoice::factory()->overdue()->count(2)->create();

  User::factory()->unverified()->create();
  ```

Een state geeft een naam aan een variant. `Invoice::factory()->overdue()` leest als wat je bedoelt, een array met vier velden niet. `unverified()` zit al in de UserFactory die Laravel meelevert.

> **Toelichting:** In de UserFactory van Laravel zie je static::$password: het wachtwoord wordt één keer gehasht en hergebruikt, omdat hashen traag is. Een state kan ook relaties maken. Benoem states naar wat ze betekenen voor de klant: overdue(), niet dueDateInThePast().

<!-- slide 35 -->

### Wat is het verschil tussen make() en create() op een factory?

- A. make() slaat niets op in de database
- B. create() is sneller
- C. make() maakt ook de relaties aan
- D. Er is geen verschil

**Antwoord:** A. make() slaat niets op in de database

create() slaat het model op, make() maakt het alleen in het geheugen. Het is net omgekeerd met de snelheid: make() is sneller, omdat er geen query gebeurt. Een model uit make() heeft wel geen id, dus een test die het in een url gebruikt, faalt.

> **Toelichting:** raw() is een derde variant: die geeft alleen de array met attributen terug, zonder model. Handig om een geldig formulier te posten in een HTTP-test. Een model uit make() heeft geen id, dus route('invoices.show', $invoice) werkt er niet mee.

<!-- slide 36 -->

## 5. HTTP-tests

> **Toelichting:** Een HTTP-test doet wat je browser doet: een request sturen en kijken wat terugkomt. Omdat hij door je routes, middleware, validatie en controller gaat, vangt hij de meeste fouten die een klant zou zien. Het is de test die je het vaakst gaat schrijven.

<!-- slide 37 -->

### Een request zonder browser

```php
it('redirects guests to the login page', function () {
    $this->get(route('invoices.index'))
        ->assertRedirect(route('login'));
});
```

- Er gaat geen echt request over het netwerk. Laravel stuurt het intern door de kernel, dus het is snel.
- CSRF staat uit in tests, je stuurt geen token mee
- De Laravel-docs raden één request per test aan. Twee requests zijn twee tests.
- Zie [HTTP Tests](https://laravel.com/docs/13.x/http-tests) in de Laravel-docs.

> **Toelichting:** Omdat er geen browser meedoet, draait er ook geen JavaScript. Voor Livewire zijn er eigen testhelpers, en Pest heeft een browser-plugin die een echte browser aanstuurt. Voor de meeste formulieren en pagina's volstaat een gewone HTTP-test.

<!-- slide 38 -->

### actingAs

```php
it('shows a client their own invoices', function () {
    $client = User::factory()->create();
    $mine = Invoice::factory()->for($client)->create();
    $other = Invoice::factory()->create();

    $this->actingAs($client)
        ->get(route('invoices.index'))
        ->assertOk()
        ->assertSee($mine->number)
        ->assertDontSee($other->number);
});
```

1. Een klant met één eigen factuur, en een factuur van iemand anders.
2. `actingAs` logt de klant in voor dit request, zonder loginformulier.
3. Status 200, en zijn eigen factuurnummer staat op de pagina.
4. Het halve werk: een lijst die alles toont, slaagt ook op `assertSee`.

> **Toelichting:** actingAs slaat het loginformulier over. Test je het loginformulier zelf, dan post je naar de loginroute en controleer je met assertAuthenticated(). Met een tweede argument kies je een andere guard. Meer over guards staat in de les over authenticatie.

<!-- slide 39 -->

### Een formulier posten

```php
it('lets an admin create an invoice', function () {
    $client = User::factory()->create();

    $this->actingAs(User::factory()->admin()->create())
        ->post(route('invoices.store'), [
            'user_id' => $client->id,
            'amount' => 12_100,
            'due_at' => now()->addMonth()->toDateString(),
        ])
        ->assertRedirect(route('invoices.index'));

    $this->assertDatabaseHas('invoices', ['amount' => 12_100]);
});
```

1. `post()` stuurt de velden zoals een formulier dat zou doen, als admin.
2. Na een geslaagde store stuurt de controller door naar de lijst.
3. `assertDatabaseHas` bewijst dat de factuur echt bewaard is. Een redirect alleen zegt dat niet.

> **Toelichting:** Een redirect na een geslaagde store heet het Post/Redirect/Get-patroon: de browser stuurt het formulier dan niet opnieuw als de gebruiker herlaadt. assertRedirect() met een url controleert ook waarheen. Zonder argument controleert het alleen dat er een redirect is.

<!-- slide 40 -->

### Een datum die ontploft

- *Faalt vanaf november*

  ```php
  'due_at' => '2026-11-01',
  ```
- *Werkt altijd*

  ```php
  'due_at' => now()->addMonth()->toDateString(),
  ```

De validatie vraagt een vervaldatum in de toekomst. Een vaste datum werkt vandaag, en maakt de test rood zodra die dag voorbij is, zonder dat iemand iets aan de code veranderde. Reken datums altijd uit vanaf `now()`.

> **Toelichting:** Hetzelfde probleem krijg je met leeftijden, deadlines en jaartallen in een factuurnummer. Heb je een vast moment nodig, zet de klok dan stil of verzet hem. Dat zie je in de les over fakes.

<!-- slide 41 -->

### Validatie testen

```php
it('refuses an empty invoice', function () {
    $this->actingAs(User::factory()->admin()->create())
        ->post(route('invoices.store'), [])
        ->assertInvalid(['user_id', 'amount', 'due_at']);

    $this->assertDatabaseEmpty('invoices');
});
```

`assertInvalid` controleert dat de validatie faalde op precies die velden. Het werkt voor een redirect met errors in de sessie en voor een JSON-antwoord met status 422. `assertDatabaseEmpty` bewijst dat er niets half bewaard werd.

> **Toelichting:** assertInvalid kan ook de melding controleren: assertInvalid(['amount' => 'required']) slaagt als de foutmelding dat stuk tekst bevat. Test niet elke regel van elk veld, de validatie van Laravel is al getest. Test de regels die bij de klant horen, zoals een minimumbedrag.

<!-- slide 42 -->

### Assertions die je vaak gebruikt

| Assertion | Controleert |
| --- | --- |
| `assertOk()` | Status 200 |
| `assertRedirect($url)` | Een redirect, naar die url |
| `assertSee() / assertDontSee()` | Tekst in de HTML, of net niet |
| `assertForbidden() / assertNotFound()` | 403 of 404 |
| `assertInvalid() / assertValid()` | Validatiefouten op die velden, of geen |
| `assertDatabaseHas() / Missing()` | Een rij met die waarden, of net niet |

De volledige lijst staat in de docs onder [Available Assertions](https://laravel.com/docs/13.x/http-tests#available-assertions). De assertions voor JSON komen terug in de les over API's.

> **Toelichting:** assertCreated() controleert status 201, wat een API teruggeeft na het aanmaken. assertSeeText() negeert de HTML-tags, handig als je tekst door opmaak in stukken staat. Met assertSeeInOrder() controleer je dat items in de juiste volgorde op de pagina staan.

<!-- slide 43 -->

### Datasets

```php
it('refuses bad input', function (array $input, string $field) {
    $this->actingAs(User::factory()->admin()->create())
        ->post(route('invoices.store'), [
            ...validInvoice(),
            ...$input,
        ])
        ->assertInvalid($field);
})->with([
    'no amount' => [['amount' => null], 'amount'],
    'negative amount' => [['amount' => -500], 'amount'],
    'amount as text' => [['amount' => 'veel'], 'amount'],
    'due date passed' => [['due_at' => '2020-01-01'], 'due_at'],
    'unknown client' => [['user_id' => 999], 'user_id'],
]);
```

1. Vijf gevallen, elk met een naam. Pest draait de test één keer per geval.
2. Elke waarde komt binnen als argument: de foute input en het veld dat moet falen.
3. Een geldige set velden, waarvan één geval telkens één veld overschrijft.

> **Toelichting:** Datasets kan je ook in een apart bestand zetten, in tests/Datasets, met dataset('invalid invoices', [...]), en hergebruiken met ->with('invalid invoices'). Twee keer ->with() achter elkaar maakt alle combinaties. Kijk uit dat je zo geen honderden tests maakt die hetzelfde bewijzen.

<!-- slide 44 -->

### validInvoice()

*onderaan tests/Feature/InvoiceTest.php*

```php
function validInvoice(): array
{
    return [
        'user_id' => User::factory()->create()->id,
        'amount' => 12_100,
        'due_at' => now()->addMonth()->toDateString(),
    ];
}
```

Een gewone functie in het testbestand. Zo zie je in de dataset per geval wat er fout is, in plaats van twintig velden die toevallig kloppen. Gebruik je hem in meerdere bestanden, zet hem dan in `tests/Pest.php`.

> **Toelichting:** Sommige teams gebruiken hiervoor liever raw() op de factory. Het resultaat is hetzelfde: één plek die zegt wat een geldige factuur is. Verandert het formulier, dan pas je alleen die plek aan.

<!-- slide 45 -->

### Eén test, vijf runs

```console
$ php artisan test --filter="refuses bad input"
  PASS  Tests\Feature\InvoiceTest
 ✓ it refuses bad input with data set "no amount"                 0.06s
 ✓ it refuses bad input with data set "negative amount"           0.02s
 ✓ it refuses bad input with data set "amount as text"            0.02s
 ✓ it refuses bad input with data set "due date passed"           0.02s
 ✓ it refuses bad input with data set "unknown client"            0.02s

 Tests:    5 passed (5 assertions)
```

De naam van elk geval staat in de output. Wordt "due date passed" rood, dan weet je zonder de code te openen welke regel ontbreekt.

> **Toelichting:** Met --filter kan je ook één geval draaien, zoals --filter='due date passed'. Zonder namen toont Pest de waarden zelf, en een array met drie velden wordt dan snel onleesbaar. Geef je datasets daarom altijd een naam.

<!-- slide 46 -->

### Factories in een dataset

```php
it('lets staff see all invoices', function (User $user) {
    $this->actingAs($user)
        ->get(route('invoices.index'))
        ->assertOk();
})->with([
    'admin' => fn () => User::factory()->admin()->create(),
    'accountant' => fn () => User::factory()->accountant()->create(),
]);
```

Pest leest datasets in voor Laravel opstart. Een factory zonder closure faalt dus, want er is nog geen database. In een closure wacht hij tot de test draait. Zie [Datasets](https://pestphp.com/docs/datasets), onder Bound Datasets.

> **Toelichting:** Een accountant()-state bestaat niet vanzelf, die schrijf je zelf in je UserFactory. Het type van de parameter, hier User $user, is verplicht bij een closure in een dataset. Zonder type weet Pest niet dat het de closure eerst moet uitvoeren.

<!-- slide 47 -->

### De factuur van een ander

```php
it('hides invoices of others', function () {
    $client = User::factory()->create();
    $invoice = Invoice::factory()->create();

    $this->actingAs($client)
        ->get(route('invoices.show', $invoice))
        ->assertForbidden();
});
```

Iemand verandert `/invoices/41` in `/invoices/42` en ziet de factuur van een andere klant. Dat heet een IDOR, een van de meest voorkomende lekken in een klantenportaal. Hoe je het afschermt, staat in de les over [autorisatie](/nl/the-agency/11-autorisatie).

> **Toelichting:** 403 of 404? Met 403 zegt je app dat factuur 42 bestaat, alleen niet voor jou. Met 404 verraad je zelfs dat niet. Voor een klantenportaal is 404 vaak de betere keuze. Kies er één en test die, de les over autorisatie gaat er dieper op in.

<!-- slide 48 -->

### Een rode test lezen

Lees de output van boven naar onder, en zoek bij elk deel het antwoord op één vraag.

Een falende testrun, deel per deel uitgelegd.

```
  FAIL  Tests\Feature\InvoiceTest
 ✓ it redirects guests to the login page
 ⨯ it hides invoices of others
  FAILED  Tests\Feature\InvoiceTest > it hides…
 Expected response status code [403] but
 received 200.
 Failed asserting that 200 is identical to 403.
 at tests/Feature/InvoiceTest.php:30
   29▕     ->get(route('invoices.show', $invoice))
 ➜ 30▕     ->assertForbidden();
 Tests:    1 failed, 1 passed (2 assertions)
```

1. **Welk bestand**: FAIL en het testbestand, daaronder per test een vinkje of een kruis.
2. **Welke test**: De naam die jij schreef. Een goede naam zegt al wat er stuk is.
3. **Wat er misliep**: Verwacht 403, gekregen 200. De app toont de factuur gewoon.
4. **Waar**: Bestand en lijn, met een pijl naar de assertion die faalde.
5. **De balans**: Hoeveel tests er faalden en slaagden.

> **Toelichting:** Faalt er meer dan één test, begin dan bij de eerste. Vaak veroorzaakt één fout alle andere, bijvoorbeeld een migratie die ontbreekt. Met --bail stopt de run bij de eerste rode test, zodat je niet door een lange lijst hoeft te scrollen.

<!-- slide 49 -->

### Wat de fout je vertelt

- Welk bestand en welke test: de eerste twee delen zeggen waar je moet kijken, zonder de code te openen
- Wat er misliep: verwacht 403, gekregen 200. De app toont de factuur aan elke ingelogde user.
- Waar: lijn 30 is de assertion, niet de bug. De bug zit in de controller die de pagina toont.
- De test heeft hier gelijk. Verander hem niet naar assertOk() om het groen te krijgen, fix de app.

> **Toelichting:** Soms heeft de test wel ongelijk: de eisen veranderden en de test niet. Beslis dat bewust, en pas de test dan samen met de code aan. Wat je nooit doet: een test weghalen omdat hij in de weg zit. Pest heeft ->skip() en ->todo(), maar die zijn voor even, niet voor altijd.

<!-- slide 50 -->

### De fix

*InvoicesController.php*

```php
public function show(Invoice $invoice): View
{
    Gate::authorize('view', $invoice);

    return view('invoices.show', ['invoice' => $invoice]);
}
```

```console
$ php artisan test --filter=InvoiceTest
  PASS  Tests\Feature\InvoiceTest
 ✓ it redirects guests to the login page  0.09s
 ✓ it hides invoices of others            0.05s

 Tests:    2 passed (3 assertions)
```

`Gate::authorize` vraagt de policy of deze user deze factuur mag zien, en geeft anders een 403. Sinds Laravel 11 heeft de basiscontroller geen `$this->authorize()` meer, vandaar de facade.

> **Toelichting:** Een policy maak je met php artisan make:policy InvoicePolicy --model=Invoice. Laravel vindt hem vanzelf als hij in app/Policies staat en de naam volgt. In een route kan het ook met ->can('view', 'invoice'). De les over autorisatie vergelijkt de manieren.

<!-- slide 51 -->

### Als je niet ziet waarom

```php
$this->withoutExceptionHandling();

$response = $this->actingAs($client)
    ->get(route('invoices.show', $invoice));

$response->dump();
$response->dumpSession();
```

1. Laravel vangt exceptions normaal op en maakt er een 500 van. Hiermee gooit hij ze zelf, met de volledige stack trace.
2. `dump()` toont de hele response. `dd()` doet hetzelfde en stopt de test meteen.
3. `dumpSession()` toont de sessie, handig voor validatiefouten en flash-berichten.

> **Toelichting:** Vergeet withoutExceptionHandling() weer weg te halen. Een test die een 403 of een 404 verwacht, faalt ermee, want dan wordt die exception gegooid in plaats van omgezet in een response. Gebruik het om een fout te vinden, niet als vaste lijn in je tests.

<!-- slide 52 -->

### Een 500 lezen

```console
  FAILED  Tests\Feature\InvoiceTest > it shows a client their own invoices
  Expected response status code [200] but received 500.
  Failed asserting that 500 is identical to 200.

  The following exception occurred during the last request:

 Illuminate\Database\QueryException: SQLSTATE[HY000]: General error: 1
 no such column: invoices.paid_at (Connection: sqlite, SQL: select …)
```

Bij een 500 plakt Laravel de exception van dat request onder de melding. Lees verder dan de eerste lijn: hier ontbreekt een migratie voor `paid_at`.

> **Toelichting:** Laravel doet hetzelfde bij validatiefouten: faalt assertRedirect() terwijl er errors in de sessie staan, dan zet Laravel die errors onder de melding. Een test die onverwacht op de validatie botst, zie je zo meteen.

<!-- slide 53 -->

### Een test faalt met 'Expected response status code \[200\] but received 500.' Waar kijk je eerst?

- A. In phpunit.xml
- B. In de exception die Laravel onder die melding zet
- C. In de browser
- D. In de factory

**Antwoord:** B. In de exception die Laravel onder die melding zet

Laravel zet de exception van het request mee in de output, met de echte oorzaak. De browser lijkt logisch, maar daar draait een andere database en een andere sessie, dus je ziet er vaak iets anders. Staat er niets onder, gebruik dan withoutExceptionHandling().

> **Toelichting:** De exception in de output is het snelst, en hoort bij precies deze test. storage/logs/laravel.log is een tweede plek, maar daar staat alles door elkaar. Een factory lijkt ook een kandidaat, maar een fout in je testdata faalt meestal al voor het request, met een andere melding.

<!-- slide 54 -->

## 6. Wat test je eerst

> **Toelichting:** Niemand in een agency heeft tijd om alles te testen. Het verschil tussen een goede en een slechte testsuite zit in de keuze wat je test. Dit hoofdstuk helpt je die keuze maken, en uitleggen aan een klant waarom je daar tijd voor rekent.

<!-- slide 55 -->

### Begin bij het geld

|   | Waarom eerst |
| --- | --- |
| **Betalen en de webhook** | Een bestelling die op open blijft hangen kost geld. Zie de les over [online betalingen](/nl/advanced-web-development/online-betalingen). |
| **Wie wat mag zien** | Een factuur bij de verkeerde klant is een datalek, geen bug |
| **Inloggen en registreren** | De starter kit levert die tests mee. Laat ze staan, en laat ze groen. |
| **Het formulier van de klant** | De offerteaanvraag, de inschrijving: waar de klant van leeft |
| **Berekeningen** | Btw, kortingen, deadlines. Unit tests, snel geschreven. |

Je hebt nooit tijd om alles te testen. Begin waar een fout geld kost of klantdata lekt.

> **Toelichting:** Vraag bij de start van een project aan de klant wat er niet mag misgaan. Het antwoord is bijna altijd iets met geld, persoonsgegevens of een deadline. Dat zijn je eerste tests. Zet ze in de backlog als deel van de feature, niet als aparte taak die altijd naar achter schuift.

<!-- slide 56 -->

### Wat je laat liggen

- Laravel zelf. Of een route bestaat of `$fillable` werkt, testen de makers van Laravel al.
- Packages met hun eigen tests. Test hoe jij ze gebruikt, niet het package.
- De opmaak van je views tot op de pixel. Die verandert elke week.
- Private methods apart. Test de public method die ze gebruikt, dan mag je de private vrij herschrijven.

> **Toelichting:** Configuratie test je wel: een route die alleen voor admins open staat, is jouw keuze en niet die van Laravel. En gebruik je een package op een manier die de makers niet voorzien, test dan hoe het zich in jouw app gedraagt.

<!-- slide 57 -->

### Een bug is een test

- De klant meldt een bug
- Je schrijft eerst een test die de bug toont. Die faalt, en bewijst zo dat de bug bestaat.
- Je fixt de code tot de test groen is
- Komt de bug ooit terug, dan ziet CI het voor de klant

> [!TIP]
> **Zo groeit een testsuite**
>
> Niemand krijgt budget om achteraf honderd tests te schrijven. Eén test per bug krijg je altijd verkocht.

> **Toelichting:** Zet in de naam van de test waar de bug over ging, zoals 'it keeps the discount when the cart changes'. Een link naar het ticket in een commentaar mag ook. Zo weet een collega later waarom die test er staat, en haalt hij hem niet weg.

<!-- slide 58 -->

### Test-driven development

- Je schrijft de test voor de code. Zo weet je vooraf wat klaar betekent.
- Red: de test faalt, en om de juiste reden. Niet door een typfout in de test.
- Green: het minste dat de test groen maakt. Mooi hoeft nog niet.
- Refactor: je kuist de code op. De test blijft groen en bewaakt dat je niets verandert aan wat de code doet.

> **Toelichting:** TDD hoeft niet voor alles. Het werkt het best voor logica met duidelijke regels, zoals een berekening of een validatie, en voor bugs. Voor een nieuwe pagina waarvan je de vorm nog zoekt, schrijf je de test vaak pas als de pagina staat.

<!-- slide 59 -->

### Red, green, refactor

Kies Red, Green en Refactor, en vergelijk telkens wat er aan de code verandert en wat de testrun zegt.

**Red**: Eerst de test. Hij faalt, want Vat bestaat nog niet.

```php
it('adds 21 percent vat', function () {
    expect(Vat::add(10_000))->toBe(12_100);
});
```

```
  FAIL  Tests\Unit\VatTest
 ⨯ it adds 21 percent vat

  Error: Class "App\Support\Vat"
  not found
```

**Green**: Het minste dat werkt, met 1.21 hard in de code.

```php
final class Vat
{
    public static function add(int $cents): int
    {
        return (int) round($cents * 1.21);
    }
}
```

```
  PASS  Tests\Unit\VatTest
 ✓ it adds 21 percent vat

 Tests:    1 passed
```

**Refactor**: Het tarief wordt een argument. De test blijft groen.

```php
final class Vat
{
    public static function add(int $cents, int $rate = 21): int
    {
        return (int) round($cents * (100 + $rate) / 100);
    }
}
```

```
  PASS  Tests\Unit\VatTest
 ✓ it adds 21 percent vat

 Tests:    1 passed
```

> **Toelichting:** Groen met 1.21 hard in de code voelt als valsspelen, en dat is het ook een beetje. Het idee: de test bepaalt wat de code moet doen, niet je hoofd. Wil je een tarief als argument, schrijf dan eerst een tweede test met een ander tarief. Die dwingt de algemene versie af.

<!-- slide 60 -->

### Het budget laat tien tests toe. Welke schrijf je eerst?

- A. De homepage geeft status 200
- B. Een betaalde bestelling staat op paid na de webhook
- C. Een User heeft een naam
- D. De footer toont het juiste jaartal

**Antwoord:** B. Een betaalde bestelling staat op paid na de webhook

Begin waar geld of klantdata door loopt. De homepage-test lijkt nuttig omdat hij veel raakt, maar een homepage die plat ligt ziet iedereen meteen. Een bestelling die op open blijft hangen ziet niemand, tot de klant belt.

> **Toelichting:** De test voor de webhook stuurt een nagemaakt request naar je webhookroute, zonder echte betaling. Hoe je de client van Mollie in een test vervangt, zie je in de les over fakes. Welke tests een klant het meest waard zijn, kan je ook met hem bespreken: hij weet wat hem geld kost.

<!-- slide 61 -->

## 7. Tests in CI

> **Toelichting:** Tests die alleen lokaal draaien, draaien niet: iemand vergeet het, iemand heeft haast. Een pipeline draait ze bij elke push, voor iedereen, en zet een rood kruis bij de commit die iets brak.

<!-- slide 62 -->

### GitHub Actions

`.github/workflows/tests.yml`
```yaml
name: tests
on: [push, pull_request]
jobs:
  tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.4', coverage: none }
      - run: composer install --no-interaction --prefer-dist
      - run: cp .env.example .env && php artisan key:generate
      - run: npm ci && npm run build
      - run: php artisan test
```

1. Bij elke push en elke pull request.
2. Je code ophalen en PHP installeren. Dezelfde checkout-versie als in de starter kit.
3. Packages, een `.env` met een key, en de Vite-build voor je views.
4. De tests. Geen MySQL nodig: phpunit.xml zet sqlite in memory.

> **Toelichting:** De starter kits van Laravel pinnen elke action op een commit-hash, met de versie als commentaar erachter: actions/checkout@3d3c42e… # v7.0.1. Dat beschermt je tegen iemand die een tag verplaatst. Voor je eerste workflow is @v7 prima leesbaar, maar de hash is de veiligere gewoonte.

<!-- slide 63 -->

### Vite manifest not found

```console
  FAILED  Tests\Feature\InvoiceTest > it shows a client their own invoices
  The following exception occurred during the last request:

 Illuminate\Foundation\ViteManifestNotFoundException: Vite manifest not
 found at: /home/runner/work/klantenportaal/public/build/manifest.json
```

Lokaal slaagt alles, in CI faalt elke test die een pagina toont. Je views gebruiken `@vite`, en in CI is er nooit gebuild. Daarom staat `npm run build` in de workflow. Een alternatief is `$this->withoutVite()` in je tests.

> **Toelichting:** Hetzelfde gebeurt lokaal als je nog nooit npm run build of npm run dev draaide, bijvoorbeeld net na een git clone. In CI kost de build tijd. Heb je veel tests en weinig pagina's, dan kan $this->withoutVite() in een beforeEach in Pest.php sneller zijn.

<!-- slide 64 -->

### Rood blokkeert de merge

- Zet een ruleset op main (Settings, Rules) met de testjob als required status check
- Een pull request met rode tests kan dan niet gemerged worden, ook niet door jou
- sqlite in memory is snel, maar geen MySQL. Gebruik je iets MySQL-specifieks, laat CI dan op MySQL draaien.
- Een flaky test, die soms rood is zonder reden, is een bug. Meestal zit het in tijd, volgorde of een externe API.
- Zie [About rulesets](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets) bij GitHub.

> **Toelichting:** Een pipeline die de hele tijd rood staat, negeert iedereen na een week. Hou main dus groen, en fix een rode build voor je aan iets anders begint. Een ruleset zet je als admin van de repository. Werk je in een organisatie van school, vraag dan wie admin is.

<!-- slide 65 -->

### Opdracht: Tests voor je Agency-project

In het project van je team, op een eigen branch, met een pull request.

1. Zet RefreshDatabase aan in tests/Pest.php en schrijf een factory met twee states voor je belangrijkste model
2. Schrijf vier tests: een gast wordt doorgestuurd, de eigenaar ziet zijn data, een ander krijgt 403 of 404, en een dataset met ongeldige input
3. Voeg de GitHub Actions workflow toe en zet de check als required op main
4. Klaar als je PR groen is, en rood wordt zodra je de autorisatie in de controller weghaalt
5. Vast? Factories op slide 30 tot 34, HTTP-tests vanaf slide 37, of [HTTP Tests](https://laravel.com/docs/13.x/http-tests) in de docs.

> **Toelichting:** Begin met de test voor de gast: die heeft geen data nodig en toont meteen of Pest en je database goed staan. Maak hem één keer rood door de auth-middleware van de route te halen, en zet die dan terug. Zo weet je dat hij echt iets test. Pas daarna schrijf je de factory.

## Begrippen

- **Pest**: Het testframework dat Laravel standaard voorstelt, gebouwd bovenop PHPUnit. Een test is een closure met een zin als naam, en expect() leest als een zin. (slide 9)
- **PHPUnit**: Het klassieke testframework voor PHP, met een class per testbestand en assertions als $this->assertSame(). Pest draait erbovenop, dus de Laravel-assertions zijn dezelfde. (slide 12)
- **Arrange, act, assert**: De drie delen van elke test: zet klaar wat je nodig hebt, doe één ding, en controleer het resultaat. Zie je vier acts in één test, dan zijn het vier tests. (slide 8)
- **expect()**: De functie waarmee je in Pest een waarde controleert, zoals expect($total)->toBe(12100). toBe vergelijkt strikt, dus 12100 en "12100" zijn niet hetzelfde. (slide 15)
- **Feature test**: Een test met heel Laravel erbij: routes, database, sessie. Meestal een request erin en een response en een database eruit. Het grootste deel van je tests hoort hier. (slide 18)
- **Unit test**: Een test van één class of functie, zonder Laravel. Geen database, geen facades, geen config(). Razendsnel, ideaal voor berekeningen. (slide 18)
- **RefreshDatabase**: De trait die elke test met een propere database laat starten. De migraties draaien één keer, daarna zit elke test in een transactie die op het einde teruggedraaid wordt. (slide 27)
- **phpunit.xml**: De config van je testrun. Zet onder meer APP_ENV op testing, de database op sqlite in memory, de mailer op array en de queue op sync. (slide 28)
- **Model factory**: Een class die weet hoe een geldig record eruitziet, met nepdata van fake(). In een test zet je alleen de velden die voor die test tellen. Iets anders dan het factory pattern uit de les over design patterns. (slide 30)
- **Factory state**: Een methode op een factory die een variant beschrijft, zoals paid() of overdue(). Invoice::factory()->paid()->create() leest als wat je bedoelt. (slide 34)
- **make() en create()**: create() slaat het model op in de database, make() maakt het alleen in het geheugen. make() is sneller als je de database niet nodig hebt. (slide 32)
- **actingAs**: Logt een user in voor de duur van de test, zonder loginformulier. $this->actingAs($user)->get(...) doet het request als die user. (slide 38)
- **assertInvalid**: Controleert dat de validatie faalde op de velden die je opgeeft. Werkt voor een redirect met errors in de sessie en voor een JSON-antwoord met status 422. (slide 41)
- **assertDatabaseHas**: Controleert dat er een rij in een tabel staat met de waarden die je opgeeft. assertDatabaseMissing doet het omgekeerde. (slide 39)
- **Dataset**: Een lijst waarden waarmee Pest dezelfde test meerdere keren draait, één keer per waarde. Met een naam per waarde lees je in de output meteen welk geval faalde. (slide 43)
- **IDOR**: Insecure direct object reference: een id in de url veranderen en zo data van iemand anders zien. De test ertegen logt in als de ene klant en vraagt de factuur van een andere op. (slide 47)
- **Regressietest**: Een test die je schrijft voor een bug die al eens gebeurd is. Hij faalt zolang de bug er is, en zorgt ervoor dat hij nooit ongemerkt terugkomt. (slide 57)
- **Test-driven development**: Eerst de test, dan de code. Het ritme heet red, green, refactor: een test die faalt, het minste dat hem groen maakt, en dan opkuisen terwijl hij groen blijft. (slide 58)
- **Continuous integration**: Bij elke push en pull request draait een server je tests. Rood betekent: niet mergen. Bij ons is dat GitHub Actions. (slide 62)
- **Required status check**: Een check die groen moet zijn voor GitHub een merge toelaat. Je zet hem aan in een ruleset of branch protection op main. (slide 64)
- **Flaky test**: Een test die soms slaagt en soms faalt zonder dat de code veranderde, meestal door tijd, volgorde of een externe dienst. Behandel hem als een bug. (slide 64)

## Links en bronnen

### In de presentatie

- [Pest: Writing Tests](https://pestphp.com/docs/writing-tests) (slide 10): test(), it(), describe() en expect()
- [Pest: Installation](https://pestphp.com/docs/installation) (slide 11): Pest installeren in een project met PHPUnit
- [Pest: Filtering Tests](https://pestphp.com/docs/filtering-tests) (slide 17): --filter, --dirty, --bail en only()
- [Database Testing](https://laravel.com/docs/13.x/database-testing) (slide 27): RefreshDatabase met pest()->use(), de database assertions
- [Eloquent: Factories](https://laravel.com/docs/13.x/eloquent-factories) (slide 33): definition, states, for() en has()
- [HTTP Tests](https://laravel.com/docs/13.x/http-tests) (slide 37, slide 65): actingAs, de response assertions, assertInvalid, dump en withoutExceptionHandling
- [Available Assertions](https://laravel.com/docs/13.x/http-tests#available-assertions) (slide 42)
- [Pest: Datasets](https://pestphp.com/docs/datasets) (slide 46): Datasets met namen, en bound datasets met closures voor factories
- [GitHub: About rulesets](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets) (slide 64): Een required status check op main

### Bronnen

- [Testing: Getting Started](https://laravel.com/docs/13.x/testing): make:test, php artisan test, --parallel, --profile, .env.testing
- [Pest: Configuring Tests](https://pestphp.com/docs/configuring-tests): pest()->extend()->use()->in() in tests/Pest.php
- [Pest: Expectations](https://pestphp.com/docs/expectations): toBe, toHaveCount, and() en not
- [Pest: Continuous Integration](https://pestphp.com/docs/continuous-integration): De GitHub Actions workflow
- [Pest: Upgrade Guide](https://pestphp.com/docs/upgrade-guide): Pest 5 vraagt PHP 8.4 en draait op PHPUnit 13
- [laravel/laravel phpunit.xml](https://github.com/laravel/laravel/blob/13.x/phpunit.xml): sqlite in memory, array mailer en sync queue in tests
- [Livewire starter kit: tests.yml](https://github.com/laravel/livewire-starter-kit/blob/main/.github/workflows/tests.yml): De versie van actions/checkout die de starter kits gebruiken
