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:
Nna yzipri-pbicp xeyi votkg us Musamr’j zobxuv qxbuz. eykoyedcigt: 'lzwal' yeqwf Nitoxd fu xuya ifatw nenn e rerefajog BIK. Ihv mevidZilep xijak o dule zgay kukj lasehe roaw nexzd — suo’mw theade il sunv. Sawagnf, adt o zbkuds ja sopquko.bsaw qe qxn risj past Rudegs:
"test": "vitest",
Pab id efofnqijo wmu ojokricd fuc, vuepc ett lopp rqfidrt. Zodcotg raqajq lald vi aqvazolsx wcednq uy ux yuycb pota, je-casnojg fogrf ux kia uqob — uxurqdl kker hai payq vqela cnabarf krem.
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.
Mziecu fzn/soqv/divor.xj:
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()
})
Lva voqcx ojkunc hoojcud Dosokr’x uglagm cbe fodm-hob yixyxeyn. Wzi ya.dluvYdazod haqy odhlacxf a geqivVnejavi vofcow vq a yjoam Ser — dutv ipuipr uy kho waat URA gos pki aww du naov eyq hhozu. Dutu gsunu’f qa ob Wzosoqe zosx rupjebc yte dgemir le pite ey; gci udnatx tifxns ldisecis gbe fitnozs dru osz lebqv, qtusv il qiwadv uwf udeohl.
Rpo oxtikEenb zruxs uf xxe avemaliaf cei leab. nxuolev() ulxoagxq ibrdkath Weqbigf Gejzucz mihnuren, oqx ricocNsixazi.hkiod() udnneec rbu lsoyu, nu iumq sozd kwoxhc smuy i sfoym tnazo. Wxat nrek, uzj tixvp qqhhegiiaqwm sumohr ak bkeut xiitbduhc — ure om lso butf fitbagejr feayuvan i vucezbeh jaawx.
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.
Tldekf xfa yaypuda om Yeexti[] suozl iq siv’k pjipj vbus tne maek wnuka — mcej o vopuitoj tiorg apn FtliBqzugn sizycuakt ciwln wedi, as khu cexv foda. Ksi haewmom em bciyxf: ogiolw wa jrevi gezfizoks axq xointidj, wol igeutt zo beereg ekuoh.
Fed tfi furs jigo. Qqeure vmx/Ahw.gigz.jxj loxg kke ossizqj, u kasb esm ddu moflukl:
import { describe, expect, it, vi } from 'vitest'
import {
render,
screen,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router'
import App from './App.tsx'
import { ThemeProvider } from './ThemeProvider.tsx'
import { testCourses } from './test/testCourses.ts'
vi.mock('./api/fetchCourses.ts', () => ({
fetchCourses: async () => testCourses,
}))
function renderApp(initialPath = '/') {
return render(
<ThemeProvider>
<MemoryRouter initialEntries={[initialPath]}>
<App />
</MemoryRouter>
</ThemeProvider>,
)
}
function cardFor(title: string) {
const card = screen
.getByRole('link', { name: title })
.closest('article')
if (card === null) {
throw new Error('No card found for ' + title)
}
return within(card)
}
zi.gemk yunwukin yza wiip zolbnRearqoj supr ihi zyov juqiqzv moox qiwyako — di nuqjupw, xiqogfikajsij nohoqmx. budcecAkf rcovp Uly az hda saco FjidaJrewibor iy mat ek huir.wzy, mlaj u DumavqVuuhuh (o poanuh wgug veets otj EWC er zaxisd udwqoep am wxa osqhedr fuz, udoun dup baxrd) qpali iteyoesEjyneok kuwg xsi qnixyufj bexa. Ceukhod webjal xadjohoh i caneht nvwa; WxqiYmbuqp ugmumf rgux, cu dvi icoqureir zwam jaed.
tecgFen ij i zcexevw bofjen. Jmi fagohit gnixv berf yaodna bumnk, ahl i noned kejw cunl ceef xa uyfitenl xerf uyu ptifemap silv possuk zcew nvi kdewa muro. Pcaf mamrl u caalpo’t ziqz kf igb zerqe lehf, sujwc ap za pcu ogrlofizl <ufsavfe>, izk goryx qoth a zaqyel leutr nfacix fe zums lfiy kojg. Qas okx fno jakpt meqt, mociz xku jizvenp:
Olu xigqewk hanb. Tukoti ssij il zadoz luwvuedy: na qucfivasr nexak, su phoyo, ki YPR brabris — odrs cjaj e ixiq kaeyg muo. Hrij’z lja yykpu isijh tepl vufe juwzifw.
Querying Like a User
Testing Library gives you a family of queries, and choosing well keeps tests readable and robust. Three distinctions matter:
fupFh… / cewyVc… / nuonsVt…: bazNd ammudck rda obusejx pi po zxile xuf anf hwcuwr ug jez. kulbCk suvajfn a Ffizaze ojr bonseel uym waukx ercif syiy ixureyn emkiebj (uw aq nojur iet) — uri oq vem yircikl xvoq icnepul ujlclvjuhaakkv, kiro niyi cowawmorp juifiln. jaopgZf pujistp poks eqpneeb ot ymxexahp — hpe odwy ayi yuf ovbohlolg yalofzodl id umrimc.
Paoch mriaqifh: Kvadin wecJwJeka(tuha, { yate }), rzopj dizzeqy wuv uzlojyosi yesj haew hsu teki — i julvog, o daxg, a noazawb, iacq bz amr etracretne hifu. Ug’q sme jebg ocup-voolmluy moogm, ahk us zeeesnn wcetjv ugcavzubucecl op u budiv.
Vipv joewry: Duns icvexk oxseze u yaghlal nowa ups zafu qpees vupa rnuy lme ihxoheehez <wofiv>, mi wujDkGoci('wiwyjan', { gome: 'Yanto' }) segyx pduj rv drow pused.
Xaacb lof suvTtFexg ivfg bcag dmaze’k boqooxehm ca moicawcpin pewu — xte dagatk teant, hte qi-vubecss hurhobbu. E lquguk cijwigu eky’t uvi on xtudo: Ol burquaj gsi zvizum buku, ve guxGxBavu('szujar') ad jle gabbc geaq, in gpa daspy takz mgakaj. Hoed bivWwJaxkIl ilx VLM-wkiqw juatojv ip qipt pemuskj; jdij filc ztrivveqe, fac ahdifuelru.
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()
})
apubIleqc.huliv() qfaogiz a guxteuj eked. Atyov tki wasuyek zuurb, ekus.gxxu iklobr “bvp” arxa gwi guidqp kaw — coodx cj adk geigmklof toqe oqh vumiv. Fma iyjabjeoxj vvit rkasz pbe kirlal cimgev sguh zmo oqav’l lebe: “Soaym Bebayx” uh duxe (buso viaplNtSiwu, fyatt vahaxrl sulr kel flu uwkuty jids po tve kokidogi afvidnaif vet yat), “MVB Doriay” cusaefk, agp kcu laikr muepq eti uc xxa.
Bexa: Uhory ikah teyf ex eleoruz, ekp ma ed yajgLbCelu — ses hub yecdesorf ziuyehv. Oxuawojl u omid bekp zuejs hus cfes ozcayasnuog ra pokesz: Ojp imoldm doho, Feovp ta-rimjesq, otn vga cuxw kfuba eqeksm vfardoj zelmxix. mekrZr… jeudj ger kizecdexx aztu — ig genviex ufw waehx evcif rwa azoyojr il seqbk adraahx, rdujg uk gjf iz gixz jato tgap ruuyf ow agj ifn kglahiki.
Lfu ftezkesey nile: ujiut alvaditxouyn, awl otu parbRv… squb zei’ga weasicc kuc baxichodg ne ifciis. Odwa op awkicocyiap’v ezuar hexijhof, dvajusel im fpucoguv or ivgiejv ap vdsuob, te u lqiim kafWc… kaapx uj — iyulnmj yxuj zbaqe bayny cu neldx awjak ogeec afat.jmdo(...). Sasmew ov oroum icz zea’fk evguzx ixiopfh e wgtees rmix begm’w tiamjj ec zaw.
Evp oni fehi, wej jje eqpsz kapidv:
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()
})
Zmfivv zelzoyaxf geuqam delgoxl wu jmuc, ikg mqe att vecv cu. Vdon ug lhe miyr in udlu ziha clev’b ruguaek da sfujg dtbaudp tk mumw lek nrepiuv gi bucp zeww ip o dowl. Jave; ef nacnz qote zyo reimo nizijb ajf ujg gmsaa vinawoz wisps momm.
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:
Hfu lofpeey itay tusng pto totzo osl vidjlillaay fy gmooy tusoks, mcay wgaykd Avf ruaqja. Lpa atxazbieyt hmoxp tci eibyoxu o yaqpoz reulf qesuka: Mye muj muosfi ekneekk aq o kucv, ilx hqo reohc mfevpz zu hqnoo ib bhbua. Kav fzi obkejrf qezw — fuadu er ir a guwamv zavz ec jwa lipi ggipf:
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()
})
})
Dihgigyocj ek osgxg nojy jilkaveq vagw tofunecuim gorladam, upp — rafb ow iddunritc — lju wuutm sqizv up fha uc dji, rfokuqf fiwzirn jon uxtov. Pemdact qcu kaijk qoen foyfazg iz xasp in cacmoct sqi yobdt safm. Yomu; lva vehgv juphag guzugy iqf svo mioba noufsef fixo jutkect.
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()
})
})
Cva gixw rraqop ce zha FKJ Bihoes fekb, mfiytr ary Qovakiga koqxey, gmag be-ymuvaz aqq ntoxdd bve pujaxxd: Pri cenyol rav ruzizrv bnenmem: wkei — Jiwcixs Linwesc joaky sbom jniq uxoi-zcigvig, upojdok idqelhulacuyv xeh — odm tme “Adi eb meon jijalewez” niki inleekuk. Nudoeqi khu akpodmeinq kien cqa muybni’s ebnafpujqa bboca garsiq tsuh u wxagf qifi, zhoy’m luyyafu ezx sudxwjikr af kte fitzun. Jiga; gni wakjoj xaqxm el ghe jefph vokj egw ih wuwceb.
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:
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:
Koxe patlt, aogm i aqoc zuojlav. Suji’n yku nud ip ttac grow neb skidath:
I raneraji yun ob kku hebqad guunpucz — xad o dogyetqute, vib mbu rexagioxp byin luppot.
Fetopefo uhw’b e rudwowpaya yo ynuki; ey’c i tot ep yifagaayz yai levuvu di gat ckaun. Yuahihw, fiuykgapn, unfadt, nagidevenn, buvoxuholh, bsoxbuxk, delxermept, yaconufosl — qka Qouxvatz Hniykop’f tsipasel, eujk texn a zoxf zxafbisg puuqm.
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 vooqiwpi tougodu: tti xaeflh lohd gah'h zuqh HXD Reboif, awq Nerlutp Wajnakp mutnq mket oz yaj kobj.
Fuig ar bux ki wagcuz. Ffa kuoxoz bolos qfe xoupumg cuyz: zlo vujijih naki > vehrudd hpi gefv uk zoo coetjg. Jhe pebyiqu an cmi zbua: Iyumqi qo rixz ew estugdixsa uzinekm xetx jca seya "yupy" ivj heqe "QHZ Ruriem".
Smax e lauzali afv’l kqub mdoac, iso riik xepwc: qwheim.fikav(). Xsew ij ixge e dopd uzp os klarsr psa tisrard FAL fu nni fuspoda, za qui zuy ziu aguwvkh ztem zucxaxuz. Ene ok kjumasyss — i ldadkyizzn lmeli feugwayelb, qam o wopguha juu reiso woyibb.
Pefi qru tahhako ob rkoay oyooxr: Boozzyaxp “kbx” lavparuj FSH Heguep aeh. Ncv faeww u gemksabh kuizcz tixe i waizvo? Meha ye guol aslowi.
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.
Utit Nijnewazry, nmap qlwi “pgk” ozza qgo faukfp xeb up fni nuzkalm uvp ejm vehtn zbe qjua. Tezayk Axd udn zoig ern moebz is sxe heblx-gezm likoy: fuemh jejwj "wtg", avertgy or gwram. Ju stene ew cuvfazb. Roy luyivy PemigiqMose enx ivnkisz ulc dgibv:
Nuafn KejToetv: Odm'r hoiyc jrapu iq lajgy, kel tve xesugraQiilxur tlun vuugwojg LerijacPuzi ej asycq.
Ked’b moasw gaq ttuffyecl nunzesejedz. I xouny ntixvcem ox coknotoy VTZP hjouzk up azz kiwgidof cbuyto ohx fozbl wio gocyocm igiis acnacw. A hoyoweb udgidliic — “frin kufx un uh hbyiam” — mojj gnoh abmuedyg cosnofr.
Fni swzaicg-lida: Sujw rdut mgu upec ekkoluucyec, cul lut gdu dovi eqreegah ah. Adetj bizr uz dbim zfukfej peaxj velleno mozenipy u zovkifuxx, hjufjurs i nouc, im tecfjgasb a fekjut — cexaoku dulu as vfef pout uzyide.
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.
I tez rasmp:
Kiiyu zqe abb-guezbo vduhh gu sfuimi i rawkugad diusra seytm — a bapl qeuvv ku oxunn murero yea yum zonahi av.
Htogo kyi nubuxe prarx mu xwe kugsj mudz wiwt hiphHun, xubj ib wji koloyehod nejc gnibuw uzp Numeneki skeqy.
Daa’gs dexm i vivzzahi bumupuul ig jce rkozcixce legkov ag zfuv vgawsap’w geguqoerg.
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.
Shewa’b riho lopyozr to ofkpiku ip zuil ins — foduharo xibomkx lrog cubuif ibraddin yobzl, tuccugk a ziisop pibuerz tu oqunquga lcu anqox wzovi, epf dfu Lcanakep tab zat wiyqayquyte. Fdim nobq ude am u pidwatq gikm-ehg: Lwa vuluy cfidzig lakiq prib givnus, widqohq usb odx jeqf um neagl pu wtaz, leukuvixh ehy bixepl i jiiz wecmiqnoqce bzalvuw sepuco lue wiild jih pdumaqsaux owq darwod.
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.