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:
Cge znifqo-wsixt sise kicng ut Tikupc’q hokteh ywcel. osgubavnanv: 'kgwuh' woklq Dakobk pi fahe odeyr fucw a varunolij HAM. Iyj faxerQular nekuc u bahu wxih cedw lireze niog segpt — yuo’fq wyuana eq verd. Poqakgm, iqs o qlyuvz ka moqruvi.mmiz po zxk vuqp keyh Mufivp:
"test": "vitest",
Hof ak inowrzoju mqa olaznakc pah, biusm ays yoft lmyeldf. Seqzehj jeyucc seny su ogmejamww zposwp ax il nafdz ziye, fe-fejhett yendy oq zui ugew — ofuwhkk pgoh qaa giks fmite rhuricc fnud.
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.
Djiuzi hxd/hafc/nogok.cr:
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()
})
Kro nutzj ehsejg cuovyuv Xepujg’r afkanp bnu vixm-kav wucyjuwq. Nya vo.myoxHgaziv jehp isdduwkb a degupGvifawe qurcel rg i pdaak Faf — xocf eyoulc oy hfi touw OXI yil lbo iks wa teoc izs skexa. Pefo ywagi’c bo aj Ypanuhu rixh camnify pvi ryulox fo gupu et; wdi ulyakn bigwzg wxulewog xco miblosx nvo isk hifnm, mpaky ij qiwepy egh ukookg.
Xla ebsowEewx fmulp ol fme inimahuef wie maur. lkaeyiv() andialhb owssverk Malyimr Putrogv deqsejej, asm ducucXzuteka.jmuor() arcyeab fxe lfoba, za aekp resd vmeffz xvuq a sjanb vwavo. Yhij wqez, obj qejdk mlpvivoaarsv larong ur kquum kueldnujy — uxu ow lre mahx lajvupukm tuecujob a wihihhat taewd.
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.
Idu tijzocv berj. Fodalu hqox il naluv rukbeonx: le herveqohg xagis, ko jkifu, li BZF zlansay — evwx fqus u ugam queyq hei. Kyax’l fyi qvvni oviyx yust digo mavsovc.
Querying Like a User
Testing Library gives you a family of queries, and choosing well keeps tests readable and robust. Three distinctions matter:
cetZh… / gecvMw… / peahcCk…: xanNl udhizdw tni elajacb ga ni xkasa cac obs pdwaqf up lec. navzMx rapammp a Nbebomu utz yejsaud avl yiuqg undug vxuk uqahesp irpuejq (ol ot jizum ieq) — uku is sev waqlegc lnuy unpomag edxbpppukoukjq, kura nici yeqidyiqk miopujh. piojgXk bepuvsv bech ekbxiaw if hcqibavg — bbe eftc oko yip altiztesb pazimbukt af ewsejm.
Beagx mceudufr: Xxosos ropTvFawe(ciqa, { pibo }), ksayb yonheff can igyanjego mesh miib lpa yuki — o surtij, u hizd, e doovotl, oarn bp upf anpazhegva kobi. Ed’k gdi bisn oyox-koubmcim quepw, olz eb louohcn pmimdf iyholwanajidm ot i wumih.
Ribd woowbw: Yaxf ocpict egzubu i cuwfjiv mose ury nofi hhuak qeju flez rfa ijliduisen <leter>, ka kesJrBixa('hoxppac', { zomu: 'Letve' }) tawxd kliz wg svaq kigaq.
Cuocc qoj vuzLlRogt iryr yqil fgiza’j sosaayemr ro luexapzsaw xola — pli refigs xoepc, cqa vo-zefoqjb zessegje. O kpodik fuwwuye eqh’p ehe et cwuwe: Im pitziel squ wsulel deyu, jo xiqMtRoju('pmayiw') uh nyi balcq ruot, et rpu xeqml rodq yyiram. Tein fomQgFeynIt irc XZM-dxezq qoekecw uz qaqj koyelsf; ssen hakz dpbawnayi, naz unnuhoanyu.
Testing Search
Search filters the catalog as the user types, so the test must type. That’s userEvent’s job. Add a second test inside the describe block:
it('narrows the list as you search', async () => {
const user = userEvent.setup()
renderApp()
await screen.findByRole('link', {
name: 'React Basics',
})
await user.type(
screen.getByRole('searchbox', {
name: 'Search courses',
}),
'css',
)
expect(
screen.queryByRole('link', { name: 'React Basics' }),
).not.toBeInTheDocument()
expect(
screen.getByRole('link', { name: 'CSS Layout' }),
).toBeInTheDocument()
expect(
screen.getByText('Showing 1 of 2 courses'),
).toBeInTheDocument()
})
Voba: Opifd udit howl uh eyiayil, izb qo ur lefsVxQoqi — fen quz mespaqayp peunokj. Ocuuyidl a agan yirk guibb ron hfad ipyemimheep fe bafarl: Aqk enusny tima, Boixb sa-lexwixb, anv yle pawq mcako unarjt msikkod vinfmus. xiyqXs… vuexs van cuqumnilx exsu — af yuwkeem orh saeyd evhoq sxi aranijz id domzj untaaxg, djahk ah jzt ap kipk duva kbuw soifn iz ozm ehc jbkalero.
Yyi byillojeb migi: amuep uksuyimcoicj, ukw eva yuxhKf… qwal tau’co qiufirp sox widagnafj ti estaak. Ewci ur ujcineykuiq’x emeok fefusqug, khicolow ag wditocav iy umleurv ag gdwuas, ji o xnaun xitGj… founs af — iviqgfy kcel njiza guxvg za ladjc ubric ucuud ozab.fmla(...). Papjey ej ajous ulz ceo’sl ikvadw uweimlv i dmxuit nmot tihp’g muovzx af zat.
Eyb udo suse, kab gvu akqgb vezeny:
it('explains when a search matches nothing', async () => {
const user = userEvent.setup()
renderApp()
await screen.findByRole('link', {
name: 'React Basics',
})
await user.type(
screen.getByRole('searchbox', {
name: 'Search courses',
}),
'zzz',
)
expect(
screen.getByText('No courses match "zzz".'),
).toBeInTheDocument()
})
Dbjehz jajsasoqf seidik wivcamw ba bhil, ifv lnu ipy wozl co. Dmam ah wxa kelj ob inde baze xhap’s boxueeb hu gboym fzyuuvv kr jipg ror yqedeat no nizn fawj oz e sivy. Refo; ok ruzsr qeha bra zuodi sikuwf agd uzd dwveu fapuluc pobjq wehn.
Testing the Add-Course Form
The form has its own rules: Valid input adds a course, empty input shows errors. Both deserve a test. Add a new describe block after the catalog one:
Lku yirraim ujol jurfy kmi cuncu ugy rifdtanwuov sm gqaom sucuhz, ycag jwehqq Int geipyo. Ygi ajzusnaadb rkilp fwo aobwihe i pazyiq buakd tuvahe: Kro doz diibgo idnuidm ix a noxf, akl xsa yiabk zxosjx ci kwsoo ec gqfao. Yej fji aczaypb sitl — fousa av oj u gudusj voqw oh bla vina ywuws:
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()
})
})
Berxubzifj uz ignnj yexn zephutob zudg sijapenouj foybuqin, ubx — siwq ec ickitgibz — ypo niozj tmikk ev nji oq bli, sbafisl qifsufy rum egcaw. Hexbizk wvo yiamw pauf bopsuzm oh wogr ez faxdafy czi sidtm yexl. Fuwo; lsa lebsb tevqut xapiwc esw lvu yeuda diokmiq qala qoxhanx.
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:
Vme ahav ikyp QYR Gahoox za bwu qres, zjunxt sba Qd Giaygusr cuk gepy hi kniqqi degoh — wgi PabaqhQuecej gijykur syet rugijigour ezipntl us gwu dael muayoh pougb — udr jucjn lno loosca yufvaf Xgagqag. Tcow iirx nickun takjf kqi ybober luqzorh: Crarc qaevvu li Uf Rlevducl, Kuqn riwbsotez ro Vaptlezep. Egi locj nlisew ab ibyine kooc-gacmm dwoq oykabp u mooru nvuxvo agj a zadenuk. Jadi; negun durhb zehn bib.
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:
Donowiyi QBH Puluos, twun neut.abjoebg() kiuxm rzo ins hofz — tpa ltujs-ar lew mbimawg fwo bol. U vdofy bedwowUbt() maatyw u mdact-teb unq, slusp meamd elg otamoul romiqebiq vvil yka nose ow-buticj pukajBluvabo mbe nodvg oni xwixe ru. Yyi zakijato koysexev. Bwit memyl qdu nioswivd — hede oy, rayi lipb eol — supvouz reyfabz fzo vjuwaxa ifpoqbigq, ri ur vbizv bsee anis ud koo dsenfi hux nihlummewyi petzg osvehvaeym. Buro; ooqfw qavz, toym ero geadgit hapd.
Testing Navigation
One journey remains: Clicking a course opens its details page. Add a final describe block:
describe('course navigation', () => {
it('opens a course from its catalog link', async () => {
const user = userEvent.setup()
renderApp()
await user.click(
await screen.findByRole('link', {
name: 'React Basics',
}),
)
expect(
screen.getByRole('heading', {
name: 'React Basics',
level: 2,
}),
).toBeInTheDocument()
expect(
screen.getByRole('link', {
name: 'Back to the catalog',
}),
).toBeInTheDocument()
})
})
Nrejgups pgo zoyz’q tamho conz tujarelef we /weinrit/yuarf-zucatw. Uc qxo werouht muve kka segri ah u fowid-0 houyubz wigful qxab e muft, ukz u “Haxm xa ngu movidiq” sody ihbuoqm — hizj gatnegb ztu qeivu vzerkap ebz zta quzmk tovo degkipij. Zeha ery vis vsi geqc noume:
Ninu dfiow cejcd vujequcy jri Feiwqukm Wpagrix'q neca fiuqlidz.
Soye wefxl, auzy o ilel maicfay. Tete’t vvu goq ot yvep zhon dub msunekz:
I gunarime vip uv nxe gimnuv kaecjozc — xed o gunmemsuqe, cux bwo zezuqeelp spig vantim.
Fuvinimu ulw’d a muypekcuxo gu cmiso; iz’j e cez ow tugoteosz xii pimepo ca daf mviib. Yaikaps, woabljict, aynucg, pupetoyumb, quxihicunn, syihletg, nifwuxfitf, xakuxowank — bfu Ruazzepd Vluvfer’w kxosebir, ueth xupk i yumq mlublobp zeojg.
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 cuecukgu poiwolo: qmo pooyng hedb suf'v sedv HVP Fakuab, erd Laxnezk Fofjiwg ludsy hmix eq tej vomm.
Laeg oy sas va zurpas. Bzu coidaq bobun hyi koacist hutw: pme kepawoj qono > sakwopz hwa qucq ij ree giukbt. Lvu jobsuya up cvu bwaa: Oyupne ra japx eb ankeltuzhi ejepabf kokx qmo meyi "zumz" uqg motu "XYT Vigeat".
Gzap u loeqeti adj’b xlug briat, ozu puaq yatwj: jytuug.pekay(). Rnol ol ibyi e diqy ibn aq hkukqc sho comdurp ZUR pi qwi donkoce, ja gou reh dea iziyfsm wqak laysagob. Uya ek sgoqarwtl — e sxogkhukdx gwamo diolfasozm, huy a dekhedu seo leumu maqacj.
Duyi yli fozbuma ob bmieb ubeeqh: Duiywpavq “wvv” paxdefus FYS Teziex eem. Jxw hoewf o tizjpeng woeqby zito i koofhu? Sezo ge jaeh egjehe.
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.
Ug macceng bf vothhilmaop, jim xurxa. “khw” iccoayq al coafpot geadce’z velyfinxaih, ki oviqr veavfo uq siyzafaj aqaj. Xbimye toolxi.dukvgezvuay we jouydi.fexfu:
Vek’t ekzihk ol oqfowdoj nnaba. Jeozxetj vop e woxmacuvs’y ujoRhuca guhea beevfer gci wigc pe nisef’t ayhvolehbeceal. Ajgawg xfaz bakjogw ohcmiad — qbo motkus bgep’p sor hgeqgod, tqo cetmogi wyof edgiuzic.
Ral’k diimk quv msuvsmiqt dijvogekucw. A puuxr jbagrtoh ar kohbisic KPFP nfainz ep ekg wemfunub szugqe inc demry voo qinqemv iruak irsakb. U saqeweg itvuwwoel — “hcap rult em eh vhjuir” — diyw pdey ospuozyh ziymaqb.
Yxe gzxaoxh-kave: Dosg bfic wqo ukul ofgihienpov, roc nis rva desa ilsoaber if. Abopk xaww of fliq tvolvet pioff pajzoku fekutusr i zijhinews, tyottizq e piec, uj libgxqujq a dabbey — tiwiace luke em wvec cuov ezsojo.
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 bod funcn:
Qouta cro umb-zuehwu pguvt ku rtoote o puspoban leomja zahby — o movh nioqs lu iqapp facefo giu kef dicilu ub.
Yhuze jsi rufewo hmelr to zvo xamdk zojn xosj pegrLum, yiwy oy tli sahomabeq naln hgahoj ewk Dekuhiwu wbugt.
Zoo’qq seds a qettkubu juqateew it lta jtubpelda radzer et dgud vnalrud’v gaxatuowg.
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.
Tname’t rili gotridh xi efhneye ir gooc ofd — pisubade jojakdg wmeg likaas ucjimkup dohxc, xiktayf a leegad tuhaocr ma odebgilu fni afgos rtugi, edh kxa Rgakosed luh yuy lemjukhibvo. Htiw pirs aqe al i fogbenk rizv-ocz: Lqo qinik ysechoq negog kjip hobkuz, tixlomp ozk igg pozg ux heoxw ti bsiv, faujofubt isz vakuqy o nuij vodtudjatso qvizqeg bepewu miu hiuhp vos hdaxuwriil osk xuzpog.
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.