NovinkaZverejnili sme 50+ Laravel balíkov ako open source
Custom AI apps, agents and automation — Roundly ConsultingRoundly
Všetky balíky
Testing for Laravel

Základný test case pre balíky

PackageTestCase nahrádza takmer identický tests/TestCase.php, ktorý sa kopíruje do testov každého Laravel balíka. Rozširuje Orchestra Testbench, beží nad SQLite v pamäti so zapnutými obmedzeniami cudzích kľúčov, načítava migrácie podľa triedy providera a konfiguráciu aj výmenu modelov aplikuje ešte pred bootom providerov:

use RoundlyConsulting\Testing\PackageTestCase;

abstract class TestCase extends PackageTestCase
{
    protected function packageProviders(): array
    {
        return [CryptoServiceProvider::class, PasskeysServiceProvider::class];
    }

    protected function migrationSources(): array
    {
        return [PasskeysServiceProvider::class];
    }

    protected function configBeforeBoot(): array
    {
        return ['passkeys.rp.id' => 'example.test'];
    }
}

Svoj TestCase deklarujte ako abstract, nikdy nie final: Pest pre každý testovací súbor vygeneruje triedu, ktorá ho rozširuje, takže final TestCase skončí fatálnou chybou PHP hneď pri načítaní testov.

Metódy na prepísanie

MetódaPredvolenéÚčel
packageProviders()abstractTestované service providery v poradí registrácie. Jediná metóda, ktorú musíte implementovať.
migrationSources()[]Triedy providerov (reflexiou preložené na ich adresár database/migrations) a/alebo priame adresáre s migráciami.
configBeforeBoot()[]Konfigurácia aplikovaná v defineEnvironment() pred bootom providerov — miesto na prednastavenie kľúča či výmenu modelu.
keepsMigratedSchema()falseVráťte true a worker zmigruje schému raz pre každú signatúru a medzi testami resetuje len dáta — pozrite Ponechanie zmigrovanej schémy nižšie.
schemaSignatureExtras()[]Čokoľvek ďalšie, čo vaše migrácie čítajú a signatúra to nevidí — premenná prostredia, statický príznak. Uplatní sa len pri ponechanej schéme.
connectionAvailable($connection)—Verejný pomocník: či je pomenované pripojenie dostupné. Podmieňte ním asercie na reálnej databáze.

Migrácie podľa triedy providera

migrationSources() prijíma triedy providerov aj priame adresáre. Provider sa reflexiou preloží na adresár database/migrations svojho balíka (hľadá sa smerom nahor od súboru providera, najďalej po koreň balíka s jeho composer.json), takže rovnaké volanie funguje pre balík pripojený symlinkom aj pre inštaláciu z VCS a provider bez vlastných migrácií hlasno zlyhá namiesto toho, aby načítal migrácie hostiteľskej aplikácie. Pomenovať jednotlivý súbor migrácie zámerne nejde — presne táto chyba rozbila testy piatich balíkov. Zdroj, ktorý nie je triedou ani existujúcim adresárom, hlasno zlyhá a samotná kontrola nepridá testu žiadnu aserciu:

protected function migrationSources(): array
{
    return [
        PasskeysServiceProvider::class,          // resolved to its database/migrations by reflection
        __DIR__.'/Fixtures/database/migrations', // a literal directory works too
    ];
}

Výmena modelu pred bootom

Providery pri boote vešajú observery a listenery na nakonfigurovanú triedu modelu, preto musí výmena prebehnúť ešte pred bootom. Vráťte ju z configBeforeBoot() alebo zavolajte swapModel() v defineEnvironment() ešte pred odovzdaním riadenia rodičovi, ktorý aplikuje všetky zaregistrované výmeny:

// Either return the swap from the before-boot config window…
protected function configBeforeBoot(): array
{
    return ['media.media_model' => CustomMedia::class];
}

// …or register it in defineEnvironment() before the parent applies every swap.
protected function defineEnvironment($app): void
{
    $this->swapModel('media.media_model', CustomMedia::class);

    parent::defineEnvironment($app);
}

