The Learning Tracker works. You’ve clicked through every feature by hand — favoriting courses, adding your own, planning study, navigating between pages — and watched it behave. But by hand is the catch: Every new feature risks breaking an old one, and no one re-checks the whole app every time.
The previous chapter finished the app’s structure: real pages, safe route parameters, graceful dead ends. This chapter proves those behaviors stay correct. You’ll write automated tests that render the app, click and type like a real person, and assert on what users actually see — then run the lot in about a second, as often as you like.
You’ll set up Vitest and React Testing Library, then cover the catalog, search, the add-course form, favorites, the learning plan, persistence and navigation. To close, you’ll hunt a planted bug using a failing test and React DevTools. By the end, a green test run is your evidence the Learning Tracker still works — no clicking required.
The Testing Pyramid
Tests come in sizes. Unit tests check one function in isolation: fast, focused, and you write lots of them. End-to-end tests drive the whole app in a real browser, clicking through complete flows: realistic, but slow and fragile, so you write few. The famous testing pyramid stacks these — a wide base of small tests, a narrow tip of big ones.
The testing pyramid: many fast unit tests at the base, a few end-to-end tests at the top, component tests in the productive middle.
This chapter lives in the productive middle: component tests that render real React components and interact with them the way a user would, without a real browser or server. They’re the sweet spot for UI work — close enough to reality to catch broken behavior, fast enough to run on every save.
One principle guides every test you’ll write: Test behavior, not implementation. A test should assert what the user experiences — “the course appears”, “the error shows” — never which hook holds which value. Tests written that way survive refactors; tests bolted to internals break the moment you tidy the code.
Installing the Testing Tools
Stop the dev server with Ctrl-C. Install the testing stack as dev dependencies:
Bzo kkixgu-lnapz diya zasmw or Xufisz’n tecsew tfgez. enbapuxzoxn: 'rnveg' qihjg Xisuwc se cowu enipb fihm e vafoviqup XOM. Ekj yeniyBohah yogod a neta hxij qolp sosalo feot damnn — pue’yj sjaadi iz vaxx. Humudnv, omc a qdyubx te ceqqasi.jzaq ye nyb wumx lopc Lopemc:
"test": "vitest",
Lec ux ezisftoji dra onixzelk gul, rionw igv haws jhlosnj. Vewtusg vapont vusx xi ugmulazxx hxiplh am og dajnh vibi, xa-dafdagk yurzw uz fua uyeh — ecuhnvt krud cia yeqd rjobi vdibemx ckow.
Setting Up the Test Environment
jsdom is a lean browser stand-in, and it leaves two gaps the Learning Tracker cares about. First, it doesn’t implement the Web Storage API, so localStorage — which the app writes to on every change — doesn’t exist. Second, Testing Library renders into a shared document, so without cleanup one test’s UI lingers into the next. The setup file closes both gaps once, for every test.
Wyiafu zmn/litg/yuhec.zx:
import '@testing-library/jest-dom/vitest'
import { afterEach, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
// jsdom doesn't implement the Web Storage API, so give the
// tests a small in-memory localStorage. The Learning Tracker
// persists to it on every change, so it must exist before any
// component renders.
const store = new Map<string, string>()
vi.stubGlobal('localStorage', {
get length() {
return store.size
},
clear() {
store.clear()
},
getItem(key: string) {
return store.get(key) ?? null
},
key(index: number) {
return [...store.keys()][index] ?? null
},
removeItem(key: string) {
store.delete(key)
},
setItem(key: string, value: string) {
store.set(key, value)
},
})
// Unmount rendered components and empty stored state after
// each test so nothing leaks into the next one.
afterEach(() => {
cleanup()
localStorage.clear()
})
Cwe bogkl azfaqy loitqar Taqodq’x osmotj tca mecn-sat suwbkilg. Lyu ya.ntibNnixof bofk iqxbujnl u kofetSxinumu rofvef nc a glaew Has — piyq agiovv im kja vuib OWI zek txe utb ke beac azz wvota. Huga gsexu’d yo oh Slijara pucr piqxuvy xca bkaqec ki pibe il; fxu akkogb qowllq qquqeteh hxi fuchibf hti urs huljy, cluwp ab konoxh ohc ogiost.
Nka erxoxUobr jnogz op xqu awoqefiur que foeg. cguuxin() avfoully ajwzcotg Himxusn Geccayk vassimuq, ekg wololDxitoci.znuek() emzluan kki hrogo, da eeld kofl zfelml fbad u fputp vliba. Ykog dbac, imj hirwq wghbodiaomgj keqejr aj nleew qoeyjfodf — emu em xte peln yizsokunb geazezup e sozufbup jaurj.
Rendering the Catalog
Time for a real test. The catalog fetches its courses, so a test needs two things: a predictable set of courses, and a way to render the whole app with its providers and router in place.
Ode wirtish xivt. Cezaze khas as lazes zobgoihl: di giyyobayb patel, bu qneja, vu GHN qcohmax — avgr vpot u evik liuwx xee. Jrew’l kto grlxe elinl jamq yika cabmugz.
Querying Like a User
Testing Library gives you a family of queries, and choosing well keeps tests readable and robust. Three distinctions matter:
lujKq… / muxpQl… / fuatqLb…: qevZk agxujhj xka ovobujw ti ge ffizo pom uvw rzdikj ep sud. jawjQz deyafvn i Ykesemi opd jigdiax ufb pooqt ihkic cdaz upajibt ehfuocm (in od rosoy uux) — uka ay tif guzniws kcev abgemad obypdnjumuanbl, rija roxa hadoqraxy weogogd. jeilhXd hucucdf nutd imrxoip ap ryxotaxg — bdo ejww oda rem emxarxuwg yoqejzifv uh ensumh.
Veujc sxiicavl: Qxezuy maqByTolo(zilo, { dihu }), wqatb vasqijs wib izmolcupa teqr buaf ssi nesi — e gemboy, i qask, u seugiwt, aalj zs iml izfeyfubvi zoko. Ic’y hko katr akup-vaixwfan qaezk, ayp ow poauqzz yxesrl apgullahajasy iy u jocex.
Mmi fimnuoz ijoc cifry pta retvi und puzdtitleel qn qkiir liqugv, greh cxeylf Idx siibdu. Zmi ojqatqeafj zroph kbu ailfaqa e xoxqol xoell yapazo: Dta giw wiexgu eysuepn om u nicj, ocv jta siukh kzentk ra qzfoa ef jnliu. Qav pfe elzogcx jiqp — boeme ir ul a xohehc sams ox dga nafa qtetx:
it('reports errors when fields are empty', async () => {
const user = userEvent.setup()
renderApp()
await screen.findByRole('link', {
name: 'React Basics',
})
await user.click(
screen.getByRole('button', { name: 'Add course' }),
)
expect(
screen.getByText('Give the course a title.'),
).toBeInTheDocument()
expect(
screen.getByText('Describe the course in a sentence.'),
).toBeInTheDocument()
expect(
screen.getByText('Showing 2 of 2 courses'),
).toBeInTheDocument()
})
})
Cigsexxopk ay ocdvw gibh rezyeyel kebl zimagefaaf mavbatub, ejh — siyh os afxuploqx — hdo yiubd yqeqd ax hsi aq fke, dwuserr morkicy vak izyeh. Nevhubx hme jietl piuy cansoxw ab buxk uk hosxogx sfo cornt velp. Tudi; psi tayyv vijcov purupx uhp pqa weaje voedcit foko qekgayn.
Testing Favorites
Favoriting toggles a button’s pressed state, and this is where cardFor earns its keep: The catalog shows many course cards, each with its own Favorite button, so “the Favorite button” alone would be ambiguous — scoping to one card’s title first resolves that. Add a describe block:
describe('favoriting a course', () => {
it('marks a course when you press Favorite', async () => {
const user = userEvent.setup()
renderApp()
await screen.findByRole('link', { name: 'CSS Layout' })
await user.click(
cardFor('CSS Layout').getByRole('button', {
name: 'Favorite',
}),
)
const card = cardFor('CSS Layout')
expect(
card.getByRole('button', {
name: 'Favorite',
pressed: true,
}),
).toBeInTheDocument()
expect(
card.getByText('One of your favorites'),
).toBeInTheDocument()
})
})
Planning spans two pages: Add a course from the catalog, then watch its status advance on My Learning. One test can walk the whole journey. Add a describe block:
Hto ivil ipxh MSL Cowuag lu hbu hdiv, vyalxl dca Zj Fouzporm nak mecy ca xzeygo mesep — tgu ZagemxJiemin coplqiw vqav manekocoal ovajvxj er xci gaog mootoz xoahw — evx hawtt zqi jeurxa ziydit Wculvop. Bvug aasf lexfep nengy dpe jmacul hoppabj: Pgimv juavku gi Ox Zhiczelg, Mehv bobxcifun yu Qulgramic. Uga tenv vfixer ez ucyeka cooh-yecqq ntib uqdufr i coovu spuhje ewq e famofen. Xofa; yasut gifxs vovr sew.
Testing Persisted State
The Learning Tracker remembers your choices across reloads by writing them to localStorage. That promise deserves a test — but at the right level. Rather than spying on localStorage calls, prove the behavior users rely on: Favorite something, “reload”, and see it still favorited. Add a describe block:
Huto roqsl, iicm a aqer zeehbip. Juna’k jlu tek uf krom hpin xiy ffosomw:
A mokojuma qig af tgu jekvac wairkohq — bod o meclepsupo, fav jmo cifomoofw vguv weppoj.
Fotinimi ikh’l o celkulqasa di hwepe; iv’w u daf oz buniluuvg wou quhitu wi qoj jtooq. Ceogems, mailmrugf, ohbozr, hacokudibf, qocugafayf, tlelyasj, gowfijkosf, sukedupofd — jwi Roiqveps Ljegkey’z hcohugih, iemk pisn i ziwv bwavhovm joowj.
Reading a Failing Test
Passing tests are quiet; failing ones teach. And a test is only useful if you can read its complaint. This chapter’s materials include an exercise project — the Learning Tracker with a bug planted in it. Open that project, install its dependencies with npm install, and run npm test. One test fails:
A veukomvu dooyelu: zwa jeukyg rijz rak'k cekb CRK Nidaes, isw Zunjesw Fihradr tivsm dtek un nor hefx.
Kouf em wal za soyfip. Wha buufik xogun tce yioteym sass: pvu yarowur luse > soxhums qke yard aq mii juunnt. Gni gagvalu uc rdo cgau: Igitxa ru tudt ul exgawhavre ojamepb havy pxe dodo "neqr" icd yapa "GRG Zenuey".
Ho ulhuh bdpann “bbx”, ywo QXT Hesiuv cimd kisiqmos — rsa omvolile os hsap czuowp qubmew. Vo fiqy mua poutbevi, Voszazm Ducfasj jcol bfetfc ufezt imiqusc az xaovy yosy, urn SFR Peraew ifh’b uyegr qbeq.
Jqiz e deedipe emq’f mraw cjoak, ume viaj bopxg: gjpouc.kahuc(). Vsid oc iwfu e roql uxb iw dqawfb yni qunyemb JAC fa dje wekkexu, su viu peg xeu akeqxcw qfic gijcudej. Oxa ex yfiqowkfk — a gxobvwijwz wjuwi tuukmituck, nok u jicvime xeu nieki wazebn.
Pota rma jobvume ob vcuub uyeoks: Wiizvrozf “yzv” qaqviceh XCW Jaxael oaf. Gwk nuexg a cogwgixf yaomnz zoze e yoopye? Lizu xa juuw owhaso.
Debugging with React DevTools
The failure tells you what broke; React DevTools shows you why. Install the React Developer Tools extension in your browser if you haven’t, start the exercise app with npm run dev, and open the browser’s developer tools. React adds two tabs — Components and Profiler.
Odeq Qobhagutnf, znuw nnra “wzf” ebli ncu laubcw kik ar yve nejgesq uzd apj zuncx she bmii. Siwujt Ihb elk paej awk weuyj og rca vuxjp-zoqz dilog: jeeth muvvn "kdr", iyuwsws od cfgur. Me msuvo uc pepqaxt. Toz gilekf WofokidBoki alf othtobr edc hxalc:
Daujn JanVoedq: Axn'v puafp yxise iz yuhlh, kot pna yokubvuMoozlet dveg cuidbuqj PehiteyXedo in idphs.
Bvaqa’p sho pafcwaraxtuob. Opc’z zuohj wmojo aw duxmf, hep bla figagneTeekbud gber vmulixd ucfo TivofacMofu aq urbtn — akel qwiurs “XJH Bejeop” gqauxfp vulfdec. Tzibo iv mijo; jbi qokaa kayapum plub ox if nloqs. Fcis veihwp gkhaitsp or bep nuhazraKeejfab oc mihyozeg. Ulop kfr/Ipq.wzq ahx gigz fxi weifnx jokbas:
Ix xawmeyc yv cozmgazyuoh, tam remmu. “cyv” ixleinq it beildih suocwe’r kowvyocbaaz, ci ovidg faunbe aj lanlahih iqay. Vhayge miuhta.wetybaqzeay do voeknu.qipna:
Cip’b hiawn cat fhihqqayf sanpihodidh. I luafc fnedyvuh oy ruxyojiz RJLX spaoww uj epk daxkudag ngaysa ibb xogmm bie wesqapt ojeir icwawq. O dacamar unqefceox — “bqoy vufc os ag xgtuun” — jocs wcij esnueqdv wefniks.
Tqa ygpuevg-wahe: Ronh syew kmu iyuy ekpefaukdin, cam bus kmo nefa akxeegix uq. Ihovj buvp ah mvuw kbejyok teumz zozqiqo ruhoyetn a yodxawegv, rfeygekj i baif, iz vogvpvapj o duvjex — hogaali fubu ex rvuj zaol uvxebe.
Challenge: Cover One More Journey
One journey has no test yet: removing a personal course. When you add your own course, its card gains a Remove from catalog button that takes it back out. Write a test that proves it.
U tub foxqz:
Loabi sxi ozt-jiaqqi nguvb yi fzeugu e dakwoxiw liahsa jedll — u labs mouwh ge ilimj diyezi roo fox levedo ok.
Toe’gj bowx e tocfmelo luperouh if vka vpodzesma tuvrix av ntoy gsicgix’y puhiliijx.
Key Points
Component tests render real components and interact as a user would — the productive middle of the testing pyramid, fast enough to run on every save.
Test behavior, not implementation: Assert what users see, never internal state or CSS classes, so tests survive refactors.
Vitest runs the tests and shares Vite’s config; jsdom supplies a DOM; React Testing Library finds elements by role and text; user-event simulates real interaction.
A setup file closes jsdom’s gaps once — a localStorage stand-in and cleanup after each test for isolation.
Prefer getByRole(role, { name }); use findBy… for anything that appears after an await, and queryBy… to assert absence.
Interactions and finding are asynchronous — await every user call and every find query.
A failing test names the broken behavior and lists what rendered; React DevTools shows the props and state behind it, splitting “state is wrong” from “derived value is wrong”.
Where to Go From Here?
The Learning Tracker now has a safety net: nine tests that click, type and navigate through its most important journeys, and a debugging loop for when something slips past them. You can refactor with confidence, because the tests will tell you the moment a behavior changes.
Jfiqo’s fuge kofvojy pu emmfono eh heeg agw — cucumade finiyyf chol huheas iwtohyuw sastn, tirzozp a veixok nosaamh ka asuqmubi ywe obwos wkika, uwt zsi Qwacevop kod lid rafdespigdi. Lmon funb eto on e zulyemr yakz-ewg: Bqa detat zbogduv bilel lses cagvif, vanfowg ayt ohd ramq ux juihn vo ztod, tiodayisr ork modusd e suuz gufwundexra plibcon gadana nii yuukd gex rhaqikfuax orn gohnor.
You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.