Siirry sisältöön

c

React-sovellusten testaaminen

Reactilla tehtyjen frontendien testaamiseen on monia tapoja. Aloitetaan niihin tutustuminen nyt.

Kurssilla käytettiin aiemmin React-komponenttien testaamiseen Facebookin kehittämää Jest-kirjastoa. Käytämme kurssilla nyt Viten kehittäjien uuden generaation testikirjastoa Vitestiä. Konfigurointia lukuunottamatta kirjastot tarjoavat saman ohjelmointirajapinnan, joten testauskoodissa ei käytännössä ole mitään eroa.

Aloitetaan asentamalla Vitest sekä Web-selainta simuloiva jsdom-kirjasto:

npm install --save-dev vitest jsdom

Tarvitsemme Vitestin lisäksi testaamiseen apukirjaston, jonka avulla React-komponentteja voidaan renderöidä testejä varten.

Tähän tarkoitukseen ehdottomasti paras vaihtoehto on React Testing Library. Testien ilmaisuvoimaa kannattaa laajentaa myös kirjastolla jest-dom.

Asennetaan tarvittavat kirjastot:

npm install --save-dev @testing-library/react @testing-library/jest-dom

Ennen kuin pääsemme tekemään ensimmäistä testiä, tarvitsemme hieman konfiguraatioita.

Lisätään tiedostoon package.json skripti testien suorittamiselle:

{
  "scripts": {
    // ...
    "test": "vitest run"
  }
  // ...
}

Tehdään projektin juureen tiedosto testSetup.js ja sille sisältö

import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
import '@testing-library/jest-dom/vitest'

afterEach(() => {
  cleanup()
})

Nyt jokaisen testin jälkeen suoritetaan toimenpide joka nollaa selainta simuloivan jsdomin.

Laajennetaan tiedostoa vite.config.js seuraavasti

export default defineConfig({
  // ...
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './testSetup.js', 
  }
})

Määrittelyn globals: true ansiosta testien käyttämiä avainsanoja kuten describe, test ja expect ei ole tarvetta importata testeissä.

Testataan aluksi muistiinpanon renderöivää komponenttia:

const Note = ({ note, toggleImportance }) => {
  const label = note.important
    ? 'make not important'
    : 'make important'

  return (
    <li className='note'>      {note.content}
      <button onClick={toggleImportance}>{label}</button>
    </li>
  )
}

Huomaa, että muistiinpanon sisältävällä li-elementillä on CSS-luokka note. Pääsemme sen avulla halutessamme muistiinpanoon käsiksi testistä. Emme kuitenkaan ensisijaisesti käytä CSS-luokkia testauksessa.

Komponentin renderöinti testiä varten

Tehdään testi tiedostoon src/components/Note.test.jsx eli samaan hakemistoon, jossa komponentti itsekin sijaitsee.

Ensimmäinen testi varmistaa, että komponentti renderöi muistiinpanon sisällön:

import { render, screen } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  render(<Note note={note} />)

  const element = screen.getByText('Component testing is done with react-testing-library')
  expect(element).toBeDefined()
})

Alun konfiguroinnin jälkeen testi renderöi komponentin React Testing Library ‑kirjaston tarjoaman funktion render avulla:

render(<Note note={note} />)

Normaalisti React-komponentit renderöityvät DOM:iin. Nyt kuitenkin renderöimme komponentteja testeille sopivaan muotoon laittamatta niitä DOM:iin.

Testin renderöimään näkymään päästään käsiksi olion screen kautta. Haetaan screenistä metodin getByText avulla elementtiä, jossa on muistiinpanon sisältö ja varmistetaan että elementti on olemassa:

  const element = screen.getByText('Component testing is done with react-testing-library')
  expect(element).toBeDefined()

Elementin olemassaolo tarkastetaan Vitestin expect komennon avulla. Expect muodostaa parametristaan väittämän jonka paikkansapitävyyttä voidaan testata erilaisten ehtofunktioiden avulla. Nyt käytössä oli toBeDefined joka siis testaa, onko expectin parametrina oleva element olemassa.