Reset stavu bez down()

Roundly balíky migrujú len dopredu a metódu down() nemajú. Testbench bežne resetuje stav príkazom migrate:rollback po každom teste — a keďže migrátor Laravelu chýbajúce down() ticho preskočí, na reálnej databáze tento rollback nespraví nič. Tabuľky prežijú a ďalší test zlyhá na duplicitnej tabuľke, pričom chybová hláška ukazuje na nevinnú migráciu.

PackageTestCase preto reálnu databázu resetuje zmazaním všetkých tabuliek a opätovnou migráciou, potom uzavrie všetky pripojenia, ktoré test otvoril — takže Postgres sada nenecháva otvorený jeden backend na každý test. Na SQLite :memory: je resetom samotné pripojenie, preto sa od verzie 1.6 vynecháva aj rollback, ktorý by Testbench spustil pre každú položku migrationSources() — mazal len riadky tabuľky migrations v databáze, ktorá vzápätí aj tak zanikne, a zaberal zhruba pätinu času SQLite sady (tickets-for-laravel: z 83 s na 60 s). Pri migrátore, ktorý si test pridá sám (loadMigrationsFrom(), loadLaravelMigrations()), rollback prebehne ako doteraz. Nič netreba konfigurovať, stačí rozšíriť základný test case:

abstract class TestCase extends PackageTestCase
{
    protected function packageProviders(): array { return [WalletServiceProvider::class]; }
    protected function migrationSources(): array { return [WalletServiceProvider::class]; }
}
  • Sady používajúce RefreshDatabase (alebo LazilyRefreshDatabase) zostávajú nedotknuté — reset si riadia samy.
  • Aj sady bez migrácií sa resetujú: tabuľka, ktorú si fixture v jednom teste vytvorí ručne, neprežije do ďalšieho.
  • Prečo nie RefreshDatabase ako predvolba: obalí každý test transakciou a pridá tak jednu úroveň — záznamníky zámkov však overujú práve transactionDepth. Zmazanie je čisté DDL a žiadnu transakciu neotvára.

Vlastný TestCase

Ak PackageTestCase nerozširujete, zavolajte DriverMatrix::configure($app) vo vlastnom defineEnvironment() a reset zmazaním tabuliek si zopakujte sami — bez neho vetva s reálnou databázou fungovať nebude.

Ponechanie zmigrovanej schémy (voliteľné)

Od verzie 1.6. Predvolene každý test zmigruje všetky zdroje a po ňom sa zmažú všetky tabuľky. Pri mnohých zdrojoch migrácií alebo na reálnej databáze na to padne väčšina času sady: tickets-for-laravel spúšťal 1061 migrácií nad 9 zdrojmi a migrácia pred každým testom tvorila asi 90 % jeho MySQL vetvy. Keď ponechanie zapnete, worker zmigruje schému raz pre každú signatúru a potom resetuje už len dáta:

abstract class TestCase extends PackageTestCase
{
    protected function keepsMigratedSchema(): bool
    {
        return true;
    }

    // Only if a migration reads something the signature cannot see:
    protected function schemaSignatureExtras(): array
    {
        return ['tenancy' => env('PKG_TENANCY')];
    }
}

Kedy ho zapnúť

  • Veľa zdrojov migrácií — balík, ktorého testy migrujú vlastnú schému a k tomu schémy viacerých závislostí (tickets-for-laravel: 9 zdrojov, asi 35 tabuliek).
  • Vetvy s reálnou databázou (test-pgsql, test-mysql) — migrácia tam stojí DDL volania pre každú tabuľku pri každom teste. MySQL vetva tickets-for-laravel sa skrátila z 32 min na 1 min 40 s a Postgres vetva z 5 min 10 s na 1 min 26 s, pričom všetky testy súbehu bežia naďalej.
  • Neoplatí sa pri sade s jedným malým zdrojom migrácií, ktorá beží len na SQLite — predvolený reset je tam lacný už teraz.

Čo každý test naďalej dostane

