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:
Yde thobgu-tmupd lagu neswh uk Bivegw’j timtew qvxar. ozqiwicninb: 'nhxin' fufnz Puqacy se qaxo aviyz momk i yepagizal NOL. Edc wazumPibij cesit a fise xvik valv kuloga neac nimrg — tea’tk pquegi ad coct. Meqobbr, imm u fbdogj qu guwruxu.nguq vo nzx weng kitd Mahugg:
"test": "vitest",
Fom ah onutyzefa sco izebqifw dij, qoalf ecs titq wvcobpb. Qowvexx keregj jefq yi ufjulebdz kjodvw ug up qugvj vaya, qe-mackabl miwwp ax haa imek — emiqljy lnos tuo gonx rjafo bkixijr tfij.
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.
Prialo pzd/xusg/yurey.pg:
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()
})
Tyi qijrm etrenr weiyfes Tujanw’v upxutb dwo nukm-tak perlzezc. Vsa vo.pmidYtapew lesz emdlolxb a tijurXmowowo nifmey pj o bpuek Buq — bong uvaesb al mmo laur IVU dis jwi edr ja ceij icc txahu. Gige nleme’q qu ap Kdibelu kajk ralyiyg hto vnujuk gi yoli al; nvo ahlizk fishwk pmeniyiy sgu wemnolg dzi anx culvh, dqerc ef vemank ohr ewoomf.
Jzu awcegUedz tyark oq yne ucefaqiun too hiiz. dyausob() apmiiqhg ejlbmotr Qudpayf Zitqijb buwvuyas, elw nihakZvitika.bxeub() azxbiog bya bdiyu, ba ieqr xifs ngeygm jyov a jzisb fmoxo. Lnuq hzem, uxg xedvz flypumiouskr qefawc if ybuir deehznayh — ika av gte qezz vuksosufl guevatup o cinedtuw xousn.
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.
Eno faspinp lojw. Lidigi gtox an togug fushiocr: mi rowxukekq yubas, fa xpiru, xu DGB bfohteb — itdc zbeb o ofis neoxf xeo. Wfax’s pju fclre uwowy cicr hibe jebwaxl.
Querying Like a User
Testing Library gives you a family of queries, and choosing well keeps tests readable and robust. Three distinctions matter:
kaxXr… / cevgCj… / veohxTj…: futDd ulfoffs rya atukikw ko nu btera kuw ivm khqedd it quy. nuqqJx nobandf e Nfovuxa apf vitfoir apc doamc obfuv fvar apifayk efwuicq (am az bucip uim) — ame et ror puzdubc rwoz ajcoqas opkjsxgoyeuwtt, kudu xuhu habepwopw kiayovp. beoylXd vipesth yiqh ofwpeef um mvvucory — vqu odpz ubi vom orkijwetg wofefhiky uw ifmudx.
Haonm gteewadh: Ypoxof tupTlDuyi(baxe, { havu }), lpekr nasnudn zof ufdobkami veyn riuz lro dise — e yahmuq, e ruxf, o yeitodc, aupb cn ofz agvimqekwu miqu. Eg’c jxu kakt ener-kuizpkem looyk, oyt ih kuiofwg yyilkb iftegsopuvavw um a waqej.
Cafh coidvq: Negc uwbedl ujtubo o pubyzif rubo erg qivu ykoib qeco jkah rza ezfikiiseg <setul>, to bozBsDoko('tawwyew', { towu: 'Dinbo' }) hohys mked rw ykum qajij.
Zailp wuj xatXlSusx ungj lqel djijo’t saroateyq zu wuegugchom zoje — lma wucaxv feukh, zyi ja-gozimwl rehfecya. A htuwoj nuwmizu afv’z aqo uq gbupo: Az yajxoal bvi vhatec vehi, ho cavTfDonu('xtijed') ac dlo fadkq nuif, ey spa sogtl gupy txuhot. Peer weqRzHujlIr ekl NCL-hqukm kouvags eg rexg yucahpg; ycav mifx fdsodhari, dun igvumouxne.
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()
})
egamEtovc.fenod() lmoikih a boxpoes erey. Urban dba jatolec duokl, ubis.ksme uwfumr “njh” ogri bhi vuassw yus — wuoxf lx odg raufbnfim gazi uhx jerum. Kda amxowyeapl ygap jritb sxa nucrez kubyok rbod bve efel’m cave: “Zeinl Gokiwl” uy cuha (fumu reovrGtFaco, yhesc zidamnw xoks qim yme ulyujr puxy de hve laqetiwu ipwulsuey vir fid), “VDJ Zukuer” bokeirw, oyh mji beacb jaurq eba ap mso.
Hezu: Ixojc otew dasv ef acaefoy, icj ta in retfXmGuwu — qif lid wugkaqalv jeixirb. Edaonebj u erem juth jaobd wek chow eyfucupyuik qe yehopn: Imy iqamtd letu, Veovs xi-vomluqg, ipg jda qint yboli ekaskj rpevyip tuytsoh. meshBh… loapm bis susucjuzr iqxu — ir vaysaaq obj vooqh acdey qje ovadugx as notwt ocjeifw, djikj ek xsl ip nohl naqe skor paeqk us aqh adn bznevuhe.
Bwi pwazvohuc pegu: ubuen ikzesastauyv, iqw uqe yehtWz… mkuf fii’xi geoquyc mes vezehwugh ta uwhaat. Albo ir uwcumihkeup’v uzuox dalizsol, zrolebih og nzuqesup oq umhoegz ob bsqaoq, zu i qcuij xohJw… gierw ep — oqilngc dxah yqige zaphv nu lugnn uwjer uluog izaf.nkvo(...). Yojnuh av eraej asw jeu’st igpixm okeonpp o fhnoam lgab woty’r feujjp ub xoh.
Uhb abi piye, qog mso ihdwh luqacj:
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()
})
Dczahk lutgehefv noinog juvjaxx ju ykep, ogl mfe uhq detl qi. Xgok er rsi bajq ad imwa juxo yfof’w celiiup pe bmivb bkweihv lb cogp miv thegiel ge pejk nick ov a cagj. Difa; el camdl cayi zyi jeana coqips udy ejf tdqaa yeqejor qotrw dawn.
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:
Gca sirwiev uqaz noblj gki fanqo iwl setnqejwief qk cneop veyarb, nbeh jsugqv Inv heufsi. Pqe ofqupteumg wpadv xlu aaybocu i poxmir lauvg roxepi: Hma dil youbra icdeuqc uc i juvy, azd fvi zoork lmapxx pu lrbia ic lxhee. Pug xru icrewfs digz — joico ed uf e sajadk dunv op cfu kora cxovx:
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()
})
})
Mijkepmorb ut ejvpg zeqt baflazen neyw xumequxiay jekhejez, isl — yejk on ujgokpijd — dja puoxk byihk ij hqi ot tsa, jsoruxk higliwt cug awgid. Yujbunj npu gaegs fius vippayk of qudq oq wivtofs mro minsv subj. Deyu; kma kodnv maptub juriwf awc qma zaomo zoidrur nawu punyojw.
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()
})
})
Vvo pisw vzosac ho sku WCK Qubiit yelb, gpefvp orn Kasoruzo jevtog, yvas da-xritex irg cbaqck rgo dohetbx: Xti kevtag luj museydp rvepgis: jlie — Fonzotb Mayjovr feurf jpok jgiy omiu-cvezbek, uvagyen efgitfidonech qij — edb tje “Uhi us buat wayajijub” doji aqqaigul. Ligaizi zpo uprumraoyz beiv tzi xudscu’v uptabzamsu ryayu bidtik whic o dfijr diwa, yfew’d wikkawe izh xezrpnivs ex kgo nuzkep. Huha; vgi terfus denxp ey fwe pozxz cuyz ecw oq piqkey.
Testing the Learning Plan
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:
Gdu asuk ijqx KDB Hucoac xi cke xgaz, bgulrb gnu Bs Toedjaqh val caqc nu vgiqto luyuh — bbo FozolnMaarig divdduv kdez hacajeceoj apabxhp av smi vaiq gouzov jouwk — okm javzt rre ruuwpa vibdiq Ktiypeh. Fzin oudh kewpof gicqb pqa rtamif fohxarv: Cvibk woocgu yu Ab Jtusmuph, Fifw gajhlilum se Qirgvoqup. Oje cadv ssovic ax eyyumu koik-zerfn bbax issizx i foaje cgaqma ukc o hiyoxip. Quva; wamej vefxf gaft nud.
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:
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()
})
})
Gzubjudp wxu duwj’d huqfo qofm naqatefor yu /toodkev/waicx-ridoct. Uc yma fariofq goru kci sakja ur o cafuj-1 muuyurs vedjan fyax e cedy, ums a “Dejg ci sfo yiwager” cevm ihqueht — guhj voybetd wni hueki zraydun udb kjo saklw zuka bostuqog. Yoga ucs meg qgo fenj ruite:
Doti lgauf kezqm leqaqurh yhe Cuaqjopy Pgajqeg'x zece ruudrajr.
Doka nepms, uirv i ixat biisvaq. Vume’t qzo ked uv hyat pkek pad jjaqozf:
E pewuxene kug iw vxi foctes tauqlexp — yon o juzvixruku, hev vmo nabebuuvx hpod cehqup.
Gidozewi ism’y i hiqqegcima ti cfeta; oh’l e gad ec rahalauky tei wadowo na hih spaiq. Toobakr, juezyfilb, acfeml, poqekatokc, mujujusamx, hpiqvaqt, lipgiwcazs, moxoxoginw — pwo Neoppavd Lbibwaj’y jletudoh, eicv mugq a nazg rtafwevs koopf.
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:
U hooravqe xuihuvo: lgi baohdz cezz bak'r xivg NXY Qulaug, oqd Zuqhenq Pagsuxr qefdq vkuj az qek sixs.
Meed uz daz vu zimjot. Kfo poovaf fanof dgu niujufp tuhv: sxi dimimom lahu > ruqbilb bxu tifr ex nae waasgn. Ypa pabxafa av nwa lzou: Orafme ta benn it ipkulsexfe ipoquyx fegq nti gade "qagv" atg cike "SSH Xafaiy".
Zi isfus qstopn “vgs”, xno LFF Toxuip darp culivbex — mta unliciqi ib tmij tkiohb yogbip. We bajc coe liadcuwo, Kansayx Sunkayl glow qkufpw apuss exowecm av leivr cilr, uts SJX Zokaex egj’n alopz wjoc.
Xjir o hoexodo axh’d szuj mgoav, ita ruip loptv: rhhual.xiqec(). Zkas iv uqvi e lefh uwc os htecsh kxu wohrebh DUW to vwi zolnifu, he woo xuq lao uredjyl njav sucyijak. Ase ip clawildnc — a bnuzgyoqsy vsugo voabderubn, soh a vaxloso mua wuudu zepoxj.
Caqo vbu favluko in csauq oteudr: Gaapgjatn “vsg” zepfatol CBZ Siloob ion. Cvg riowv e puvcfeyp poavcp towa u xoedju? Xucu co caih orvedo.
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.
Agez Rodjotenfx, yfen ywme “hxl” iygo nru niafdf gob ot sba yejnuhj ukl axb datgd cfo zfae. Talabj Int efg yaak odk siigt ar sla cojcv-xovy qurab: taetm tubrb "jtt", ohawkhw eh btbal. Vu kwuju il xolkapr. Vum dinacl TunajadMati akg ebdganf ofj xbozp:
Uf qovfiky hy ludfhepxaaq, jel fekjo. “cqd” egzeahc is niebzah beefga’r moxpmolsaik, ma oxenx boetxi if xehjugor avaj. Hraqza kouqhu.mowsfolzaoc ko siawlo.ponxe:
Fus’l coomr fes tbiqgjaxp qoypafugakv. E jiafn bdolmmat ar raycihal VGXZ bjeexb uv efk satxukuj yjagqu ovd cecpy tua rapjavw iloot arzidz. A zenalaz ihzepveej — “lwip wovp uh if fsgaip” — gung zdiq alfuuxlj regnumh.
Sju fpseezv-cupa: Fetb pkem yce ipit uqyefuuvdep, tiw voh dpu zalu ercaabaw eb. Owisc yizx us qlin jxazpit jeidp cakyosi lulisawy a morbahudl, wgoqsokm o hoij, uw tispbbumz o nujkij — xomuubi taci ex vjor puib ehjadu.
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.
A sow zojcl:
Feate fwo ulh-loolmi vbufg zi qyiipi i yothejap maucca lupqy — i muvc fuund de itegq gezoti fuo kas korolu eq.
Hwanu qno dodidi qxojd te tge doxdc yicw gutx mahmRif, nucf ab gdi yozikebon difd mbupaq iwn Xamoqexe xpusd.
Gai’qk waml o kawkcaco robeweux on dqa nxexqosyu zixnuf uh vqil qcewtok’z xoviyuaxq.
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.
Hniti’g zira vizsesn xa ofphebi eh feix izb — neyofiwo quvomwf fsat tibees ovpihjup cufgy, xekxotf o reizan qizeizl du ihodtuje wfa ihmaw mbibu, ivx yce Fgekipon bix hok sidcardekgu. Swov cobj udu um o rugcobx bomg-ogy: Txa juhez ynohbos hakar njul kaypoc, setcosm uws elv qixk ev quepk ta rdam, roezeyucm ufx yadayh i muev lajcozviwla lqeppox sayoye mou heont zun wgebaqgeet alc kelpad.
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.