Funkcja, która czeka od maja 2022
Release notes Playwrighta 1.22.0 z 12 maja 2022 miały dla mnie dwie interesujące pozycje. Pierwszą była asercja toHaveScreenshot, którą wziąłem na warsztat pół roku później, kiedy porównywałem narzędzia do visual regression. Drugą był component testing w wersji preview i tę odłożyłem na “kiedyś”. Kiedyś wypadło teraz, po piętnastu miesiącach, i to jest cała geneza tego wpisu.
Powód jest prozaiczny. 10 sierpnia wyszło 1.37.0 i podnosiłem pin w projekcie, który prowadzę od zeszłego roku. W release notes tej wersji są trzy rzeczy: reporter blob razem z narzędziem merge-reports do scalania raportów z shardów, wsparcie dla Debiana 12 i kilka poprawek w UI Mode. Żadna z nich nie dotyczy komponentów. Ale skoro i tak siedziałem w package.json i w dokumentacji, zajrzałem do zakładki, którą omijałem od maja 2022, i spędziłem z nią wieczór.
Zanim cokolwiek napiszę o samym mechanizmie, jedna rzecz, bez której ten wpis byłby nieuczciwy. Paczka po piętnastu miesiącach nadal nazywa się @playwright/experimental-ct-react i słowo experimental jest w niej pierwszym członem nazwy, nie przypisem w dokumentacji. Dokumentacja mówi wprost, że funkcja jest eksperymentalna i że API może się zmienić. To nie jest wpis o tym, że coś wreszcie dowiozło status stable, bo nic takiego się nie stało. To jest wpis o eksperymencie, na pinie 1.37.0, na jednym syntetycznym komponencie.
Nie przepisuję tu też dwóch swoich starszych tekstów. Pierwszy projekt Playwright opisuje pusty katalog, kreator i pierwszy zielony test na 1.22 i tak zostaje. Kwietniowa decyzja o schodzeniu z Cypressa opisuje, dlaczego w ogóle jestem w tym ekosystemie, i tej dyskusji nie otwieram na nowo. Tu jest tylko jedno pytanie: czy w sierpniu 2023 da się odpalić pojedynczy komponent w prawdziwej przeglądarce, bez stawiania całej aplikacji, i czy to jest coś, co chcę mieć w repozytorium.
Komponent, na którym to sprawdzam, wymyśliłem sam na potrzeby wpisu. To formularz logowania z jednym polem na adres e-mail i przyciskiem, w aplikacji, która nie istnieje. Nie ma tu ani jednej linijki z żadnego projektu, w którym pracuję, i nie będzie tu nazwy żadnego klienta.
Co dokładnie montuję
Pierwsza rzecz, którą warto zrozumieć przed jakąkolwiek komendą: component testing to osobna paczka, osobny plik konfiguracyjny i osobne uruchomienie. Nie jest to przełącznik w istniejącym playwright.config.ts. Kreator ma na to dedykowaną flagę i odpalam go w bieżącej wersji, bo kreator ma tylko wygenerować szkielet, a numery wersji ustawiam sam chwilę później:
$ npm init playwright@latest -- --ctKreator pyta o framework i po wybraniu Reacta dokłada trzy rzeczy: paczkę @playwright/experimental-ct-react, plik playwright-ct.config.ts i katalog playwright/ z dwoma plikami. Wersje wpisuje takie, jakie są najświeższe w dniu uruchomienia, i z daszkiem, więc potem robię to samo co zawsze, czyli sprzątam po kreatorze i przypinam dokładnie 1.37.0 bez daszka:
{
"scripts": {
"test": "playwright test",
"test:ct": "playwright test -c playwright-ct.config.ts"
},
"devDependencies": {
"@playwright/experimental-ct-react": "1.37.0",
"@playwright/test": "1.37.0",
"react": "18.2.0",
"react-dom": "18.2.0"
}
}Przy paczce ze słowem experimental w nazwie brak daszka nie jest przesadą, tylko minimum. 1.37.0 to jedyna wersja, na której cokolwiek z tego wpisu sprawdzałem, i jedyna, o której mogę powiedzieć, że u mnie działa.
Node zostawiam na 18 w linii LTS. Playwright 1.37 formalnie wspiera jeszcze 16, ale ta gałąź kończy wsparcie 11 września, czyli za niecały miesiąc, więc pinowanie jej w nowej konfiguracji nie miałoby sensu. Node 20 jest już wydany i celowo go tu nie stawiam: nie chcę, żeby jedyny eksperymentalny element setupu siedział obok drugiej rzeczy, której nie sprawdziłem w tym projekcie na CI.
Katalog playwright/ to miejsce, gdzie mieszka strona, na której komponent zostanie wyrenderowany. Są tam dwa pliki. index.html z pustym kontenerem:
<html lang="pl">
<body>
<div id="root"></div>
<script type="module" src="./index.ts"></script>
</body>
</html>I index.ts, który u mnie na starcie zawiera tylko import globalnych styli. To jest jedyne miejsce na rzeczy, których komponent potrzebuje, a których sam nie importuje, czyli reset CSS, font, provider motywu. Jeśli komponent działa tylko wewnątrz kontekstu, to albo ten kontekst opakowuję w teście, albo wchodzę w hooki beforeMount i wtedy setup przestaje być trywialny. Zapisuję to jako pierwszy realny koszt tej ścieżki.
Sam komponent jest tak głupi, jak umiałem go napisać, bo wpis ma być o mechanizmie, a nie o formularzu:
import { useState } from 'react'
type LoginFormProps = {
onSubmit: (email: string) => void
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('')
const [error, setError] = useState<string | null>(null)
return (
<form
onSubmit={event => {
event.preventDefault()
if (!email.includes('@')) {
setError('Podaj poprawny adres e-mail')
return
}
setError(null)
onSubmit(email)
}}
>
<label htmlFor="email">Adres e-mail</label>
<input
id="email"
name="email"
value={email}
onChange={event => setEmail(event.target.value)}
/>
{error && <p role="alert">{error}</p>}
<button type="submit">Zaloguj</button>
</form>
)
}Config jest krótszy od tego, który mam dla E2E, bo nie ma w nim ani baseURL, ani webServer. Nie ma czego uruchamiać, bo nie ma aplikacji:
import { defineConfig, devices } from '@playwright/experimental-ct-react'
export default defineConfig({
testDir: './src',
testMatch: '**/*.ct.spec.tsx',
use: {
trace: 'on-first-retry',
ctPort: 3100
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }]
})Dwie rzeczy w tym pliku są warte zdania. defineConfig importuję z paczki experimental-ct-react, nie z @playwright/test, i to nie jest kosmetyka: to jest inny runner z innym zestawem fixture’ów. ctPort to port, na którym stanie dev server budujący komponent, i podaję go jawnie, żeby nie zderzył się z niczym, co i tak mam podniesione lokalnie.
Test jest jeden i sprawdza dokładnie jedną rzecz, czyli że puste pole nie przepuszcza formularza dalej:
import { test, expect } from '@playwright/experimental-ct-react'
import { LoginForm } from './LoginForm'
test('pusty e-mail pokazuje blad i nie wola onSubmit', async ({ mount }) => {
const submitted: string[] = []
const component = await mount(
<LoginForm onSubmit={email => submitted.push(email)} />
)
await component.getByRole('button', { name: 'Zaloguj' }).click()
await expect(component.getByRole('alert')).toHaveText(
'Podaj poprawny adres e-mail'
)
expect(submitted).toEqual([])
})Uruchomienie to npm run test:ct. Pierwszy przebieg jest wyraźnie dłuższy, bo pod spodem startuje dev server Vite i buduje komponent. Kolejne mieszczą się u mnie w sekundach i to jest liczba, po którą tu przyszedłem.
Na tym kończy się nowa wiedza, którą trzeba mieć. Wszystko poniżej mount to zwykły @playwright/test: getByRole, click, toHaveText, te same timeouty, ten sam Trace Viewer, to samo zachowanie przy retry. mount zwraca Locator przypięty do korzenia wyrenderowanego komponentu, więc component.getByRole szuka wewnątrz niego, a nie po całym dokumencie. Dla kogoś, kto ma już testy E2E w Playwrightcie, próg wejścia to jedna nowa funkcja i jeden nowy config.
Jest jedna rzecz, która wygląda znajomo, a nie jest, i lepiej się o nią potknąć teraz niż za trzy tygodnie. Kod testu wykonuje się w Node, a komponent żyje w przeglądarce, więc propsy przechodzą przez granicę procesu. onSubmit z mojego testu zadziała i tablica submitted faktycznie się wypełni, gdy formularz przejdzie walidację. Ale to nie jest zwykłe wywołanie funkcji w tym samym miejscu w pamięci: propsy muszą dać się przenieść, więc obiekt z metodami albo domknięcie po stanie trzymanym w Node nie jest czymś, co bezpiecznie przekażę. Przy formularzu z jednym polem to nie boli. Przy komponencie, który dostaje klienta HTTP w propsach, jest to pierwsza ściana, w którą się wchodzi.
Odnotowuję jeszcze jedno zdanie i nie robię z niego tematu: dla Vue i Svelte istnieją siostrzane paczki o tej samej konwencji nazwy, więc opis powyżej nie jest specyficzny dla Reacta, a jedynie sprawdziłem go na Reakcie.
Gdzie to siedzi w piramidzie
Najważniejsze pytanie o nowe narzędzie nigdy nie brzmi “czy działa”, tylko “co dzięki niemu przestaję robić”. Tutaj odpowiedź jest wąska i chcę ją zapisać precyzyjnie.
Component test jest szybszy od E2E, bo nie robi trzech rzeczy, które w E2E robić muszę: nie loguje się, nie czeka na start aplikacji i nie potrzebuje danych w bazie. Cała ta warstwa, którą opisywałem, gdy wpychałem Testcontainers i Playwrighta w jeden pipeline, tu po prostu nie występuje. To nie jest optymalizacja przebiegu, to usunięcie z niego całego etapu.
Jednocześnie jest bliżej interfejsu niż test jednostkowy w symulowanym DOM. Komponent renderuje się w prawdziwym Chromium, więc click na przycisku to prawdziwe kliknięcie w prawdziwy layout, a nie zdarzenie wysłane programowo w środowisku, które CSS-a nie liczy. Element przesłonięty przez inny element nie da się kliknąć, bo naprawdę nie da się go kliknąć. Dla formularzy, w których połowa błędów siedzi w stanie disabled, focusie i kolejności tabulacji, to jest realna różnica.
Czego to nie zastępuje, punkt po punkcie. Nie zastępuje testu kontraktowego, bo w moim teście nie ma żadnego API, a kontrakt między konsumentem a dostawcą jest jedyną rzeczą, która pilnuje, że pole nie zmieniło nazwy po drugiej stronie. Nie zastępuje testu integracyjnego po stronie serwera, bo host w pamięci z podmienioną zależnością sprawdza reguły, których w komponencie w ogóle nie ma. I nie zastępuje jednego E2E na happy path, bo tylko on odpowiada na pytanie, czy formularz, endpoint i przekierowanie po zalogowaniu spotykają się w jednym miejscu.
Praktyczny podział, którego zamierzam się trzymać, mieści się w dwóch zdaniach. Stany jednego komponentu, czyli walidacja pola, komunikat błędu, blokada przycisku, wariant z długim tekstem, idą do CT, bo tam ich sprawdzenie kosztuje sekundy i nie wymaga niczego wokół. Wszystko, co przechodzi przez więcej niż jeden ekran albo dotyka backendu, zostaje w E2E, i tego zestawu nie ruszam ani o jeden przypadek w tym miesiącu.
Cena słowa experimental
Teraz część, którą chcę mieć zapisaną czarno na białym, żeby za pół roku nie tłumaczyć się z entuzjazmu.
experimental w nazwie paczki znaczy, że przy kolejnym minorze API może się zmienić bez okresu deprecacji. Nie jest to moja prognoza, tylko to, co dokumentacja mówi o tej funkcji. Skutek jest jeden i bardzo konkretny: wersja jest przypięta dokładnie, podniesienie jej idzie osobnym commitem na osobnym branchu, a jedyną rzeczą, jaką ten commit robi, jest zmiana numeru. Jeśli po podbiciu CT stanie na czerwono, chcę widzieć jedną zmianę w diffie, a nie szukać jej między dwudziestoma innymi.
Druga cena jest architektoniczna. Pod spodem stoi Vite i to jest jego dev server, nie mój build produkcyjny. Na projekcie, którego frontend też stoi na Vite, ta różnica jest niewielka. Na projekcie zbudowanym inaczej mam do wyboru dwie złe opcje: konfigurować ctViteConfig tak, aby udawał realny build, albo przyjąć, że komponent w teście jest budowany inną ścieżką niż komponent na produkcji. Druga opcja jest szczera, ale oznacza klasę błędów, których ten test nie zobaczy nigdy, i trzeba o tym wiedzieć zawczasu.
Trzecia cena jest w CI. Dwa configi to dwa uruchomienia, dwa raporty i dwa miejsca, w których coś może się wywalić. Tu z pomocą przychodzi to, co przyszło razem z 1.37.0: reporter blob i npx playwright merge-reports pozwalają scalić wyniki z kilku przebiegów w jeden raport HTML. Wersję wziąłem po to, żeby scalać sharde E2E, ale scalanie dwóch różnych configów wpada w ten sam mechanizm i to jest jedyny powód, dla którego dwa uruchomienia nie brzmią dla mnie w tym momencie jak podwojenie bałaganu.
Plan wyjścia mam prosty i to jest warunek, na jakim w ogóle to wchodzi do repozytorium. Jeśli po którymś podbiciu wersji koszt utrzymania tej konfiguracji przewyższy zysk, usuwam katalog z testami komponentów, usuwam config, usuwam paczkę i zostaję z E2E, które i tak jest tam, gdzie było. Nic w tym setupie nie jest zależnością niczego innego i chcę, żeby tak zostało.
Czego z tego wieczoru nie robię: nie ogłaszam tego standardem dla zespołu ani rekomendacją dla całej organizacji. Mam jeden komponent, jeden test i jeden pin. To jest dokładnie tyle, ile mam, i na tyle się wypowiadam.
Czego do tego nie wciągam
Lista rzeczy, których świadomie nie wpycham w component testing, jest tu ważniejsza od listy tego, co się udało, bo to ona decyduje, czy ten katalog za rok będzie miał sens.
Nie wciągam całej aplikacji. Router, layout, provider z sesją i ekran po zalogowaniu to nie komponent, tylko aplikacja z gorszą obudową. W momencie, w którym mount dostaje drzewo z pięcioma providerami, mam wolny E2E bez adresu URL, czyli najgorsze z obu światów.
Nie wciągam logowania. Cała ścieżka uwierzytelnienia, ze stanem przeglądarki i tokenem, została tam, gdzie była, czyli w testach end-to-end.
Nie robię z tego maszynki do screenshotów. toHaveScreenshot działa też na komponencie i wiem o tym, ale porównywanie wyglądu ma u mnie osobną historię, opisaną w listopadowym przeglądzie narzędzi do visual regression, i dołożenie do tej historii nowego źródła obrazków referencyjnych w tym samym tygodniu, w którym pierwszy raz odpalam CT, jest dobrym sposobem, żeby zepsuć obie rzeczy. Component testing i visual regression to dwie decyzje, nie jedna.
Nie stawiam pod tym Dockera. W tym configu nie ma bazy, kolejki ani stubu HTTP, więc nie ma czego konteneryzować. To akurat największa zaleta tej warstwy i nie zamierzam jej zjeść, dokładając komponentowi prawdziwego backendu.
Nie przepisuję istniejących testów. Ani jednego przypadku nie przeniosłem z E2E do CT i celowo nie zaczynam od migracji. Nowe stany nowych komponentów mogą powstawać tutaj. Zielone testy, które działają, zostają tam, gdzie są.
Podsumowanie
Po jednym wieczorze z @playwright/experimental-ct-react w wersji 1.37.0 mam wniosek, który mieści się w jednym zdaniu: to kolejny szczebel w drabinie, którą już mam, a nie nowy framework i nie coś, co dostało status stable.
Rozbijam to na trzy zdania, które chcę móc przeczytać za rok. Mechanizm jest banalnie prosty dla kogoś, kto zna @playwright/test, bo nową rzeczą jest jedna funkcja mount i jeden dodatkowy plik konfiguracyjny, a wszystko poniżej to te same lokatory i te same asercje. Zysk jest realny i wąski: stany pojedynczego komponentu sprawdzam w prawdziwej przeglądarce w sekundach, bez logowania, bez seedu i bez czekania na aplikację. Cena jest też realna: paczka ma experimental w nazwie, dev server jest Vite i nie musi odpowiadać mojemu buildowi produkcyjnemu, a w CI dochodzi drugie uruchomienie.
Czego z tego nie wynika. Nie wynika, że component testing jest gotowy na wdrożenie w całej organizacji, bo dokumentacja nadal ostrzega przed zmianami API. Nie wynika, że mniej potrzebuję E2E, bo nie skasowałem ani jednego testu. Nie wynika też, że to jest odpowiedź na wolny pipeline, bo mój pipeline jest wolny z powodu danych i środowiska, a nie z powodu renderowania komponentów.
Za miesiąc będę wiedział dwie rzeczy, których dziś nie wiem. Pierwsza: ile z nowych przypadków faktycznie wyląduje w CT, a ile odruchowo napiszę w E2E, bo tam mam wszystkie helpery. Druga: co się stanie przy pierwszym podbiciu wersji, bo to jest jedyny test, który naprawdę weryfikuje słowo experimental. Do tego czasu pin zostaje na 1.37.0, a decyzję o tym, czy to jest szczebel na stałe, odkładam do momentu, w którym będę miał więcej niż jeden formularz i jeden wieczór.