Suoritetaan testi:

$ npm test

> notes-frontend@0.0.0 test
> vitest


 DEV  v1.3.1 /Users/mluukkai/opetus/2024-fs/part3/notes-frontend

 ✓ src/components/Note.test.jsx (1)
   ✓ renders content

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:05:37
   Duration  812ms (transform 31ms, setup 220ms, collect 11ms, tests 14ms, environment 395ms, prepare 70ms)


 PASS  Waiting for file changes...

Kuten olettaa saattaa, testi menee läpi.

Eslint valittaa testeissä olevista avainsanoista test ja expect. Ongelmasta päästään eroon asentamalla eslint-plugin-vitest-globals:

npm install --save-dev eslint-plugin-vitest-globals

ja ottamalla plugin käyttöön muokkaamalla tiedostoa .eslint.cjs seuraavasti:

module.exports = {
  root: true,
  env: {
    browser: true,
    es2020: true,
    "vitest-globals/env": true  },
  extends: [
    'eslint:recommended',
    'plugin:react/recommended',
    'plugin:react/jsx-runtime',
    'plugin:react-hooks/recommended',
    'plugin:vitest-globals/recommended',  ],
  // ...
}

Testien sijainti

Reactissa on (ainakin) kaksi erilaista konventiota testien sijoittamiseen. Sijoitimme testit ehkä vallitsevan tavan mukaan samaan hakemistoon testattavan komponentin kanssa.

Toinen tapa olisi sijoittaa testit "normaaliin" tapaan omaan erilliseen hakemistoon. Valitaanpa kumpi tapa tahansa, on varmaa että se on jonkun mielestä täysin väärä.

Itse en pidä siitä, että testit ja normaali koodi ovat samassa hakemistossa. Noudatamme kuitenkin nyt tätä tapaa, sillä se on oletusarvo Create React App:lla konfiguroiduissa sovelluksissa.

Sisällön etsiminen testattavasta komponentista

React Testing Library ‑kirjasto tarjoaa runsaasti tapoja testattavan komponentin sisällön tutkimiseen. Itse asiassa testimme viimeisellä rivillä oleva expect on turha

import { render, screen } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  render(<Note note={note} />)

  const element = screen.getByText('Component testing is done with react-testing-library')

  expect(element).toBeDefined()})

Testi ei mene läpi, jos getByText ei löydä halutun tekstin sisältävää elementtiä.

Jos haluamme etsiä testattavia komponentteja CSS-selektorien avulla, se onnistuu renderin palauttaman container-olion metodilla querySelector:

import { render, screen } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  const { container } = render(<Note note={note} />)
  const div = container.querySelector('.note')  expect(div).toHaveTextContent(    'Component testing is done with react-testing-library'  )})

Muitakin tapoja on, esim. getByTestId, joka etsii elementtejä erikseen testejä varten luotujen id-kenttien perusteella.

Testien debuggaaminen

Testejä tehdessä törmäämme tyypillisesti moniin ongelmiin.

Olion screen -olion metodilla debug voimme tulostaa komponentin tuottaman HTML:n konsoliin. Eli kun muutamme testiä seuraavasti:

import { render, screen } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  render(<Note note={note} />)

  screen.debug()
  // ...

})

konsoliin tulostuu komponentin generoima HTML:

  <body>
    <div>
      <li
        class="note"
      >
        Component testing is done with react-testing-library
        <button>
          make not important
        </button>
      </li>
    </div>
  </body>

On myös mahdollista etsiä komponentista pienempi osa, ja tulostaa sen HTML-koodi:

import { render, screen } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  render(<Note note={note} />)

  const element = screen.getByText('Component testing is done with react-testing-library')

  screen.debug(element)
  expect(element).toBeDefined()
})

Haimme nyt halutun tekstin sisältävän elementin sisällön tulostettavaksi:

  <li
    class="note"
  >
    Component testing is done with react-testing-library
    <button>
      make not important
    </button>
  </li>