ZárukaAko
Začína na prázdnych tabuľkáchPo každom teste sa vyprázdni každá tabuľka, do ktorej test zapisoval — na MySQL a SQLite cez DELETE, na Postgrese cez TRUNCATE … CASCADE — s ohľadom na cudzie kľúče.
Id ako po čerstvej migráciiKaždé počítadlo kľúčov sa vráti tam, kde ho migrácia nechala — MySQL AUTO_INCREMENT, sekvencie Postgresu (setval vrátane startingValue()), SQLite sqlite_sequence —, a to aj počítadlá, ktoré posunul vrátený insert.
Bez obaľujúcej transakcietransactionLevel() testu patrí len jemu, takže transactionDepth v LockRecorderi aj forknuté súbežné procesy sa správajú presne ako pri resete zmazaním.
Zmenená schéma sa nikdy nepoužije znovaKontrola katalógu po každom teste porovná schému so snímkou po migrácii; pri akomkoľvek rozdiele sa schéma zahodí a ďalší test migruje od začiatku.
Seedované riadky prežijúAk migrácie zapisujú riadky, harness ich po prvej migrácii zistí a pre túto schému ponechá reset zmazaním po každom teste — žiadny test o svoje seedované riadky nepríde.
Databáza skončí prázdnaKeď worker skončí, zmaže svoje tabuľky, ako ich nechával reset zmazaním; čerstvá migrácia navyše najprv odstráni zvyšky po prerušenom behu.

Signatúra schémy a podporované enginy

Signatúru tvorí pripojenie (driver, databáza workera, voľby), adresáre migrácií v poradí, načítané providery v poradí, celý konfiguračný repozitár, statické predvoľby schema buildera (defaultStringLength, defaultMorphKeyType, defaultTimePrecision) a schemaSignatureExtras(). Objekty v konfigurácii sa porovnávajú podľa triedy — enum podľa prípadu —, takže dve closures vyzerajú rovnako. Test s inou signatúrou — vetva s kľúčmi uuid či ulid, vymenený model, iná konfigurácia — ponechanú schému zahodí a zmigruje si vlastnú. Worker drží naraz jednu schému.

Podporované sú MySQL, MariaDB, PostgreSQL a SQLite v pamäti (:memory:). SQLite databáza v súbore alebo so zdieľanou cache, SQL Server, pripojenie s prefixom tabuliek a každá sada s RefreshDatabase zostávajú pri resete zmazaním po každom teste — zapnutie potom nemá žiadny účinok.

Test case bez migrationSources() nemá čo ponechať, a preto tiež zostáva pri resete zmazaním po každom teste — tabuľka, ktorú si test vytvorí ručne, pred ďalším testom zmizne. Opravené vo verzii 1.6.1: 1.6.0 tam každý databázový test zhodila chybou „no such table: migrations“ — na 1.6.0 v takom test case prepíšte keepsMigratedSchema() tak, aby vracala false.

Čo pokrýva kontrola katalógu

Obmedzená na vlastnú schému pripojenia (Postgres) alebo jeho databázu (MySQL):

  • Každý engine — každá tabuľka a jej stĺpce (poradie, typ, nullability, predvolená hodnota), indexy, primárne, unikátne, cudzie a check obmedzenia, pohľady, triggery a každý riadok tabuľky migrations.
  • PostgreSQL — navyše druh každej relácie, identity a generované stĺpce, definície pohľadov a pri každej sekvencii typ, hranice, začiatok a krok.
  • MySQL — navyše engine a collation každej tabuľky a collation a extra každého stĺpca.
  • SQLite — DDL každého objektu v sqlite_master a sqlite_temp_master (vrátane create temp table), prepínač foreign_keys a pripojené databázy.

Nepokrýva — test, ktorý niečo z toho zmení, to musí sám vrátiť, presne ako pri resete zmazaním, ktorý to tiež neodstráni: typy Postgresu (enumy, domény), funkcie, rozšírenia a iné schémy; uložené procedúry, funkcie, udalosti a oprávnenia v MySQL; a pri SQLite v pamäti zvyšný stav relácie zdieľaného pripojenia — ostatné pragmy (recursive_triggers, case_sensitive_like, …) a funkcie zaregistrované na PDO. Na SQLite je ponechaná databáza jedno PDO, ktoré dostane každý test; na reálnej databáze sa relácia každého testu po ňom zatvorí, takže stav relácie tam uniknúť nemôže.

