Hoppa till innehåll

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.

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, CustomEvent och så vidare. Så är testerna i bart-board uppsatta.
  • 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: 22

Beror 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.

Installera Vitest och jsdom som utvecklingsberoenden:

npm install --save-dev vitest jsdom

Lägg sedan till en konfigurationsfil i projektets rot, samma som i bart-board:

vitest.config.js
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.

user-card.test.js
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
}

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.

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.

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 equality

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:

user-card.html
<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.

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.

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.

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-playwright
npx playwright install chromium

Webbläsartesterna får en egen konfigurationsfil:

vitest.browser.config.js
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.

user-card.browser.test.js
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 det await framför.

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.

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.

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.

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 follow efter remove() 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.

  • 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.
CCBY