Nappien painelu testeissä

Sisällön näyttämisen lisäksi toinen Note-komponenttien vastuulla oleva asia on huolehtia siitä, että propsina välitettyä tapahtumankäsittelijäfunktiota toggleImportance kutsutaan kun noten yhteydessä olevaa nappia painetaan.

Asennetaan testiä varten apukirjasto user-event:

npm install --save-dev @testing-library/user-event

Testaus onnistuu seuraavasti:

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'import Note from './Note'

// ...

test('clicking the button calls event handler once', async () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  const mockHandler = vi.fn()

  render(
    <Note note={note} toggleImportance={mockHandler} />
  )

  const user = userEvent.setup()
  const button = screen.getByText('make not important')
  await user.click(button)

  expect(mockHandler.mock.calls).toHaveLength(1)
})

Testissä on muutama mielenkiintoinen seikka. Tapahtumankäsittelijäksi annetaan Vitestin avulla määritelty mock-funktio:

const mockHandler = vi.fn()

Jotta renderöidyn komponentin kanssa voi vuorovaikuttaa tapahtumien avulla, tulee ensin aloittaa uusi sessio. Tämä onnistuu userEvent-olion setup-metodin avulla:

const user = userEvent.setup()

Testi hakee renderöidystä komponentista napin tekstin perusteella ja klikkaa sitä:

const button = screen.getByText('make not important')
await user.click(button)

Klikkaaminen tapahtuu userEvent-olion metodin click avulla.

Testin ekspektaatio varmistaa toHaveLength matcherin avulla, että mock-funktiota on kutsuttu täsmälleen kerran:

expect(mockHandler.mock.calls).toHaveLength(1)

Mock-funktion kutsut tallennetaan mockin sisällä olevaan listaan mock.calls.

Mock-oliot ja ‑funktiot ovat testauksessa yleisesti käytettyjä valekomponentteja, joiden avulla korvataan testattavien komponenttien riippuvuuksia eli niiden tarvitsemia muita komponentteja. Mockit mahdollistavat mm. kovakoodattujen syötteiden palauttamisen ja metodikutsujen lukumäärän ja parametrien tarkkailun testauksen aikana.

Esimerkissämme mock-funktio sopi tarkoitukseen erinomaisesti, sillä sen avulla on helppo varmistaa, että metodia on kutsuttu täsmälleen kerran.

Komponentin Togglable testit

Tehdään komponentille Togglable muutama testi. Lisätään komponentin lapset renderöivään div-elementtiin CSS-luokka togglableContent:

const Togglable = forwardRef((props, ref) => {
  // ...

  return (
    <div>
      <div style={hideWhenVisible}>
        <button onClick={toggleVisibility}>
          {props.buttonLabel}
        </button>
      </div>
      <div style={showWhenVisible} className="togglableContent">        {props.children}
        <button onClick={toggleVisibility}>cancel</button>
      </div>
    </div>
  )
})

Testit ovat seuraavassa:

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Togglable from './Togglable'

describe('<Togglable />', () => {
  let container

  beforeEach(() => {
    container = render(
      <Togglable buttonLabel="show...">
        <div className="testDiv" >
          togglable content
        </div>
      </Togglable>
    ).container
  })

  test('renders its children', () => {
    screen.getByText('togglable content')
  })

  test('at start the children are not displayed', () => {
    const div = container.querySelector('.togglableContent')
    expect(div).toHaveStyle('display: none')
  })

  test('after clicking the button, children are displayed', async () => {
    const user = userEvent.setup()
    const button = screen.getByText('show...')
    await user.click(button)

    const div = container.querySelector('.togglableContent')
    expect(div).not.toHaveStyle('display: none')
  })
})

Ennen jokaista testiä suoritettava beforeEach renderöi Togglable-komponentin ja tallettaa paluuarvon kentän container samannimiseen muuttujaan.

Ensimmäinen testi tarkastaa, että Togglable renderöi sen lapsikomponentin

