Att testa webbkomponenter
I artikeln Att testa din kod i kursvecka 7 testade du klasser som körs i Node. En webbkomponent är också en klass, men den behöver en DOM, och en DOM finns inte i Node. Den här artikeln handlar om var en komponents tester körs, vad i en komponent som är värt att testa och hur du gör det. Den bygger på grunderna från kursvecka 7 och på de tre föregående artiklarna om händelser.
Du har redan kört sådana tester: npm test i övningsuppgiften bart-board kör tester mot
komponenten. Exemplen här använder user-card från kursvecka 10 och 11, med sin knapp Follow och
sin händelse follow.
Tidsåtgång: Ungefär 15 minuter.
Var körs testet?
Section titled “Var körs testet?”Det finns två sätt att ge testerna en DOM:
- En simulerad DOM i Node. Paketet jsdom är en implementation
av DOM:en och många av webbläsarens API:er skriven i JavaScript. Testerna körs i Node som vanligt,
men har tillgång till
document,customElements,CustomEventoch så vidare. Så är testerna ibart-boarduppsatta. - En riktig webbläsare. Vitests browser mode startar en webbläsare, till exempel Chromium, och kör testerna i den.
jsdom är snabbt och enkelt att sätta upp, och räcker för det mesta du testar i en komponent: attribut, metoder, händelser och vad som står i shadow DOM. Men jsdom är inte en webbläsare. Det viktigaste som saknas är layout. Ingenting renderas, så storlekar och positioner är alltid noll:
heading.getBoundingClientRect().height// jsdom: 0// Chromium: 22Beror din komponent på hur stor den är, var den är placerad eller om något faktiskt syns på skärmen, behöver du en riktig webbläsare.
Komponenttester med jsdom
Section titled “Komponenttester med jsdom”Uppsättningen
Section titled “Uppsättningen”Installera Vitest och jsdom som utvecklingsberoenden:
npm install --save-dev vitest jsdomLägg sedan till en konfigurationsfil i projektets rot, samma som i bart-board:
import { defineConfig } from 'vitest/config'
export default defineConfig({ test: { environment: 'jsdom', include: ['src/**/*.test.js'], },})environment: 'jsdom' är det som ger testerna en DOM. Testfilen läggs bredvid komponenten, i dess
egen katalog, som src/js/components/user-card/user-card.test.js.
Vitest bygger på Vite, och därför fungerar importer som ./user-card.html?raw och
./user-card.css?inline i testerna precis som i applikationen.
Registrera under ett eget namn
Section titled “Registrera under ett eget namn”import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest'import { UserCard } from './user-card.js'
const TAG_NAME = 'test-user-card'
beforeAll(() => { customElements.define(TAG_NAME, UserCard)})
afterEach(() => { document.body.replaceChildren()})Testet importerar klassen, inte index.js, och registrerar den under ett eget namn. Det är skälet
till uppdelningen i
En komponents filer: klassen
kan importeras utan att något registreras, och testerna bestämmer själva hur och när det sker.
beforeAll() körs en gång innan filens första test, eftersom ett namn bara kan registreras en gång
per sida. afterEach() körs efter varje test och tömmer dokumentet, så att varje test börjar från
en tom sida och inte påverkas av elementen från förra testet.
En liten hjälpfunktion skapar elementet, sätter attributen och lägger in det i dokumentet:
/** * Creates a user card, sets its attributes and adds it to the document. * * @param {object} [attributes] - Attribute names and values to set. * @returns {HTMLElement} The connected element. */function createCard(attributes = {}) { const card = document.createElement(TAG_NAME) for (const [name, value] of Object.entries(attributes)) { card.setAttribute(name, value) } document.body.append(card) return card}Vad ska testas i en komponent?
Section titled “Vad ska testas i en komponent?”Samma regel som för klasserna i A3 gäller: testa det publika gränssnittet. För en komponent är
det det som står i dess README.md: attributen, metoderna och händelserna. Data skickas ned med
attribut och metoder och upp med händelser, och det är precis så testerna pratar med komponenten.
Attribut
Section titled “Attribut”describe('user-card', () => { describe('attribute name', () => { test('is shown in the heading', () => { const card = createCard({ name: 'Ada Lovelace' })
expect(card.shadowRoot.querySelector('h3').textContent).toBe('Ada Lovelace') })
test('updates the heading when it changes', () => { const card = createCard({ name: 'Ada Lovelace' })
card.setAttribute('name', 'Grace Hopper')
expect(card.shadowRoot.querySelector('h3').textContent).toBe('Grace Hopper') })
test('defaults to Unknown', () => { const card = createCard()
expect(card.shadowRoot.querySelector('h3').textContent).toBe('Unknown') }) })})För att se resultatet går testet in i komponentens shadow DOM. Det är en kompromiss: testet vet nu
att namnet står i en <h3>, vilket är en detalj inuti komponenten. Byter du till en <h2> måste
testet ändras, fast komponenten fungerar lika bra. Längre ned ser du hur ett test i en riktig
webbläsare kan undvika det.
Ett test som hittar ett fel
Section titled “Ett test som hittar ett fel”Anta att mallen har en tom rubrik, <h3 class="name"></h3>, och att standardvärdet bara sätts i
attributeChangedCallback(). Då går två av testerna igenom, men det tredje misslyckas. Med
npx vitest run --reporter=verbose skrivs varje test ut på en egen rad, med hela namnkedjan:
✓ user-card.test.js > user-card > attribute name > is shown in the heading 8ms ✓ user-card.test.js > user-card > attribute name > updates the heading when it changes 1ms × user-card.test.js > user-card > attribute name > defaults to Unknown 4ms → expected '' to be 'Unknown' // Object.is equalityFråga: varför blir rubriken tom?
Section titled “Fråga: varför blir rubriken tom?”attributeChangedCallback() sätter textContent till newValue ?? 'Unknown', och newValue är null
när attributet saknas. Ändå blir rubriken tom. Varför?
attributeChangedCallback() anropas bara när ett observerat attribut läggs till, ändras eller tas
bort. Ett attribut som aldrig har funnits har inte ändrats, så metoden anropas inte alls, och
standardvärdet sätts aldrig. ?? 'Unknown' hanterar bara fallet att attributet tas bort.
Det enklaste sättet att rätta det är att låta mallen innehålla standardvärdet från början:
<article> <h3 class="name">Unknown</h3> <slot></slot> <button type="button">Follow</button></article>Felet syns inte när du provar komponenten för hand, eftersom du nästan alltid skriver
<user-card name="...">. Att testa vad som händer när ett attribut saknas är ett typiskt
gränsfall.
Händelser
Section titled “Händelser”En händelse testas genom att testet gör det som komponenten lyssnar på, och själv lyssnar efter det
komponenten ska skicka. Som lyssnare används en mockfunktion, vi.fn(): en tom funktion som
kommer ihåg hur många gånger och med vilka argument den har anropats.
describe('event follow', () => { test('is dispatched with the name when the button is clicked', () => { const card = createCard({ name: 'Ada Lovelace' }) const onFollow = vi.fn() card.addEventListener('follow', onFollow)
card.shadowRoot.querySelector('button').click()
expect(onFollow).toHaveBeenCalledTimes(1) const event = onFollow.mock.calls[0][0] expect(event.detail).toEqual({ name: 'Ada Lovelace' }) })})onFollow.mock.calls är en array med ett element per anrop, och varje element är en array med
anropets argument. mock.calls[0][0] är alltså det första argumentet i det första anropet:
händelseobjektet.
Livscykeln
Section titled “Livscykeln”Regeln från förra artikeln, att lyssnare
läggs till i connectedCallback() och tas bort i disconnectedCallback(), går också att testa:
test('is not dispatched after the card has been removed', () => { const card = createCard({ name: 'Ada Lovelace' }) const onFollow = vi.fn() card.addEventListener('follow', onFollow)
card.remove() card.shadowRoot.querySelector('button').click()
expect(onFollow).not.toHaveBeenCalled()})
test('is dispatched again after the card has been moved', () => { const card = createCard({ name: 'Ada Lovelace' }) const onFollow = vi.fn() card.addEventListener('follow', onFollow)
const section = document.createElement('section') document.body.append(section) section.append(card) card.shadowRoot.querySelector('button').click()
expect(onFollow).toHaveBeenCalledTimes(1)})Det andra testet är just fallet från frågan om bart-board i förra artikeln: en komponent som
lägger till sina lyssnare i konstruktorn men tar bort dem i disconnectedCallback() slutar fungera
när den flyttas. Det här testet hade upptäckt det.
bart-board skriver en bokstav i taget med en timer. Att vänta på riktiga timers gör testerna
långsamma och opålitliga, så testerna i bart-board använder i stället falska timers.
Hjälpfunktionerna createBoard() och getText() finns i samma testfil och fungerar som
createCard() ovan.
beforeEach(() => { vi.useFakeTimers()})
afterEach(() => { vi.useRealTimers()})
test('writes the text letter by letter at the configured speed on mousedown', () => { const board = createBoard({ text: 'AB', speed: 10 })
board.dispatchEvent(new MouseEvent('mousedown')) expect(getText(board)).toBe('')
vi.advanceTimersByTime(10) expect(getText(board)).toBe('A')
vi.advanceTimersByTime(10) expect(getText(board)).toBe('AB')})vi.useFakeTimers() byter ut setTimeout() och setInterval() mot versioner som Vitest styr.
Ingen tid går av sig själv. I stället flyttar vi.advanceTimersByTime(10) klockan framåt 10
millisekunder, och alla timers som skulle ha körts under den tiden körs direkt. Testet blir snabbt
och ger samma resultat varje gång.
Lägg också märke till att testet utlöser mousedown med dispatchEvent(). I jsdom finns ingen mus,
så testet skapar händelsen själv.
I en riktig webbläsare
Section titled “I en riktig webbläsare”Med Vitests browser mode körs samma sorts tester i en riktig webbläsare, som Playwright startar och styr. Det kräver ett paket till och en webbläsare som Playwright kan styra:
npm install --save-dev @vitest/browser-playwrightnpx playwright install chromiumWebbläsartesterna får en egen konfigurationsfil:
import { defineConfig } from 'vitest/config'import { playwright } from '@vitest/browser-playwright'
export default defineConfig({ test: { include: ['src/**/*.browser.test.js'], browser: { enabled: true, provider: playwright(), headless: true, instances: [{ browser: 'chromium' }], }, },})och ett eget skript, "test:browser": "vitest run --config vitest.browser.config.js". Har du båda
sorterna i samma projekt behöver jsdom-konfigurationen hoppa över filerna som slutar på
.browser.test.js, med exclude.
Hitta element som användaren gör
Section titled “Hitta element som användaren gör”import { afterEach, beforeAll, expect, test, vi } from 'vitest'import { page } from 'vitest/browser'import { UserCard } from './user-card.js'
// ... beforeAll and afterEach as above
test('shows the name as a heading', async () => { document.body.innerHTML = `<${TAG_NAME} name="Ada Lovelace"></${TAG_NAME}>`
await expect.element(page.getByRole('heading', { name: 'Ada Lovelace' })).toBeVisible()})
test('dispatches follow when the user clicks Follow', async () => { document.body.innerHTML = `<${TAG_NAME} name="Ada Lovelace"></${TAG_NAME}>` const onFollow = vi.fn() document.querySelector(TAG_NAME).addEventListener('follow', onFollow)
await page.getByRole('button', { name: 'Follow' }).click()
expect(onFollow).toHaveBeenCalledTimes(1) const event = onFollow.mock.calls[0][0] expect(event.detail).toEqual({ name: 'Ada Lovelace' })})Tre saker skiljer sig från jsdom-testerna:
page.getByRole()letar upp element efter deras roll och tillgängliga namn, som en skärmläsare gör: en rubrik med texten “Ada Lovelace”, en knapp med texten “Follow”. Testet behöver inte veta att rubriken är en<h3>, och sökningen går rakt igenom komponentens öppna shadow DOM. Hittar testet inte knappen via sin roll, hittar inte en skärmläsare den heller. Testet kontrollerar alltså en del av tillgängligheten på köpet..click()klickar som en användare. Playwright skickar klicket genom webbläsarens egen hantering av mus och tangentbord, och kontrollerar först att knappen syns och går att klicka på, i stället för att testet skapar händelsen själv.expect.element()väntar. Kontrollen görs om tills den går igenom eller tills tiden tar slut. Det gör att testet tål att något dyker upp en kort stund senare, och därför står detawaitframför.
Fråga: varför hittas inte rubriken?
Section titled “Fråga: varför hittas inte rubriken?”Ett test sätter attributet hidden på kortet och vill kontrollera att det inte syns:
test('is hidden by the hidden attribute', async () => { document.body.innerHTML = `<${TAG_NAME} name="Ada Lovelace" hidden></${TAG_NAME}>`
await expect.element(page.getByRole('heading', { name: 'Ada Lovelace' })).not.toBeVisible()})Testet misslyckas efter en stund med Cannot find element with locator: getByRole('heading', { name: 'Ada Lovelace' }). Rubriken finns ju, och den syns inte. Varför hittas den inte?
getByRole() söker i tillgänglighetsträdet, samma träd som en skärmläsare läser. Element som är
dolda, med hidden eller display: none, tas inte med i det trädet alls. För getByRole() finns
rubriken alltså inte, och då kan expect.element() inte kontrollera något om den.
Kontrollera i stället själva kortet, som finns kvar i DOM:en även när det är dolt:
const card = page.elementLocator(document.querySelector(TAG_NAME))
await expect.element(card).not.toBeVisible()Felet säger också något om tillgänglighet: det som är dolt för ögat är dolt för skärmläsaren också, och det är oftast precis vad du vill.
När behövs en riktig webbläsare?
Section titled “När behövs en riktig webbläsare?”| jsdom | Browser mode | |
|---|---|---|
| Uppsättning | Ett paket | Två paket och en nedladdad webbläsare |
| Körs i | Node | Chromium, Firefox eller WebKit |
| Layout och storlekar | Saknas, allt är noll | Som för användaren |
| Interaktion | Testet skapar händelser med dispatchEvent() eller click() |
Riktig mus och riktigt tangentbord via Playwright |
| Hitta element | shadowRoot.querySelector() |
page.getByRole(), även genom shadow DOM |
En rimlig tumregel är att börja med jsdom, som kursens övningsuppgifter gör, och ta till browser
mode när du testar något jsdom inte kan visa: layout, fokus, rullning eller hur komponenten beter
sig i olika webbläsare. Med fler poster i instances, { browser: 'firefox' } och
{ browser: 'webkit' }, körs samma tester i alla tre familjer av webbläsarmotorer från
kursvecka 8.
Hela applikationen
Section titled “Hela applikationen”Komponenttester testar en komponent i taget. Det som återstår är att testa hela applikationen, som användaren upplever den: sidan laddas, användaren fyller i sitt namn, svarar på en fråga och ser resultatet. Det är end-to-end-tester, toppen av testpyramiden från kursvecka 7.
De vanligaste verktygen för det är Playwright, samma verktyg som styr webbläsaren i Vitests browser mode, och Cypress. Båda startar applikationen, öppnar den i en webbläsare och styr den som en användare. Playwright har under de senaste åren blivit det vanligaste valet, bland annat eftersom det kör samma tester i Chromium, Firefox och WebKit.
End-to-end-tester ingår inte i kursen. Men det du har gjort när du provat din applikation i webbläsaren, steg för steg enligt en plan, är samma sak för hand.
Testbara komponenter
Section titled “Testbara komponenter”Det mesta i den här artikeln har gått att testa utan krångel, eftersom komponenterna följer kursens mönster:
- Data ned via attribut och metoder, upp via händelser. Testet sätter attribut, anropar metoder och lyssnar på händelser, precis som en förälder gör.
- Lyssnaren gör lite, metoden gör jobbet. En publik metod som
stopWriting()kan testas direkt, utan att testet behöver skapa någon händelse. - Klassen registrerar sig inte själv. Testet kan importera klassen och registrera den under ett eget namn.
- Lyssnare och timers hör ihop med livscykeln. Att komponenten städar efter sig går att
kontrollera, som i testerna av
followefterremove()och efter en flytt.
Det omvända gäller också: är en komponent svår att testa är det ofta ett tecken på att den gör för mycket, eller att den pratar med omvärlden på andra sätt än via sitt publika gränssnitt.
Automatiska tester ligger utanför kursen och är inget krav i examinationsuppgift B2. Men vill du
prova är en komponent som countdown-timer, med en timer och en händelse, ett bra första tillfälle.
Att ta med sig
Section titled “Att ta med sig”- En komponent behöver en DOM. jsdom simulerar den i Node, Vitests browser mode använder en riktig webbläsare.
- Importera klassen och registrera den under ett eget namn i testet. Töm dokumentet efter varje test.
- Testa det publika gränssnittet: attribut, metoder och händelser. Lyssna på händelser med en
mockfunktion,
vi.fn(). - Testa livscykeln: vad som händer när komponenten tas bort, och när den flyttas.
- Använd falska timers,
vi.useFakeTimers(), för komponenter som bygger på tid. - I en riktig webbläsare hittar
page.getByRole()element som en skärmläsare gör, även genom shadow DOM.
Vidare läsning
Section titled “Vidare läsning”- Test Environment och Browser Mode i Vitests dokumentation.
- Mock Functions och Fake Timers i Vitests dokumentation.
- Testfilen
bart-board.test.jsi övningsuppgiften Bart Board, med kommentarer om vad varje del testar. - ARIA roles på MDN,
om rollerna som
getByRole()söker efter.