Riziká a pravidlá

  • Forknuté procesy musia skončiť cez SIGKILL. Súbežný proces vytvorený cez pcntl_fork() zdieľa databázové relácie rodiča — reláciu testu aj ponechanej schémy. Proces, ktorý zavolá exit() alebo sa vráti, spustí deštruktory a tie tieto relácie na serveri zatvoria. Po zapísaní výsledku ho ukončite cez posix_kill(posix_getpid(), SIGKILL) a dajte mu vlastné pripojenie (DriverMatrix::connectionConfig() pod iným názvom). Ak proces ponechanú reláciu aj tak zatvorí, ďalší reset odmietne pokračovať a ďalší test migruje od začiatku — nikdy nevznikne napoly resetovaná schéma.
  • Stav, ktorý migrácia číta a signatúra ho nevidí — premenná prostredia, statický príznak, stav objektu —, patrí do schemaSignatureExtras(). Inak dva testy, ktoré by migrovali rozdielne, zdieľajú jednu schému a kontrola katalógu to nerozozná: porovnáva s tou prvou.
  • Iné test cases nad tou istou reálnou databázou: test, ktorý schému neponecháva, ponechanú schému pred svojou migráciou zahodí, takže zmiešané sady fungujú — pokiaľ každý test case prechádza cez PackageTestCase::defineDatabaseMigrations(). Test case, ktorý ju nad tou istou databázou workera obíde, uvidí ponechané tabuľky.
  • Seedujúce migrácie nezrýchlite — zostávajú pri resete zmazaním. Ak o zrýchlenie stojíte, seedujte v teste alebo v defineDatabaseSeeders().

Úskalia

Prvé dve sú tiché: sada zostane zelená aj správna, len migruje pred každým testom, akoby ponechanie nikdy nezapli — príznakom je vetva s reálnou databázou, ktorá nie je o nič rýchlejšia. Tretie zafarbí test na červeno.

Náhodná hodnota konfigurácie v každom teste. Signatúra obsahuje celú konfiguráciu, takže configBeforeBoot(), ktorá pre každý test vygeneruje nový app.key — alebo akúkoľvek inú náhodnú hodnotu —, dá každému testu novú signatúru a nič sa nikdy neponechá: advertisements-for-laravel migroval 443-krát v 443 testoch. Hodnotu vygenerujte raz na proces:

private static ?string $appKey = null;

protected function configBeforeBoot(): array
{
    return ['app.key' => self::$appKey ??= 'base64:'.base64_encode(random_bytes(32))];
}

Tabuľky pre fixtures mimo migrationSources(). Prepísané defineDatabaseMigrations(), ktoré po parent::defineDatabaseMigrations() vytvorí tabuľky pre fixtures (alebo migruje ďalšie), či atribút #[DefineDatabase] zmenia schému až po snímke, takže kontrola katalógu zistí odchýlku a po každom teste schému zahodí. Tabuľky pre fixtures presuňte do vlastného adresára migrácií a uveďte ho v migrationSources() — addresses-for-laravel takto presunul svoju tabuľku test_models do tests/database/migrations/:

protected function migrationSources(): array
{
    return [AddressesServiceProvider::class, __DIR__.'/database/migrations'];
}

Event::fake() spolu s assertNothingDispatched(). Taký test môže prejsť len preto, že migrácia pred každým testom nabootovala modely balíka ešte pred Event::fake(). S ponechanou schémou ich nabootujú až vlastné dopyty testu, keď je fake aktívny, a fake zaznamená interné udalosti Eloquentu eloquent.booting a eloquent.booted — assertNothingDispatched() potom zlyhá na udalostiach, ktoré testovaný kód nikdy nevyvolal. Fakeujte len udalosti, o ktoré vám ide (Event::fake([OrderPlaced::class])), alebo okno zúžte: fake tesne pred testovanou akciou, overenie hneď po nej.