<div className="testDiv">
  togglable content
</div>

Loput testit varmistavat metodia toHaveStyle käyttäen, että Togglablen sisältämä lapsikomponentti on alussa näkymättömissä, eli että sen sisältävään div-elementtiin liittyy tyyli { display: 'none' }, ja että nappia painettaessa komponentti näkyy, eli näkymättömäksi tekevää tyyliä ei enää ole.

Lisätään vielä mukaan testi, joka varmistaa että auki togglattu sisältö saadaan piilotettua painamalla komponentin nappia cancel:

describe('<Togglable />', () => {

  // ...

  test('toggled content can be closed', async () => {
    const user = userEvent.setup()

    const button = screen.getByText('show...')
    await user.click(button)

    const closeButton = screen.getByText('cancel')
    await user.click(closeButton)

    const div = container.querySelector('.togglableContent')
    expect(div).toHaveStyle('display: none')
  })
})

Lomakkeiden testaus

Käytimme jo edellisissä testeissä user-event-kirjaston click-metodia nappien klikkaamiseen:

const button = screen.getByText('show...')
await user.click(button)

Käytännössä siis loimme metodin avulla click-tapahtuman metodin argumenttina annetulle komponentille. Voimme simuloida myös lomakkeelle kirjoittamista userEventin-olion avulla.

Tehdään testi komponentille NoteForm. Lomakkeen koodi näyttää seuraavalta:

import { useState } from 'react'

const NoteForm = ({ createNote }) => {
  const [newNote, setNewNote] = useState('')

  const handleChange = (event) => {
    setNewNote(event.target.value)
  }

  const addNote = (event) => {
    event.preventDefault()
    createNote({
      content: newNote,
      important: true,
    })

    setNewNote('')
  }

  return (
    <div className="formDiv">
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={handleChange}
        />
        <button type="submit">save</button>
      </form>
    </div>
  )
}

export default NoteForm

Lomakkeen toimintaperiaatteena on kutsua sille propsina välitettyä funktiota createNote uuden muistiinpanon tiedot parametrina.

Testi on seuraavassa:

import { render, screen } from '@testing-library/react'
import NoteForm from './NoteForm'
import userEvent from '@testing-library/user-event'

test('<NoteForm /> updates parent state and calls onSubmit', async () => {
  const user = userEvent.setup()
  const createNote = vi.fn()

  render(<NoteForm createNote={createNote} />)

  const input = screen.getByRole('textbox')
  const sendButton = screen.getByText('save')

  await user.type(input, 'testing a form...')
  await user.click(sendButton)

  expect(createNote.mock.calls).toHaveLength(1)
  expect(createNote.mock.calls[0][0].content).toBe('testing a form...')
})

Syötekenttä etsitään metodin getByRole avulla. Syötekenttään kirjoitetaan metodin type avulla. Testin ensimmäinen ekspektaatio varmistaa, että lomakkeen lähetys on aikaansaanut tapahtumankäsittelijän createNote kutsumisen. Toinen ekspektaatio tarkistaa, että tapahtumankäsittelijää kutsutaan oikealla parametrilla, eli että luoduksi tulee samansisältöinen muistiinpano kuin lomakkeelle kirjoitetaan.

Kannattaa huomata, että vanha kunnon console.log toimii testeissä normaaliin tapaan. Jos esim. halutaan takastella miltä mock-olion tallettamat kutsut näyttävät, voidaan tehdä seuraavasti

test('<NoteForm /> updates parent state and calls onSubmit', async () => {
  const user = userEvent.setup()
  const createNote = vi.fn()

  render(<NoteForm createNote={createNote} />)

  const input = screen.getByRole('textbox')
  const sendButton = screen.getByText('save')

  await user.type(input, 'testing a form...')
  await user.click(sendButton)

  console.log(createNote.mock.calls)})

Testien suorituksen sekaan tulostuu

[ [ { content: 'testing a form...', important: true } ] ]

Lisää elementtien etsimisestä

