b
Monimutkaisempi tila, fetch, testaaminen
Jatketaan muistiinpanosovelluksen Zustand-version laajentamista.
Sovelluskehitystä helpottaaksemme muutetaan alkutilaa siten, että siellä on jo muutama muistiinpano:
const initialNotes = [ { id: 1, content: 'Zustand is less complex than Redux', important: true, }, { id: 2, content: 'React app benefits from custom hooks', important: false, }, { id: 3, content: 'Remember to sleep well', important: true, } ]
const useNoteStore = create((set) => ({
notes: initialNotes,
// ...
}Monimutkaisempi tila
Toteutetaan sovellukseen näytettävien muistiinpanojen filtteröinti, jonka avulla näytettäviä muistiinpanoja voidaan rajata. Filtterin toteutus tapahtuu radiopainikkeiden avulla:

Herää kysymys, miten filtterin tilanhallinta kannattaisi hoitaa. Vaihtoehtoja on käytännössä kaksi: tehdään filterille erillinen store Zustandilla, tai lisätään se olemasaolevaan storeen. Kumpikin näistä ratkaisuista on perusteltavissa. Internetistä löytyvät parhaat käytänteet kehottavat pitämään toisistaan täysin erilliset asiat erillisissä storeissa. Muistiinpanojen lista ja filtteröinti ovat kuitenkin sen verran sidoksissa toisiinsa, että päädymme sijoittamaan molemmat samaan storeen:
const useNoteStore = create((set) => ({
notes: initialNotes,
filter: 'all', actions: {
add: note => set(
state => ({ notes: state.notes.concat(note) })
),
toggleImportance: id => set(
state => ({
notes: state.notes.map(note =>
note.id === id ? { ...note, important: !note.important } : note
)
})
),
setFilter: value => set(() => ({ filter: value })) }
}))
export const useNotes = () => useNoteStore((state) => state.notes)
export const useFilter = () => useNoteStore((state) => state.filter)export const useNoteActions = () => useNoteStore((state) => state.actions)Filtterille arvon asettava komponentti:
import { useNoteActions } from './store'
const VisibilityFilter = () => {
const { setFilter } = useNoteActions()
return (
<div>
<input
type="radio"
name="filter"
onChange={() => setFilter('all')}
defaultChecked
/>
all
<input
type="radio"
name="filter"
onChange={() => setFilter('important')}
/>
important
<input
type="radio"
name="filter"
onChange={() => setFilter('nonimportant')}
/>
not important
</div>
)
}
export default VisibilityFilterKomponentti App renderöi filtterin:
const App = () => (
<div>
<NoteForm />
<VisibilityFilter /> <NoteList />
</div>
)Näytettävien muistiinpanojen filtteröinti voitaisiin hoitaa komponentissa NoteList esim. seuraavassa:
import { useNotes, useFilter } from './store'
import Note from './Note'
const NoteList = () => {
const notes = useNotes()
const filter = useFilter()
const notesToShow = notes.filter(note => { if (filter === 'important') return note.important if (filter === 'nonimportant') return !note.important return true })
return (
<ul>
{notesToShow.map(note => ( <Note key={note.id} note={note} />
))}
</ul>
)
}Parempaan ratkaisuun päädytään jos filtteröintilogiikka sisällytetään suoraan storen funktioon useNotes:
import { create } from 'zustand'
const useNoteStore = create((set) => ({
// ...
}))
export const useNotes = () => { const notes = useNoteStore((state) => state.notes) const filter = useNoteStore((state) => state.filter) if (filter === 'important') return notes.filter(n => n.important) if (filter === 'nonimportant') return notes.filter(n => !n.important) return notes}Eli funktio useNotes palauttaa aina halutulla tavalla filtteröidyn muistiinpanojen listan. Funktion käyttäjän, eli komponentin NoteList ei tarvitse edes olla tietoinen filtterin olemassaolosta:
import { useNotes } from './store'
import Note from './Note'
const NoteList = () => {
// component gets always the properly filtered set of notes
const notes = useNotes()
return (
<ul>
{notes.map(note => (
<Note key={note.id} note={note} />
))}
</ul>
)
}Ratkaisu on elegantti!
Mahdollinen vaihtoehtoinen ratkaisu
Eräs vaihtoehto olisi toteuttaa filtteröinti suoraan selektorifunktion sisällä, jolloin sekä muistiinpanot että filtteri luettaisiin yhdellä useNoteStore-kutsulla:
export const useNotes = () => useNoteStore(({ notes, filter }) => { if (filter === 'important') return notes.filter(n => n.important) if (filter === 'nonimportant') return notes.filter(n => !n.important) return notes })Tämä tie kuitenkaan toimi, vaan johtaa loputtomaan uudelleenrenderöintisilmukkaan, kun filtteriä vaihdetaan.
Syy on seuraava: Zustand vertaa selektorin paluuarvoa ===-operaattorilla. Koska notes.filter(...) luo joka renderöinnillä uuden taulukon, React tulkitsee sen aina uudeksi tilaksi ja käynnistää uuden renderöinnin, joka taas luo uuden taulukon, ja niin edelleen.
Korjaus on lisätä useShallow, joka korvaa ===-vertailun "matalalla" vertailulla eli se ainoastaan vertaa taulukon alkioita yksitellen. Jos sisältö ei ole muuttunut, se palauttaa vanhan taulukon viitteen, jolloin React näkee tilan samana kuin aiemmin eikä renderöi komponenttia uudelleen.
import { useShallow } from 'zustand/react/shallow' //... export const useNotes = () => useNoteStore(useShallow(({ notes, filter }) => { if (filter === 'important') return notes.filter(n => n.important) if (filter === 'nonimportant') return notes.filter(n => !n.important) return notes }))Ratkaisu toimii, mutta se on hieman hankalampi ymmärtää. Käytämmekin materiaalissa aiemmin esitettyä kahden erillisen useNoteStore-kutsun versiota.
Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part6-3.
Data palvelimelle
Laajennetaan sovellusta siten, että muistiinpanot talletetaan backendiin. Käytetään osasta 2 tuttua JSON Serveriä.
Tallennetaan projektin juureen tiedostoon db.json tietokannan alkutila:
{
"notes": [
{
"id": 1,
"content": "Zustand is less complex than Redux",
"important": true
},
{
"id": 2,
"content": "React app benefits from custom hooks",
"important": false
},
{
"id": 3,
"content": "Remember to sleep well",
"important": true
}
]
}Asennetaan projektiin JSON Server
npm install json-server --save-devja lisätään tiedoston package.json osaan scripts rivi
"scripts": {
"server": "json-server -p 3001 db.json",
// ...
}Käynnistetään JSON Server komennolla npm run server.
Fetch API
Ohjelmistokehityksessä joudutaan usein pohtimaan, kannattaako jokin toiminnallisuus toteuttaa käyttämällä ulkoista kirjastoa vai onko parempi hyödyntää ympäristön tarjoamia natiiveja ratkaisuja. Molemmilla lähestymistavoilla on omat etunsa ja haasteensa.
Olemme käyttäneet HTTP-pyyntöjen tekemiseen kurssin aiemmissa osissa Axios-kirjastoa. Tutustutaan nyt vaihtoehtoiseen tapaan tehdä HTTP-pyyntöjä natiivia Fetch APIa hyödyntäen.
On tyypillistä, että ulkoinen kirjasto kuten Axios on toteutettu hyödyntäen muita ulkoisia kirjastoja. Esimerkiksi jos Axioksen asentaa projektiin komennolla npm install axios, konsoliin tulostuu:
$ npm install axios
added 23 packages, and audited 302 packages in 1s
71 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilitiesKomento asentaisi projektiin siis Axios-kirjaston lisäksi yli 20 muuta npm-pakettia, jotka Axios tarvitsisi toimiakseen.
Fetch API tarjoaa samankaltaisen tavan tehdä HTTP-pyyntöjä kuin Axios, mutta Fetch APIn käyttäminen ei vaadi ulkoisten kirjastojen asentamista. Sovelluksen ylläpito helpottuu, kun päivitettäviä kirjastoja on vähemmän, ja myös tietoturva paranee, koska sovelluksen mahdollinen hyökkäyspinta-ala pienenee. Sovellusten tietoturvaa ja ylläpitoa sivutaan kurssin osassa 7.
Pyyntöjen tekeminen tapahtuu käytännössä käyttämällä fetch()-funktiota. Käytettävässä syntaksissa on jonkin verran eroja verrattuna Axiokseen. Huomaamme myös pian, että Axios on huolehtinut joistakin asioista puolestamme ja helpottanut elämäämme. Käytämme nyt kuitenkin Fetch APIa, koska se on laajasti käytetty natiiviratkaisu, joka jokaisen Full Stack -kehittäjän on syytä tuntea.
Datan hakeminen palvelimelta
Tehdään backendistä dataa hakeva funktio tiedostoon src/services/notes.js:
const baseUrl = 'http://localhost:3001/notes'
const getAll = async () => {
const response = await fetch(baseUrl)
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
const data = await response.json()
return data
}
export default { getAll }Tutkitaan getAll-funktion toteutusta tarkemmin. Muistiinpanot haetaan backendistä nyt kutsumalla fetch()-funktiota, jolle on annettu argumentiksi backendin URL-osoite. Pyynnön tyyppiä ei ole erikseen määritelty, joten fetch toteuttaa oletusarvoisen toiminnon eli GET-pyynnön.
Kun vastaus on saapunut, tarkistetaan pyynnön onnistuminen vastauksen kentästä response.ok ja heitetään tarvittaessa virhe:
if (!response.ok) {
throw new Error('Failed to fetch notes')
}Attribuutti response.ok saa arvon true, jos pyyntö on onnistunut eli jos vastauksen statuskoodi on välillä 200-299. Kaikilla muilla statuskoodeilla, esimerkiksi 404 tai 500, se saa arvon false.
Huomaa, että fetch ei automaattisesti heitä virhettä, vaikka vastauksen statuskoodi olisi esimerkiksi 404. Virheenkäsittely tulee toteuttaa manuaalisesti, kuten olemme nyt tehneet.
Jos pyyntö on onnistunut, vastauksen sisältämä data muunnetaan JSON-muotoon:
const data = await response.json()fetch ei siis automaattisesti muunna vastauksen mukana mahdollisesti olevaa dataa JSON-muotoon, vaan muunnos tulee tehdä manuaalisesti. On myös hyvä huomata, että response.json() on asynkroninen funktio, eli sen kanssa tulee käyttää await-avainsanaa.
Suoraviivaistetaan koodia vielä hieman palauttamalla suoraan funktion response.json() palauttama data:
const getAll = async () => {
const response = await fetch(baseUrl)
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
return await response.json()}Lisätään storeen funktio, jonka avulla tila voidaan alustaa palvelimelta haettavilla muistiinpanoilla:
const useNoteStore = create((set) => ({
notes: [], filter: '',
actions: {
// ...
setFilter: value => set(() => ({ filter: value })),
initialize: notes => set(() => ({ notes })) }
}))Toteutetaan muistiinpanojen alustus App-komponentiin, eli kuten yleensä dataa palvelimelta haettaessa, käytetään useEffect-hookia:
const App = () => {
const { initialize } = useNoteActions()
useEffect(() => { noteService.getAll().then(notes => initialize(notes)) }, [initialize])
return (
<div>
<NoteForm />
<VisibilityFilter />
<NoteList />
</div>
)
}Muistiinpanot haetaan palvelimelta siis käyttäen määrittelemäämme getAll()-function ja tallennetaan sitten storen funktiolla initialize . Toiminnot tehdään useEffect-hookissa eli ne suoritetaan App-komponentin ensimmäisen renderöinnin yhteydessä.
Tutkitaan vielä tarkemmin erästä pientä yksityiskohtaa. Olemme lisänneet initialize-funktion useEffect-hookin riippuvuustaulukkoon. Jos yritämme käyttää tyhjää riippuvuustaulukkoa, ESLint antaa seuraavan varoituksen: React Hook useEffect has a missing dependency: 'initialize'. Mistä on kyse?
Koodi toimisi loogisesti täysin samoin, vaikka käyttäisimme tyhjää riippuvuustaulukkoa, koska initialize viittaa samaan funktioon koko ohjelman suorituksen ajan. On kuitenkin hyvän ohjelmointikäytännön mukaista lisätä useEffect-hookin riippuvuuksiksi kaikki sen käyttämät muuttujat ja funktiot, jotka on määritelty kyseisen komponentin sisällä. Näin voidaan välttää yllättäviä bugeja.
Datan lähettäminen palvelimelle
Toteutetaan seuraavaksi toiminnallisuus uuden muistiinpanon lähettämiseksi palvelimelle. Pääsemme samalla harjoittelemaan, miten POST-pyyntö tehdään fetch()-funktiota käyttäen.
Laajennetaan tiedostossa src/services/notes.js olevaa palvelimen kanssa kommunikoivaa koodia seuraavasti:
const baseUrl = 'http://localhost:3001/notes'
const getAll = async () => {
const response = await fetch(baseUrl)
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
return await response.json()
}
const createNew = async (content) => { const response = await fetch(baseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, important: false }), }) if (!response.ok) { throw new Error('Failed to create note') } return await response.json()}
export default { getAll, createNew }Tutkitaan createNew-funktion toteutusta tarkemmin. fetch()-funktion ensimmäinen parametri määrittelee URL-osoitteen, johon pyyntö tehdään. Toinen parametri on olio, joka määrittelee muut pyynnön yksityiskohdat, kuten pyynnön tyypin, otsikot ja pyynnön mukana lähetettävän datan. Voimme selkeyttää koodia vielä hieman tallentamalla pyynnön yksityiskohdat määrittelevän olion erilliseen options-apumuuttujaan:
const createNew = async (content) => {
const options = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, important: false }), } const response = await fetch(baseUrl, options)
if (!response.ok) {
throw new Error('Failed to create note')
}
return await response.json()
}Tutkitaan options-oliota tarkemmin:
- method määrittelee pyynnön tyypin, joka tässä tapauksessa on POST
- headers määrittelee pyynnön otsikot. Liitämme pyyntöön otsikon 'Content-Type': 'application/json', jotta palvelin tietää, että pyynnön mukana oleva data on JSON-muotoista, ja osaa käsitellä pyynnön oikein
- body sisältää pyynnön mukana lähetettävän datan. Kenttään ei voi suoraan sijoittaa JavaScript-oliota, vaan se tulee ensin muuntaa JSON-merkkijonoksi kutsumalla funktiota JSON.stringify()
Kuten GET-pyynnön kanssa, myös nyt vastauksen statuskoodi tutkitaan virheiden varalta:
if (!response.ok) {
throw new Error('Failed to create note')
}Jos pyyntö onnistuu, JSON Server palauttaa juuri luodun muistiinpanon, jolle se on generoinut myös yksilöllisen id:n. Vastauksen sisältämä data tulee kuitenkin vielä muuntaa JSON-muotoon funktiolla response.json():
return await response.json()Muutetaan sitten sovelluksemme NoteForm-komponenttia siten, että uusi muistiinpano lähetetään backendiin. Komponentin funktio addNote muuttuu hiukan:
import { useNoteActions } from './store'
import noteService from './services/notes'
const NoteForm = () => {
const { add } = useNoteActions()
const addNote = async (e) => {
e.preventDefault()
const content = e.target.note.value
const newNote = await noteService.createNew(content) add(newNote)
e.target.reset()
}
return (
<form onSubmit={addNote}>
<input name="note" />
<button type="submit">add</button>
</form>
)
}
export default NoteFormKun uusi muistiinpano luodaan backendiin kutsumalla funktiota createNew(), saadaan paluuarvona muistiinpanoa kuvaava olio, jolle backend on generoinut id:n.
Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part6-4.
Asynkroniset actionit
Lähestymistapamme on melko hyvä, mutta siinä mielessä ikävä, että palvelimen kanssa kommunikointi tapahtuu komponentit määrittelevien funktioiden koodissa. Olisi parempi, jos kommunikointi voitaisiin abstrahoida komponenteilta siten, että niiden ei tarvitsisi kuin kutsua sopivaa storen tarjoamaa funktiota.
Haluammekin, että App alustaa sovelluksen tilan seuraavasti:
const App = () => {
const { initialize } = useNoteActions()
useEffect(() => {
initialize() }, [initialize])
return (
<div>
<NoteForm />
<VisibilityFilter />
<NoteList />
</div>
)
}NoteForm puolestaan luo uuden muistiinpanon näin:
const NoteForm = () => {
const { add } = useNoteActions()
const addNote = async (e) => {
e.preventDefault()
const content = e.target.note.value
await add(content) e.target.reset()
}
return (
<form onSubmit={addNote}>
<input name="note" />
<button type="submit">add</button>
</form>
)
}Tiedostoon store.js tehtävä muutos on seuraava:
import { create } from 'zustand'
import noteService from './services/notes'
const useNoteStore = create((set) => ({
notes: [],
filter: '',
actions: {
add: async (content) => { const newNote = await noteService.createNew(content) set(state => ({ notes: state.notes.concat(newNote) }))
},
initialize: async () => { const notes = await noteService.getAll() set(() => ({ notes }))
},
// ...
}
}))Funktiot add ja initialize on siis muutettu asynkronisiksi funktioiksi, jotka kutsuvat ensin sopivaa noteServicen funktiota, ja päivittävät tilan tämän jälkeen.
Ratkaisu on tyylikäs, tilan käsittely ja palvelimen kanssa kommunikointi on kokonaisuudessaan eriytetty React-komponenttien ulkopuolelle.
Viimeistellään vielä sovellus siten, että muistiinpanojen tärkeyden muutos synkronoidaan palvelimelle.
noteService.js laajenee seuraavasti:
const update = async (id, note) => {
const response = await fetch(`${baseUrl}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(note),
})
if (!response.ok) {
throw new Error('Failed to update note')
}
return await response.json()
}
export default { getAll, createNew, update } Storen funktion toggleImportance muutos on seuraava
const useNoteStore = create((set) => ({
notes: [],
filter: '',
actions: {
add: async (content) => {
const newNote = await noteService.createNew(content)
set(state => ({ notes: state.notes.concat(newNote) }))
},
toggleImportance: async (id) => { const note = useNoteStore.getState().notes.find(n => n.id === id) const updated = await noteService.update( id, { ...note, important: !note.important } ) set(state => ({ notes: state.notes.map(n => n.id === id ? updated : n) })) }, setFilter: value => set(() => ({ filter: value })),
initialize: async () => {
const notes = await noteService.getAll()
set(() => ({ notes }))
}
}
}))Uudessa funktiossa on eräs huomionarvoinen seikka. Funktio saa parametrina muistiinpanon id:n. Backendiin on kuitenkin lähetettävä muutettu muistiinpano. Se saadaan selville kutsumalla storen funktiota getState:
const note = useNoteStore.getState().notes.find(n => n.id === id)Zustand-storeilla on myös joukko muita apufunktioita, mille saattaa olla joissain tilanteissa käyttöä.
Muutetaan kuitenkin vielä storen määrittelyä siten, että välitetään createlle annettavalle funktiolle myös parametri get, jonka kautta pääsemme tarpeen tullen käsiksi tilan arvoihin:
const useNoteStore = create((set, get) => ({ notes: [],
filter: '',
actions: {
toggleImportance: async (id) => {
const note = get().notes.find(n => n.id === id) const updated = await noteService.update(
id, { ...note, important: !note.important }
)
set(state => ({
notes: state.notes.map(n => n.id === id ? updated : n)
}))
},
// ...
}
}))Funktio get siis palauttaa storen sen hetkisen tilan. Eli esimerkiksi kutsu get().notes antaa storen tämänhetkiset muistiinpanot. Funktio get vastaa toiminnaltaan kutsua useNoteStore.getState(), mutta on idiomattisin tapa viitata storen tilaan storen omien funktioiden sisältä.
Sovelluksen koodi on GitHubissa branchissa part6-5.
Middlewaret
Sovellusta kehittäessä törmää tilanteeseen, jossa on vaikea hahmottaa, miksi sovellus käyttäytyy odottamattomasti. Tila muuttuu jonkun action-funktion seurauksena, mutta on epäselvää, mikä kutsu muutti mitäkin ja missä järjestyksessä. Perinteinen konsolilokitus yksittäisistä funktioista auttaa vain rajallisesti.
Zustand tukee ns. middlewareja, joiden avulla voidaan lisätä toiminnallisuutta storeihin läpinäkyvästi, ilman että itse storen logiikkaan tarvitsee koskea. Middlewaren idea on yksinkertainen: se "kietoutuu" storen ympärille ja voi reagoida jokaiseen tilanmuutoksen automaattisesti halutulla tavalla.
Middlewarefunktioiden muoto on hieman kryptinen. Seuraavassa on logger, joka tulostaa aina tilan vaihtuessa storen vanhan ja uuden tilan:
const logger = (config) => (set, get) => config(
(...args) => {
console.log('prev state', get());
set(...args);
console.log('next state', get());
},
get
);Middleware otetaan käyttöön "kietomalla" Zustandin create:lle annettava funktio sen parametriksi:
const useNoteStore = create(logger((set, get) => ({ notes: [],
filter: '',
actions: {
// ...
}
})))Nyt storen tilan muuttuessa näemme konsolista aina miten tila muuttuu:

Käytännössä määrittelemämme middleware toimii siten, että se korvaa alkuperäisen funktion set funktiolla
(...args) => {
console.log('prev state', get());
set(...args);
console.log('next state', get());
}joka tulostaa vanhan ja uuden tilan (joihin se pääsee käsiksi funktiolla get) konsoliin sen lisäksi että se kutsuu vanhaa funktiota set. Toisena parametrina on vanha get muuttumattomana.
Zustandissa on myös valmis devtools-middleware, joka integroi storen selaimen Redux DevTools -laajennukseen. Devtools on kehitystyökaluna erittäin hyödyllinen, sillä sen avulla voi seurata tilan muutoksia graafisesti.
Käyttöönotto on helppo tehdä:
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
const useNoteStore = create(devtools((set, get) => ({ notes: [],
filter: '',
actions: {
// ...
}
})))Kun Redux DevTools -laajennus on asennettuna selaimeen, voidaan storen tilaa ja sen muutoksia tarkastella selaimen konsolista:

Zustand-storejen testaaminen
Tarkastellaan vielä lopuksi Zustand-storejen testaamista Vitestillä.
Aloitetaan yksinkertaisuuden vuoksi laskurin storesta:
import { create } from 'zustand'
const useCounterStore = create(set => ({
counter: 0,
actions: {
increment: () => set(state => ({ counter: state.counter + 1 })),
decrement: () => set(state => ({ counter: state.counter - 1 })),
zero: () => set(() => ({ counter: 0 })),
}
}))
export const useCounter = () => useCounterStore(state => state.counter)
export const useCounterControls = () => useCounterStore(state => state.actions)
export default useCounterStoreLisäsimme testejä varten määrittelyyn eksportin, jonka avulla testi pääsee käsiksi storeen.
Asennetaan Vitest:
npm install --save-dev vitestToteutetaan testi tiedostoon store.test.js:
import { beforeEach, describe, expect, it } from 'vitest'
import useCounterStore from './store'
beforeEach(() => {
useCounterStore.setState({ counter: 0 })
})
describe('counter store', () => {
it('initial state is 0', () => {
expect(useCounterStore.getState().counter).toBe(0)
})
it('increment increases counter by 1', () => {
useCounterStore.getState().actions.increment()
expect(useCounterStore.getState().counter).toBe(1)
})
it('decrement decreases counter by 1', () => {
useCounterStore.getState().actions.decrement()
expect(useCounterStore.getState().counter).toBe(-1)
})
it('zero resets counter to 0', () => {
useCounterStore.getState().actions.increment()
useCounterStore.getState().actions.increment()
useCounterStore.getState().actions.zero()
expect(useCounterStore.getState().counter).toBe(0)
})
})Testit ovat varsin suoraviivaiset ja hyödyntävät storen funktiota getState, joiden avulla ne pääsevät lukemaan storen tilaa sekä suorittamaan storen funktiota.
Ennen jokaista testiä store palautetaan alkutilaan beforeEach-lohkossa storen funktion setState avulla.
Storen palauttaminen alkutilaan on tapauksessamme yksinkertaista. Aina ei välttämättä näin ole. Zustandin dokumentaatio neuvoo tavan, miten storeista voidaan luoda testejä varten versio, joka asetetaan automaattisesti alkutilaan ennen jokaista testiä. Tapa on kuitenkin sen verran monimutkainen ja meille tarpeeton, että emme siihen nyt mene.
Testit siis käyttävät storea suoraan. Jos storejen käyttöön on toteutettu custom hookeina monimutkaisempaa logiikkaa, saattaa olla tarpeen tehdä testit siten, että ne myös hyödyntävät hookkeja.
Laskurissa storen käyttö tapahtuu hookien useCounter ja useCounterControls kautta:
const useCounterStore = create(set => ({
// ...
}))
// hightlight-start
export const useCounter = () => useCounterStore(state => state.counter)
export const useCounterControls = () => useCounterStore(state => state.actions)
// hightlight-endTässä tapauksessa hookit eivät sisällä mitään logiikkaa, ne vaan paljastavat erikseen storen tallettaman arvon ja storen funktiot. Yllä käyttämämme testaustapa on siis oikein hyvä.
Tehdään kuitenkin esimerkin vuoksi vielä toinen version testeistä, missä storea käytetään täysin samalla tavalla kuin sovellus sitä käyttää.
useCounter ja useCounterControls ovat React hookeja, joten niiden testaamiseen tarvitsemme React Testing Libraryn sekä jsdom-kirjaston:
npm install --save-dev @testing-library/react jsdomLisätään tiedostoon vite.config.js testausympäristöstä kertova konfiguraatio:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: { environment: 'jsdom', },})Testit ovat seuraavassa:
import { beforeEach, describe, expect, it } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import useCounterStore, { useCounter, useCounterControls } from './store'
beforeEach(() => {
useCounterStore.setState({ counter: 0 })
})
describe('counter hooks', () => {
it('useCounter returns initial value of 0', () => {
const { result } = renderHook(() => useCounter())
expect(result.current).toBe(0)
})
it('increment updates counter', () => {
const { result: counter } = renderHook(() => useCounter())
const { result: controls } = renderHook(() => useCounterControls())
act(() => controls.current.increment())
expect(counter.current).toBe(1)
})
it('decrement updates counter', () => {
const { result: counter } = renderHook(() => useCounter())
const { result: controls } = renderHook(() => useCounterControls())
act(() => controls.current.decrement())
expect(counter.current).toBe(-1)
})
it('zero resets counter', () => {
const { result: counter } = renderHook(() => useCounter())
const { result: controls } = renderHook(() => useCounterControls())
act(() => {
controls.current.increment()
controls.current.increment()
controls.current.zero()
})
expect(counter.current).toBe(0)
})
})Testissä on muutama mielenkiintoinen asia. Testien aluksi hookit renderöidään funktion renderHook avulla:
const { result: counter } = renderHook(() => useCounter())
const { result: controls } = renderHook(() => useCounterControls())Näin testi pääsee käsiksi hookien palauttamiin funktioihin, jotka sijoitetaan muuttujiin counter ja controls.
Hookeja kutsutaan wrappaamalla kutsu funktion act sisälle:
act(() => {
controls.current.increment()
controls.current.increment()
controls.current.zero()
})Lopuksi tapahtuu testin ekspektaatio:
expect(counter.current).toBe(0)Kuten huomaamme, päästäksemme hookiin itseensä käsiksi joudumme vielä ottamaan funktion renderHook palauttamasta oliosta kentän current, joka vastaa hookin nykyistä arvoa.
Mikä on act?
act on apufunktio, joka varmistaa että kaikki tilan päivitykset ja niiden aiheuttamat sivuvaikutukset on käsitelty loppuun ennen kuin testikoodi jatkuu.
Kun React-komponentissa tai hookissa tapahtuu tilan muutos React ei päivitä tilaa välittömästi vaan jonottaa päivitykset. act pakottaa nämä jonossa olevat päivitykset suoritettaviksi
Ilman actia testi saattaisi tarkistaa tilan ennen kuin React on ehtinyt päivittää sen, jolloin testi epäonnistuisi tai antaisi väärän tuloksen.
React Testing Library käärii monet toimintonsa (kuten fireEvent, userEvent) automaattisesti act:iin, mutta hookeja suoraan testattaessa sitä on viisainta käyttää.
Hookien kautta tapahtuva testaaminen käyttää React Testing Libraryä, ja renderöi hookit oikeassa React-kontekstissa jsdomin avulla. Tämä lähestymistapa on huomattavasti hitaampi kuin suoraan storea käyttävät testit, eli jos hookit eivät sisällä kompleksista logiikkaa, voi olla riittävää suorittaa testit suoraan storea käyttäen.
Zustand-laskurin testit sisältävä koodi löytyy GitHubista.
Muistiinpanostoren testaaminen
Muistiinpanosovelluksen storen testaaminen on hieman haastavampi tapaus, sillä store sisältää asynkronisia funktioita, jotka kutsuvat palvelinta:
import { create } from 'zustand'
import noteService from './services/notes'
const useNoteStore = create(set => ({
notes: [],
filter: '',
actions: {
add: async (content) => {
const newNote = await noteService.createNew(content) set(state => ({ notes: state.notes.concat(newNote) }))
},
toggleImportance: async (id) => {
const note = useNoteStore.getState().notes.find(n => n.id === id)
const updated = await noteService.update( id, { ...note, important: !note.important } ) set(state => ({
notes: state.notes.map(n => n.id === id ? updated : n)
}))
},
setFilter: value => set(() => ({ filter: value })),
initialize: async () => {
const notes = await noteService.getAll() set(() => ({ notes }))
}
}
}))
export const useNotes = () => {
const notes = useNoteStore((state) => state.notes)
const filter = useNoteStore((state) => state.filter)
if (filter === 'important') return notes.filter(n => n.important)
if (filter === 'nonimportant') return notes.filter(n => !n.important)
return notes
}
export const useFilter = () => useNoteStore((state) => state.filter)
export const useNoteActions = () => useNoteStore((state) => state.actions)Tällä kertaa myös useNotes sisältää merkittävissä määrin logiikkaa, joten testaus lienee syytä tehdä hookien kautta React Testing Libraryllä.
Asennetaan tarvittavat kirjastot:
npm install --save-dev vitest @testing-library/react jsdomLisätään tiedostoon vite.config.js testausympäristöstä kertova konfiguraatio:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: { environment: 'jsdom', },})Testien ensimmäinen osa seuraavassa:
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
vi.mock('./services/notes', () => ({
default: {
getAll: vi.fn(),
createNew: vi.fn(),
update: vi.fn(),
}
}))
import noteService from './services/notes'
import useNoteStore, { useNotes, useFilter, useNoteActions } from './store'
beforeEach(() => {
useNoteStore.setState({ notes: [], filter: '' })
vi.clearAllMocks()
})
describe('useNoteActions', () => {
it('initialize loads notes from service', async () => {
const mockNotes = [{ id: 1, content: 'Test', important: false }]
noteService.getAll.mockResolvedValue(mockNotes)
const { result } = renderHook(() => useNoteActions())
await act(async () => {
await result.current.initialize()
})
const { result: notesResult } = renderHook(() => useNotes())
expect(notesResult.current).toEqual(mockNotes)
})
it('add appends a new note', async () => {
const newNote = { id: 2, content: 'New note', important: false }
noteService.createNew.mockResolvedValue(newNote)
const { result } = renderHook(() => useNoteActions())
await act(async () => {
await result.current.add('New note')
})
const { result: notesResult } = renderHook(() => useNotes())
expect(notesResult.current).toContainEqual(newNote)
})
it('toggleImportance flips important flag', async () => {
const note = { id: 1, content: 'Test', important: false }
useNoteStore.setState({ notes: [note] })
noteService.update.mockResolvedValue({ ...note, important: true })
const { result } = renderHook(() => useNoteActions())
await act(async () => {
await result.current.toggleImportance(1)
})
const { result: notesResult } = renderHook(() => useNotes())
expect(notesResult.current[0].important).toBe(true)
})
})Testeissä on paljon pureskeltavaa. Testit muodostavat Vitestin avulla mock-version palvelimen kanssa kommunikoinnista huolehtivasta noteServicestä:
import { describe, it, expect, beforeEach, vi } from 'vitest'
vi.mock('./services/notes', () => ({
default: {
getAll: vi.fn(),
createNew: vi.fn(),
update: vi.fn(),
}
}))vi.mock korvaa hakemiston ./services/notes sisältävän noteServicen omalla versiollaan, missä kaikki funktiot on korvattu vi.fn palauttamalla mock-funktiolla.
Ennen jokaista testiä store palautetaan alkutilaan ja mock-funktiot nollataan:
beforeEach(() => {
useNoteStore.setState({ notes: [], filter: '' })
vi.clearAllMocks()
})Jokaisen testin alussa mockatulle noteServicelle kerrotaan funktion mockResolvedValue avulla kuinka sen tulee toimia testin kontekstissa:
it('initialize loads notes from service', async () => {
const mockNotes = [{ id: 1, content: 'Test', important: false }] noteService.getAll.mockResolvedValue(mockNotes)
const { result } = renderHook(() => useNoteActions())
await act(async () => {
await result.current.initialize()
})
const { result: notesResult } = renderHook(() => useNotes())
expect(notesResult.current).toEqual(mockNotes)
})Aluksi testi siis määrittelee, että kutsuttaessa funktiota noteService.getAll palautetaan storelle taulukossa mockNotes olevat muistiinpanot.
Testattava asia on funktion initialize kutsu:
await act(async () => {
await result.current.initialize()
})Koska kyse on asynkronisesta funktiosta, tulee kutsun valmistumista odottaa avainsanaa await käyttämällä.
Lopuksi testi varmistaa, että storen tilassa on sama lista muistiinpanoja, mitä mockattu noteService.getAll palautti:
const { result: notesResult } = renderHook(() => useNotes())
expect(notesResult.current).toEqual(mockNotes)Muut testit noudattavat samaa kaavaa: ensin määritellään mitä storen kutsuma noteServicen funktio palauttaa, ja tämän jälkeen suoritetaan itse testi.
Testien jälkimmäinen osa varmistaa filtteröinnin toimivuuden:
describe('useNotes filtering', () => {
const notes = [
{ id: 1, content: 'A', important: true },
{ id: 2, content: 'B', important: false },
]
beforeEach(() => {
useNoteStore.setState({ notes })
})
it('returns all notes with no filter', () => {
const { result } = renderHook(() => useNotes())
expect(result.current).toHaveLength(2)
})
it('filters important notes', () => {
useNoteStore.setState({ notes, filter: 'important' })
const { result } = renderHook(() => useNotes())
expect(result.current).toEqual([notes[0]])
})
it('filters nonimportant notes', () => {
useNoteStore.setState({ notes, filter: 'nonimportant' })
const { result } = renderHook(() => useNotes())
expect(result.current).toEqual([notes[1]])
})
})Tila alustetaan kahdella muistiinpanolla, joista toinen on tärkeä ja toinen ei. Kolme testitapausta tarkastavat, että useNotes palauttaa kaikilla filtterin arvoilla oikeat muistiinpanot.
Sovelluksen lopullinen koodi on GitHubissa branchissa part6-6.