CI pre vetvy s reálnou databázou

Ponechaná schéma odstráni migráciu pred každým testom; ďalším nákladom je trvanlivosť zápisu samotného enginu. Engine spustite cez docker run ako prvý krok úlohy — nabehne, kým sa inštaluje PHP — na tmpfs s vypnutou trvanlivosťou zápisu. Plánovač, úrovne izolácie, zámky aj limity zostávajú rovnaké; nič, čo testovacia vetva dokazuje, nezávisí od toho, či databáza prežije výpadok prúdu:

test-pgsql:
  runs-on: ubuntu-latest
  name: P8.4 - pgsql real-engine
  steps:
    - name: Start postgres
      run: >-
        docker run -d --name postgres --tmpfs /var/lib/postgresql/data
        -e POSTGRES_DB=testing -e POSTGRES_USER=testing -e POSTGRES_PASSWORD=secret
        -p 5432:5432 postgres:16
        -c fsync=off -c synchronous_commit=off -c full_page_writes=off
    # … checkout, setup-php (pgsql, pdo_pgsql, pcntl; coverage: none), composer update …
    # The image's init server listens on the socket only, so a TCP answer means the real one is up.
    - name: Wait for postgres
      run: |
        for _ in $(seq 1 90); do
          docker exec postgres psql -h 127.0.0.1 -U testing -d testing -c 'select 1' >/dev/null 2>&1 && exit 0
          sleep 1
        done
        docker logs postgres
        exit 1
    - name: Execute tests against postgres
      env:
        TESTING_DB_DRIVER: pgsql
        TESTING_DB_HOST: 127.0.0.1
        TESTING_DB_PORT: 5432
        TESTING_DB_DATABASE: testing
        TESTING_DB_USERNAME: testing
        TESTING_DB_PASSWORD: secret
      run: vendor/bin/pest --ci --parallel

MySQL, ak balík potrebuje vetvu test-mysql — performance_schema zostáva zapnutá, pretože testy súbehu čítajú data_lock_waits:

- name: Start mysql
  run: >-
    docker run -d --name mysql --tmpfs /var/lib/mysql
    -e MYSQL_DATABASE=testing -e MYSQL_ROOT_PASSWORD=secret
    -p 3306:3306 mysql:8
    --skip-log-bin --sync-binlog=0 --innodb-flush-log-at-trx-commit=0 --innodb-doublewrite=OFF
# wait: docker exec mysql mysql -h 127.0.0.1 -uroot -psecret -e 'select 1' testing
# env: TESTING_DB_DRIVER: mysql, TESTING_DB_PORT: 3306, TESTING_DB_USERNAME: root

Ak sada testuje správanie binárneho logu (v testoch hľadajte log_bin alebo binlog), vynechajte --skip-log-bin — StatementBinlogTest v lifecycle sa bez binárneho logu ticho preskočí — a --sync-binlog=0 ponechajte. Porovnajte počet preskočených testov na reálnej databáze pred prechodom a po ňom; práve tak sa na to prišlo.

--parallel dá každému workeru vlastnú databázu (<database>_<token>), takže každý drží vlastnú schému. Vlastná pgsql vetva balíka pridáva po testoch ešte jeden krok: zlyhá, ak v databáze zostane tabuľka — a práve to dokazuje, že worker pri skončení svoje tabuľky zmaže.

Prejavte lásku k open source

Tento balík je zadarmo pod licenciou MIT. Ak vám šetrí čas, jednorazový príspevok alebo členstvo na Patreone nám pomôže ho ďalej udržiavať, testovať a dokumentovať.

Ďalšie spôsoby podpory vrátane kryptomien

Odoslaním daru súhlasíte s našimi podmienkami prijímania darov.

Chcete to zabudovať do svojho produktu?

Naše balíky integrujeme do zákazkových Laravel a AI riešení. Napíšte nám, na čom pracujete, a ozveme sa do 48 hodín.