Oletetaan että lomakkeella olisi useita syötekenttiä:

const NoteForm = ({ createNote }) => {
  // ...

  return (
    <div>
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={handleChange}
        />
        <input          value={...}          onChange={...}        />        <button type="submit">save</button>
      </form>
    </div>
  )
}

Nyt testissä käytetty syötekentän etsimistapa:

const input = screen.getByRole('textbox')

aiheuttaisi virheen:

Konsoli kertoo virheestä TestingLibraryElementError: Found multiple elements with the role "textbox"

Virheilmoitus ehdottaa käytettäväksi metodia getAllByRole (jos tilanne ylipäätään on se mitä halutaan). Testi korjautuisi seuraavasti:

const inputs = screen.getAllByRole('textbox')

await user.type(inputs[0], 'testing a form...')

Metodi getAllByRole palauttaa taulukon, ja oikea tekstikenttä on taulukossa ensimmäisenä. Testi on kuitenkin hieman epäilyttävä, sillä se luottaa tekstikenttien järjestykseen.

Syötekentille määritellään usein placeholder-teksti, joka ohjaa käyttäjää kirjoittamaan syötekenttään oikean arvon. Lisätään placeholder lomakkeellemme:

const NoteForm = ({ createNote }) => {
  // ...

  return (
    <div>
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={handleChange}
          placeholder='write note content here'        />
        <input
          value={...}
          onChange={...}
        />    
        <button type="submit">save</button>
      </form>
    </div>
  )
}

Nyt oikean syötekentän etsiminen onnistuu metodin getByPlaceholderText avulla:

test('<NoteForm /> updates parent state and calls onSubmit', () => {
  const createNote = vi.fn()

  render(<NoteForm createNote={createNote} />) 

  const input = screen.getByPlaceholderText('write note content here')  const sendButton = screen.getByText('save')

  userEvent.type(input, 'testing a form...' )
  userEvent.click(sendButton)

  expect(createNote.mock.calls).toHaveLength(1)
  expect(createNote.mock.calls[0][0].content).toBe('testing a form...' )
})

Kaikkein joustavimman tavan tarjoaa aiemmin tässä luvussa esitellyn render-metodin palauttaman olion content-kentän metodi querySelector, joka mahdollistaa komponenttien etsimisen mielivaltaisten CSS-selektorien avulla.

Jos esim. määrittelisimme syötekentälle yksilöivän attribuutin id:

const NoteForm = ({ createNote }) => {
  // ...

  return (
    <div>
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={handleChange}
          id='note-input'        />
        <input
          value={...}
          onChange={...}
        />    
        <button type="submit">save</button>
      </form>
    </div>
  )
}

Testi löytäisi elementin seuraavasti:

const { container } = render(<NoteForm createNote={createNote} />)

const input = container.querySelector('#note-input')

Jätämme koodiin placeholderiin perustuvan ratkaisun.

Vielä muutama tärkeä huomio. Oletetaan että komponentti renderöisi samaan HTML-elementtiin tekstiä seuraavasti:

const Note = ({ note, toggleImportance }) => {
  const label = note.important
    ? 'make not important' : 'make important'

  return (
    <li className='note'>
      Your awesome note: {note.content}      <button onClick={toggleImportance}>{label}</button>
    </li>
  )
}

export default Note

Nyt testissä käyttämämme getByText ei löydä elementtiä:

test('renders content', () => {
  const note = {
    content: 'Does not work anymore :(',
    important: true
  }

  render(<Note note={note} />)

  const element = screen.getByText('Does not work anymore :(')

  expect(element).toBeDefined()
})

Komento getByText nimittäin etsii elementtiä missä on ainoastaan parametrina teksti eikä mitään muuta. Jos halutaan etsiä komponenttia joka sisältää tekstin, voidaan joko lisätä komennolle ekstraoptio:

const element = screen.getByText(
  'Does not work anymore :(', { exact: false }
)

tai käyttää komentoa findByText:

const element = await screen.findByText('Does not work anymore :(')

On tärkeä huomata, että toisin kuin muut ByText-komennoista, findByText palauttaa promisen!

On myös jotain tilanteita, missä komennon muoto queryByText on käyttökelpoinen. Komento palauttaa elementin mutta ei aiheuta poikkeusta jos etsittävää elementtiä ei löydy.

Komentoa voidaan hyödyntää esim. varmistamaan, että jokin asia ei renderöidy:

test('does not render this', () => {
  const note = {
    content: 'This is a reminder',
    important: true
  }

  render(<Note note={note} />)

  const element = screen.queryByText('do not want this thing to be rendered')
  expect(element).toBeNull()
})

Testauskattavuus

Testauskattavuus saadaan helposti selville suorittamalla testit komennolla

npm test -- --coverage

Kun suoritat ensimmäistä kertaa komennon, kysyy Vitest haluatko asentaa tarvittavan apukirjaston @vitest/coverage-v8. Asenna se, ja suorita komento uudelleen:

Konsoliin tulostuu taulukko joka näyttää kunkin tiedoston testien kattavuusraportin sekä mahdolliset testien kattamattomat rivit

HTML-muotoinen raportti generoituu hakemistoon coverage. HTML-muotoinen raportti kertoo mm. yksittäisten komponentin testaamattomat koodirivit:

Selaimeen renderöityy näkymä tiedostoista jossa värein merkattu ne rivit joita testit eivät kattaneet

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part5-8.

Frontendin integraatiotestaus

Suoritimme edellisessä osassa backendille integraatiotestejä, jotka testasivat backendin tarjoaman API:n läpi backendia ja tietokantaa. Backendin testauksessa tehtiin tietoinen päätös olla kirjoittamatta yksikkötestejä, sillä backendin koodi on melko suoraviivaista ja ongelmat tulevatkin esiin todennäköisemmin juuri monimutkaisemmissa skenaarioissa, joita integraatiotestit testaavat hyvin.

Toistaiseksi kaikki frontendiin tekemämme testit ovat olleet yksittäisten komponenttien oikeellisuutta valvovia yksikkötestejä. Yksikkötestaus on toki välillä hyödyllistä, mutta kattavinkaan yksikkötestaus ei riitä antamaan riittävää luotettavuutta sille, että järjestelmä toimii kokonaisuudessaan.

Voisimme tehdä myös frontendille useiden komponenttien yhteistoiminnallisuutta testaavia integraatiotestejä, mutta se on oleellisesti yksikkötestausta hankalampaa, sillä integraatiotesteissä jouduttaisiin ottamaan kantaa mm. palvelimelta haettavan datan mockaamiseen. Päätämmekin keskittyä koko sovellusta testaavien end to end ‑testien tekemiseen, jonka parissa jatkamme tämän osan seuraavassa luvussa.

Snapshot-testaus

Vitest tarjoaa "perinteisen" testaustavan lisäksi aivan uudenlaisen tavan testaukseen, ns. snapshot-testauksen. Mielenkiintoista snapshot-testauksessa on se, että sovelluskehittäjän ei tarvitse itse määritellä ollenkaan testejä, snapshot-testauksen käyttöönotto riittää.

Periaatteena on verrata aina koodin muutoksen jälkeen komponenttien määrittelemää HTML:ää siihen HTML:ään, jonka komponentit määrittelivät ennen muutosta.

Jos snapshot-testi huomaa muutoksen komponenttien määrittelemässä HTML:ssä, voi kyseessä olla joko haluttu muutos tai vahingossa aiheutettu "bugi". Snapshot-testi huomauttaa sovelluskehittäjälle, jos komponentin määrittelemä HTML muuttuu. Sovelluskehittäjä kertoo muutosten yhteydessä, oliko muutos haluttu. Jos muutos tuli yllätyksenä eli kyseessä oli bugi, sovelluskehittäjä huomaa sen snapshot-testauksen ansiosta nopeasti.

Emme kuitenkaan käytä tällä kurssilla snapshot-testausta.