Test a React App with RTL
Write a full test suite for a React bookmark manager using React Testing Library.
Introduction
30 Second Summary
You add a new feature to your app and everything looks perfect in the browser. A week later, a user reports that something you never touched is broken.
In this project, you will build a bookmark manager app in React and write a full test suite using React Testing Library. Your tests will catch exactly these kinds of silent breakages by verifying every feature from a real user's perspective.
What You'll Build
You will run a single terminal command and watch green checkmarks confirm that every feature of your bookmark manager works correctly.
By the end of this project, you'll have:
- A working bookmark manager where you type a title and URL, click add, and watch each entry appear in a live list.
- Favorite and filter controls that let you star any bookmark and toggle a view showing only your favorites.
- A full test suite that catches real bugs without breaking when you refactor. You will also fix a test that breaks when you add a new input field, learning why accessible queries matter.
- Secret Mission: Add a delete button with a confirmation dialog and write tests for it.
Are there any prerequisites?
This project assumes basic familiarity with React concepts like components, state, and props. You will also need Node.js and a code editor (like VS Code or Cursor) installed on your machine.
Before We Start
Before you dive in, take a moment to lock in what you're about to build and why it matters.
Set Up Your Project and Testing Tools
Before you can write any tests, you need an app to test. In this step you will scaffold a React project and set up the tools that let you run tests against it.
You will use Vite to create the project and Vitest as the test runner. Vitest shares the same config file as Vite, so there is no separate build pipeline to maintain.
In this step, get ready to:
- Scaffold a React project with Vite.
- Install Vitest and React Testing Library.
- Configure the test environment so tests can run.
Scaffold the React project
You need a React project before anything else. Vite's scaffolding command creates one in seconds.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create a new React project by running this command:
npm create vite@latest bookmark-manager -- --template react
What will you see?
npm may ask you to install the create-vite package. If you see a prompt saying it needs to install packages followed by "Ok to proceed?", type y and press Enter.
You may also see a prompt asking "Install with npm and start now?". Select No so you stay in control of when to install and start.
- Move into the new project folder and install dependencies by running these commands:
cd bookmark-manager
npm install
You should see npm download the project's dependencies. The output ends with a summary of packages added.
Seeing a Node.js version error?
Vite requires Node.js version 20.19+ or 22.12+. Run node --version to check yours. If it is lower, update Node.js from nodejs.org before continuing.
help me fix a Node.js version issue with Vite
- Open your code editor. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code (or Cursor) and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the bookmark-manager folder, then click Open.
Now verify the app runs. This confirms the scaffold worked before you add testing tools.
- Open a terminal inside your editor by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Start the development server by running:
npm run dev
- Open http://localhost:5173 in your browser.
You should see the default Vite + React landing page with a spinning logo and a counter button.
- Go back to your terminal and press Ctrl+C to stop the dev server.
Don't see the landing page?
Make sure the terminal still shows the dev server running. If you accidentally closed it, run npm run dev again. Check that you are visiting http://localhost:5173 and not a different port.
help me get the Vite dev server running for my React project
Install testing dependencies
Your app works, but there is no way to run automated tests yet. You need a test runner and a library that can render React components in a simulated browser environment.
- Install the testing packages by running this command:
npm install --save-dev vitest jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event
What are these packages?
- vitest is the test runner. It finds your test files, executes them, and reports pass/fail results.
- jsdom simulates a browser's DOM in Node.js so your React components can render without a real browser.
- @testing-library/react provides render and screen helpers to mount and query your components.
- @testing-library/dom is a required peer dependency for React Testing Library v16.
- @testing-library/jest-dom adds custom matchers like toBeInTheDocument() so your assertions read clearly.
- @testing-library/user-event simulates real user interactions like typing and clicking.
You should see a success message listing the packages that were added to devDependencies.
Install failing?
Make sure you are inside the bookmark-manager folder. Run pwd (macOS/Linux) or cd (Windows) to check your current directory. The path should end with bookmark-manager.
help me fix an npm install error for testing libraries
Configure Vitest
The packages are installed, but Vitest does not know how to find your tests or render React components yet. You need to point it at a jsdom environment and register the custom matchers.
- In the Explorer sidebar, click vite.config.js to open it.
- Select all the existing code and replace it with the following:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Configure Vite with React plugin and Vitest test settings
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.js',
},
})
What does this config do?
- globals: true makes describe, it, and expect available in every test file without importing them.
- environment: 'jsdom' tells Vitest to simulate a browser DOM so React components can render.
- setupFiles points to a file that runs before every test suite. You will create this file next.
- Save vite.config.js.
The setup file referenced in the config does not exist yet. You need to create it so the custom matchers are registered.
- In the Explorer sidebar, right-click the src folder and select New Folder.
- Name the folder test and press Enter.
- Right-click the new test folder and select New File.
- Name the file setup.js and press Enter.
- Paste the following code into setup.js:
// Register custom matchers like toBeInTheDocument()
import '@testing-library/jest-dom'
What does this setup file do?
This import runs before every test suite. It registers matchers like toBeInTheDocument() and toHaveValue() on the global expect object. Without it, those assertions would throw "not a function" errors.
- Save setup.js.
Finally, add test scripts to package.json so you can run tests with a short command.
- In the Explorer sidebar, click package.json to open it.
- Find the "scripts" section. It currently looks like this:
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "oxlint",
"preview": "vite preview"
},
- Add two new scripts after the "preview" line, so the section looks like this:
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "oxlint",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
What do these scripts do?
test runs every test file once and exits. test:watch starts Vitest in watch mode, which re-runs tests automatically when you save a file. You will use npx vitest run throughout this project to run tests on demand.
- Save package.json.
✔️ Awesome, I've got everything!
Great. Double check that you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the complete vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.js',
},
})
Here is the complete src/test/setup.js:
import '@testing-library/jest-dom'
In package.json, confirm your "scripts" section includes "test": "vitest run" and "test:watch": "vitest".
Time to prove the test runner works. Before you run the next command, what do you think Vitest will report when there are no test files in the project?
- Run the test suite by running this command:
npx vitest run
You should see "No test files found" with a clean exit and no errors. Vitest found your config, loaded the jsdom environment, and looked for test files. It just did not find any yet.
Seeing an error instead?
If you see a message about a missing setup file, confirm that src/test/setup.js exists and contains the @testing-library/jest-dom import. Check that setupFiles in vite.config.js points to ./src/test/setup.js (not ./test/setup.js).
help me fix a Vitest configuration error
Your test runner is configured and ready. Next up, you will build the bookmark manager app that you will write tests for.
Build the Bookmark Manager MVP
Your testing tools are installed and configured. Now you need something to actually test.
In this step, you'll build a simple bookmark manager with a single Title input and a list of saved bookmarks. Starting with one input field keeps the component focused and sets up a natural learning moment in a later step.
In this step, get ready to:
- Replace the default Vite component with the bookmark manager.
- Style the bookmark manager layout.
- Verify the app works in the browser.
Replace the default App component
The scaffolded Vite project comes with a default counter component. You'll replace it entirely with the bookmark manager logic.
- Open src/App.jsx in your code editor.
- Select all the existing code and delete it.
- Paste the following at the top of the empty file:
import { useState } from 'react'
import './App.css'
function App() {
// Track the list of bookmarks and the current input value
const [bookmarks, setBookmarks] = useState([])
const [title, setTitle] = useState('')
// Handle form submission: ignore empty input, add bookmark, clear field
function handleSubmit(e) {
e.preventDefault()
if (!title.trim()) return
setBookmarks([...bookmarks, { id: Date.now(), title: title.trim() }])
setTitle('')
}
What does this code do?
- bookmarks is an array that holds all saved bookmarks. title tracks whatever the user is currently typing into the input field.
- handleSubmit prevents the page from refreshing, skips empty submissions, adds a new bookmark object with a unique id, and clears the input field for the next entry.
- Still in src/App.jsx, paste the following directly below the handleSubmit function:
// Render the form and bookmark list
return (
<div className="app">
<h1>Bookmark Manager</h1>
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<button type="submit">Add Bookmark</button>
</form>
{/* Show bookmarks or empty state */}
{bookmarks.length === 0 ? (
<p>No bookmarks yet</p>
) : (
<ul>
{bookmarks.map((bookmark) => (
<li key={bookmark.id}>
<span>{bookmark.title}</span>
</li>
))}
</ul>
)}
</div>
)
}
export default App
What does this JSX do?
- The <form> contains a labeled text input and a submit button. The htmlFor attribute on the label connects it to the input by its id.
- The conditional block checks if the bookmarks array is empty. If it is, you see "No bookmarks yet". Otherwise, each bookmark renders as a list item.
- Save src/App.jsx.
- Start the dev server by running this command in your terminal:
npm run dev
- Open http://localhost:5173 in your browser. You should see the heading "Bookmark Manager", a Title input, an Add Bookmark button, and the text "No bookmarks yet".
Seeing a blank page or an error?
- Check your terminal for a red error message. A missing closing brace or parenthesis is the most common cause.
- Make sure you deleted all the original Vite code before pasting. Leftover code from the default component causes conflicts.
- Confirm both code chunks are in the right order. The imports and handleSubmit should come first, then the return block and export default App.
Why is my bookmark manager showing a blank page?
✔️ Awesome, I've got everything!
Great. Double check you've saved src/App.jsx before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm these two chunks are in src/App.jsx in order:
- Chunk 1: The imports, useState declarations for bookmarks and title, and the handleSubmit function.
- Chunk 2: The return statement with the form, the conditional bookmark list, the closing brace, and export default App.
Style the bookmark manager
The app works, but it inherits the default Vite styles. You'll replace those with clean styles for the bookmark manager layout.
- Open src/App.css in your code editor.
- Select all the existing code and delete it.
- Paste the following layout styles at the top of the empty file:
/* Main app container */
.app {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
/* Form layout */
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
/* Form field groups */
form div {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* Text inputs */
input[type="text"] {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
What do these styles do?
The .app class centers the content at a maximum width of 600px. The form styles stack each field vertically using flexbox with consistent spacing.
- Save src/App.css and check your browser.
- You should see the app centered on the page with the form inputs neatly stacked. The button still has default browser styling.
- Still in src/App.css, paste the following button and list styles below the input[type="text"] block:
/* Buttons */
button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
background-color: #646cff;
color: white;
}
button:hover {
background-color: #535bf2;
}
/* Bookmark list */
ul {
list-style: none;
padding: 0;
}
What do these styles do?
The button gets a purple background with white text and a hover effect. The ul removes the default bullet points so the bookmark list renders cleanly.
- Paste the following remaining styles below the ul block:
/* Individual bookmark items */
li {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-bottom: 1px solid #eee;
}
/* Bookmark links */
li a {
color: #646cff;
text-decoration: none;
}
/* Favorites filter section */
.filter {
margin: 1rem 0;
}
What do these styles do?
Each li displays as a horizontal row with a subtle bottom border separating bookmarks. The li a and .filter styles are for features you'll add in later steps.
- Save src/App.css and check your browser.
- The Add Bookmark button should now be purple with white text. The "No bookmarks yet" message sits below the form with clean spacing.
Styles not updating in the browser?
- Make sure you saved src/App.css. Vite hot-reloads on save, but unsaved changes won't appear.
- Check that all three CSS chunks are pasted in order with no leftover code from the default Vite styles above them.
Why aren't my CSS styles showing up in the browser?
✔️ Awesome, I've got everything!
Great. Double check you've saved src/App.css before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm these three chunks are in src/App.css in order:
- Chunk 1: The .app, form, form div, and input[type="text"] selectors.
- Chunk 2: The button, button:hover, and ul selectors.
- Chunk 3: The li, li a, and .filter selectors.
Test the app in the browser
The app looks right, but does it actually work? Before you write automated tests in the next step, verify the core behavior by hand.
Before you interact with the app, what do you think will happen if you type "React Docs" into the Title field and click Add Bookmark?
- In your browser at http://localhost:5173, type React Docs into the Title field.
- Click Add Bookmark.
"React Docs" appears in the list below the form. The "No bookmarks yet" message is gone and the Title field clears automatically.
- Try adding a second bookmark with any title you like. Confirm it appears below the first one.
- Click Add Bookmark without typing anything. The list should stay unchanged because handleSubmit ignores empty submissions.
Why test manually first?
You just ran through three scenarios by hand: adding a bookmark, adding a second one, and submitting an empty form. This manual testing works for three checks, but imagine repeating it every time you change a line of code.
In the next step, you'll automate these exact checks with React Testing Library so they run in milliseconds.
Your bookmark manager is working. Next up, you'll write automated tests that verify these same behaviors without ever opening a browser.
Write Your First Tests
Your bookmark manager works. You tested it by clicking around in the browser, adding titles, and watching the list update. That approach is fine for three features, but imagine repeating that same routine after every single code change.
React Testing Library lets you write automated tests that interact with your app the way a real user does. In this step, you'll write three tests that cover rendering, form submission, and input validation.
In this step, get ready to:
- Create a test file for your App component.
- Write tests that render, query, interact, and assert using RTL.
- Run your test suite and confirm all tests pass.
Create the test file
Every RTL test follows the same four-step workflow: render the component, query for an element, interact with it, and assert the result. Your first test will cover rendering and asserting.
- Create a new file called src/App.test.jsx in the same folder as App.jsx.
- Paste the following code into src/App.test.jsx:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import App from './App'
describe('Bookmark Manager', () => {
it('renders the heading and empty state', () => {
// Render the App component into a virtual DOM
render(<App />)
// Assert the heading is present by its accessible role
expect(
screen.getByRole('heading', { name: /bookmark manager/i })
).toBeInTheDocument()
// Assert the empty state message is visible
expect(screen.getByText(/no bookmarks yet/i)).toBeInTheDocument()
})
})
What does this code do?
render(<App />) mounts your component into a virtual DOM powered by jsdom. No browser needed.
screen.getByRole('heading') finds the element by its accessible role, the same way a screen reader would. screen.getByText() finds an element by its visible text content. Both throw an error if the element is missing, which makes the test fail immediately.
toBeInTheDocument() is a matcher provided by @testing-library/jest-dom. It asserts the element exists in the DOM.
- Save src/App.test.jsx.
- Confirm the first test passes by running this command:
npx vitest run
You should see 1 test passing with a green checkmark.
Seeing an error instead of a passing test?
- Check that your test file is at src/App.test.jsx, not inside src/test/. Vitest looks for files matching *.test.jsx in your src directory.
- Make sure globals: true is set in your vite.config.js test block. Without it, describe and expect are undefined.
- Verify that src/test/setup.js contains import '@testing-library/jest-dom'. This registers matchers like toBeInTheDocument().
Why is my first Vitest test failing?
Now that you have a passing test, you can add the remaining two. The next test simulates a real user interaction: typing into the input and clicking the button.
- In src/App.test.jsx, find the closing }) at the bottom of the file.
- Add this second test above that closing }):
it('adds a bookmark when the form is submitted', async () => {
render(<App />)
// Set up user-event for realistic browser interactions
const user = userEvent.setup()
// Type into the text input and click the submit button
await user.type(screen.getByRole('textbox'), 'React Docs')
await user.click(screen.getByRole('button', { name: /add bookmark/i }))
// Assert the bookmark text now appears on the page
expect(screen.getByText('React Docs')).toBeInTheDocument()
})
What does this test do?
userEvent.setup() creates a user instance that simulates realistic browser interactions like typing and clicking. Every interaction method is async, so you await each call.
getByRole('textbox') finds the text input by its accessible role. This works here because the form only has one textbox. The test then types "React Docs" into it and clicks the **Add Bookmark** button.
- Save src/App.test.jsx.
The third test verifies that clicking the button without typing anything does not add a bookmark. This tests your validation logic.
- Still in src/App.test.jsx, add this third test below the one you just added, still above the closing }):
it('does not add a bookmark with empty fields', async () => {
render(<App />)
const user = userEvent.setup()
// Click submit without typing anything
await user.click(screen.getByRole('button', { name: /add bookmark/i }))
// The empty state message should still be visible
expect(screen.getByText(/no bookmarks yet/i)).toBeInTheDocument()
})
What does this test do?
This test clicks the **Add Bookmark** button without typing into the input first. Your handleSubmit function checks for empty input and returns early, so the bookmark list stays empty. The test asserts that "No bookmarks yet" is still visible.
- Save src/App.test.jsx.
✔️ Awesome, I've got everything!
Great. Double check you've saved src/App.test.jsx before moving on.
ⓧ I'd like to double check the full code
Scroll up through your work and confirm these three sections are present in src/App.test.jsx, in order:
- The three import lines at the top (render, screen, userEvent, App) followed by the describe('Bookmark Manager') wrapper.
- Test 1: 'renders the heading and empty state' with getByRole('heading') and getByText(/no bookmarks yet/i).
- Test 2: 'adds a bookmark when the form is submitted' with userEvent.setup(), user.type(), and user.click().
- Test 3: 'does not add a bookmark with empty fields' that clicks the button without typing and asserts the empty state.
- A closing }) at the bottom that ends the describe block.
Run the tests
All three tests are written. Before you run them, think about what you expect to see. Do you think all three will pass on the first try?
- Run the full test suite by running this command:
npx vitest run
You should see 3 tests passing with green checkmarks.
How do these tests read?
Notice how each test reads like a description of what a user does, not what the code does internally. "Adds a bookmark when the form is submitted" describes the user's action and the expected result. The tests never reference state variables, handler functions, or component internals.
This is the core philosophy of React Testing Library. The more your tests resemble the way your software is used, the more confidence they give you.
Tests not all passing?
- Check that each test is inside the describe('Bookmark Manager', () => { ... }) block. Tests outside the block won't be grouped correctly.
- Make sure the second and third tests are marked async and each user.type() and user.click() call is preceded by await. Missing an await causes the assertion to run before the interaction finishes.
- Verify the file ends with exactly one }) to close the describe block. An extra or missing bracket causes a syntax error.
Why are my RTL tests failing?
You now have three automated tests that prove your app works from a user's perspective. Next up, you'll add a URL field to the form and discover what happens when your tests are not specific enough.
Add a URL Field and Fix Your Broken Tests
Three green checkmarks confirm your bookmark manager works. You can add bookmarks, verify the heading renders, and reject empty submissions. The tests pass, and they read like plain descriptions of what a user does.
But those tests have a hidden fragility. Right now, getByRole('textbox') works because there is exactly one text input. The moment you add a second input, that query will match both and the test will crash. This is exactly the kind of real-world breakage that React Testing Library is designed to surface.
In this step, get ready to:
- Add a URL input field to the bookmark manager form.
- Experience a test failure caused by ambiguous queries.
- Fix the tests using accessible name matching.
Add the URL field to the component
The bookmark manager needs a URL field so bookmarks actually link somewhere. You will replace the current App component with an expanded version that includes a URL input, favorites toggling, and filtering. The favorites and filtering features set up the next step where you will write tests for them.
- Open src/App.jsx in your editor.
- Select all the code and delete it.
- Paste this first section at the top of the empty file to set up the component state and form handler:
import { useState } from 'react'
import './App.css'
function App() {
const [bookmarks, setBookmarks] = useState([])
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [showFavorites, setShowFavorites] = useState(false)
// Add a new bookmark when both title and url are filled
function handleSubmit(e) {
e.preventDefault()
if (!title.trim() || !url.trim()) return
setBookmarks([
...bookmarks,
{ id: Date.now(), title: title.trim(), url: url.trim(), favorite: false },
])
setTitle('')
setUrl('')
}
What does this code do?
- Four state variables track the bookmark list, the two form inputs, and a favorites filter toggle.
- handleSubmit now requires both title and url to be non-empty before adding a bookmark. Each bookmark object gets a favorite flag set to false by default.
- Add this code directly below in the same file to define helper functions:
// Flip a bookmark's favorite flag
function toggleFavorite(id) {
setBookmarks(
bookmarks.map((b) =>
b.id === id ? { ...b, favorite: !b.favorite } : b
)
)
}
// Show all bookmarks or only favorites
const displayed = showFavorites
? bookmarks.filter((b) => b.favorite)
: bookmarks
What do these helpers do?
- toggleFavorite flips a single bookmark's favorite flag without mutating the array.
- displayed filters the bookmark list when the favorites toggle is active. The rest of the JSX renders displayed instead of the raw bookmarks array.
- Continue building the file by adding the JSX return statement with the form:
return (
<div className="app">
<h1>Bookmark Manager</h1>
{/* Form with labeled Title and URL inputs */}
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div>
<label htmlFor="url">URL</label>
<input
id="url"
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<button type="submit">Add Bookmark</button>
</form>
Why do labels matter here?
Each input has a <label> linked to it via the htmlFor attribute. This gives each input an accessible name that screen readers and testing tools can use. It is also the key to fixing the test failure you are about to see.
- Complete the component by adding the filter toggle, bookmark list, and closing tags below the form:
<div className="filter">
<label>
<input
type="checkbox"
checked={showFavorites}
onChange={() => setShowFavorites(!showFavorites)}
/>
Show Favorites Only
</label>
</div>
{displayed.length === 0 ? (
<p>{showFavorites ? 'No favorites yet' : 'No bookmarks yet'}</p>
) : (
<ul>
{/* Each bookmark shows title, URL link, and favorite toggle */}
{displayed.map((bookmark) => (
<li key={bookmark.id}>
<span>{bookmark.title}</span>
<a href={bookmark.url} target="_blank" rel="noreferrer">
{bookmark.url}
</a>
<button onClick={() => toggleFavorite(bookmark.id)}>
{bookmark.favorite ? 'Unfavorite' : 'Favorite'}
</button>
</li>
))}
</ul>
)}
</div>
)
}
What does this section render?
- A checkbox toggles the showFavorites state, which controls whether displayed shows all bookmarks or only favorites.
- Each bookmark renders its title as text, its URL as a clickable link, and a Favorite/Unfavorite toggle button.
- Add export default App as the very last line of the file, with a blank line above it.
- Save src/App.jsx.
- Switch to your browser at http://localhost:5173. If your dev server is not running, start it by running:
npm run dev
- You should see two input fields labeled Title and URL, plus a Show Favorites Only checkbox below the form.
- Type React Docs in the Title field and https://react.dev in the URL field, then click Add Bookmark. Both the title and a clickable URL link appear in the list.
Don't see the URL field?
- Make sure you saved src/App.jsx after pasting all four sections and the export default App line.
- Check that export default App appears at the very bottom of the file. Without it, Vite cannot import the component.
- If you see a syntax error in the terminal, double-check that the JSX sections are pasted in order with no missing closing braces.
Help me fix my App.jsx file
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before moving on.
ⓧ I'd like to double check the full code
Your src/App.jsx should contain these sections in order. Scroll up and confirm each one is present:
- Section 1: imports, four useState declarations, and the handleSubmit function.
- Section 2: toggleFavorite function and displayed variable.
- Section 3: return ( through </form>, including both the Title and URL input divs.
- Section 4: the filter checkbox div, the conditional bookmark list with <a> links and Favorite/Unfavorite buttons, and the closing </div>, ), }.
- A blank line followed by export default App at the very bottom.
Run the tests and watch them break
Your app works in the browser. But your tests were written for a form with one textbox. Now there are two. Before you run the tests, think about this: the existing tests use getByRole('textbox') to find the input. What do you think will happen when there are two elements with the role textbox?
- Run the test suite:
npx vitest run
The "adds a bookmark when the form is submitted" test fails. You see an error containing:
TestingLibraryElementError: Found multiple elements with the role "textbox"
This is exactly the outcome you predicted. getByRole('textbox') matched both the Title and URL inputs. When getByRole finds more than one match, it throws instead of guessing which one you meant. This protects you from tests that pass by accident.
Why does RTL throw instead of picking one?
If getByRole silently picked the first match, your test would pass now but test the wrong input after a future reorder. Throwing forces you to write a query specific enough that it can only match one element. That specificity is what makes your tests resilient to refactors.
Inspect the DOM with screen.debug()
To see exactly what RTL sees, temporarily add screen.debug() before the failing line in your test and run the tests again. It prints the rendered DOM to the terminal, showing both <input> elements with their associated labels. Remove screen.debug() after inspecting the output.
Fix the tests with accessible queries
The fix is to tell RTL which textbox you mean by matching its accessible name. The name option in getByRole matches against the text of the <label> associated with each input. Since you gave each input a label ("Title" and "URL"), you can target them precisely.
- In src/App.test.jsx, find the "adds a bookmark when the form is submitted" test.
- Replace the entire test (from it('adds a bookmark to its closing })) with this updated version:
it('adds a bookmark when the form is submitted', async () => {
render(<App />)
const user = userEvent.setup()
// Target each textbox by its accessible name from the <label>
await user.type(
screen.getByRole('textbox', { name: /title/i }),
'React Docs'
)
await user.type(
screen.getByRole('textbox', { name: /url/i }),
'https://react.dev'
)
await user.click(screen.getByRole('button', { name: /add bookmark/i }))
expect(screen.getByText('React Docs')).toBeInTheDocument()
expect(screen.getByText('https://react.dev')).toBeInTheDocument()
})
What changed in this test?
- getByRole('textbox') became getByRole('textbox', { name: /title/i }) and getByRole('textbox', { name: /url/i }). The name option matches the input's accessible name, which comes from the associated <label>.
- The test now types into both fields and asserts that both the title text and the URL text appear in the rendered output.
- Add this new test directly below the one you just updated, before the "does not add a bookmark with empty fields" test:
it('clears the form after adding a bookmark', async () => {
render(<App />)
const user = userEvent.setup()
await user.type(
screen.getByRole('textbox', { name: /title/i }),
'React Docs'
)
await user.type(
screen.getByRole('textbox', { name: /url/i }),
'https://react.dev'
)
await user.click(screen.getByRole('button', { name: /add bookmark/i }))
// Both inputs should be empty after submission
expect(screen.getByRole('textbox', { name: /title/i })).toHaveValue('')
expect(screen.getByRole('textbox', { name: /url/i })).toHaveValue('')
})
What does this new test verify?
After a successful submission, the form should reset so the user can add another bookmark immediately. toHaveValue('') asserts that each input's value is an empty string, confirming the state was cleared.
- Save src/App.test.jsx.
Not sure where to place the new test?
Your test file should have four tests in this order inside the describe block: "renders the heading and empty state" (unchanged), "adds a bookmark when the form is submitted" (updated), "clears the form after adding a bookmark" (new), and "does not add a bookmark with empty fields" (unchanged).
Help me fix my test file structure
✔️ Awesome, I've got everything!
Great. Double check you have saved src/App.test.jsx before running the tests.
ⓧ I'd like to double check the full code
Your src/App.test.jsx should have these four tests in order inside the describe('Bookmark Manager') block:
- "renders the heading and empty state" (unchanged from the previous step).
- "adds a bookmark when the form is submitted" (updated to use { name: /title/i } and { name: /url/i }, types into both fields, asserts both values appear).
- "clears the form after adding a bookmark" (new test asserting both inputs have toHaveValue('') after submission).
- "does not add a bookmark with empty fields" (unchanged from the previous step).
The imports at the top and the describe wrapper remain unchanged.
Time for the moment of truth. How many tests do you expect to pass now?
- Run the test suite:
npx vitest run
All 4 tests pass with green checkmarks. The accessible name queries make each test resilient. Even if you reorder the inputs or add more fields in the future, the tests will keep targeting the right element.
Your tests survived a real feature addition. You saw them break, understood why, and fixed them with queries that match the way a real user identifies form fields. Next up, you will add favorites and filtering to the app and write tests that verify elements disappear from the DOM.
Test Favorites and Filtering
Your tests now use accessible name queries that survive harmless refactors. That fixes the fragility problem, but forms are only part of the picture.
Real apps have multi-step interactions. A user clicks a button, the UI changes, they click something else, and certain items disappear. In this step, you'll write tests that simulate those flows and learn a new query that lets you assert an element is gone from the page.
In this step, get ready to:
- Verify the favorites and filtering features work in the browser.
- Write tests for toggling favorites and filtering the bookmark list.
- Use queryByText to assert that elements are absent from the DOM.
Verify favorites and filtering in the browser
Your App.jsx already has favorites and filtering features built in. Before writing tests for them, try them out manually so you know exactly what the tests need to verify.
- Start the dev server by running:
npm run dev
- Open http://localhost:5173 in your browser.
- Type React Docs into the Title field.
- Type https://react.dev into the URL field.
- Click Add Bookmark.
- Click the Favorite button next to the bookmark.
The button text changes from "Favorite" to "Unfavorite". Now check the Show Favorites Only checkbox.
- Add a second bookmark with any title and URL.
- Check the Show Favorites Only checkbox.
Only the favorited bookmark stays visible. The second bookmark disappears. Uncheck the box and both return.
How do favorites and filtering work in the code?
Each bookmark object has a favorite property that starts as false. The toggleFavorite function flips it when you click the button.
The displayed variable filters the list based on the showFavorites state. When the checkbox is checked, only bookmarks with favorite: true are shown.
- Press Ctrl+C in the terminal to stop the dev server.
Write the favorites toggle test
You just tested favorites manually. Now write an automated test that does the same thing. The test adds a bookmark, clicks the Favorite button, and checks that the button text changes to "Unfavorite".
- In src/App.test.jsx, find the closing }) of the does not add a bookmark with empty fields test. Add this new test below it, still inside the describe block:
it('toggles a bookmark as favorite', async () => {
render(<App />)
const user = userEvent.setup()
// Add a bookmark to work with
await user.type(
screen.getByRole('textbox', { name: /title/i }),
'React Docs'
)
await user.type(
screen.getByRole('textbox', { name: /url/i }),
'https://react.dev'
)
await user.click(screen.getByRole('button', { name: /add bookmark/i }))
// Click Favorite and verify the button changes
await user.click(screen.getByRole('button', { name: /favorite/i }))
expect(
screen.getByRole('button', { name: /unfavorite/i })
).toBeInTheDocument()
})
What does this test do?
This test simulates a two-step user interaction. First it adds a bookmark (the setup), then it clicks the Favorite button and checks the result.
The assertion queries for a button named "unfavorite" instead of checking internal state. If the button text changed, the toggle worked. This is user-centric testing in action.
- Save src/App.test.jsx.
- Run the tests to confirm the new one passes:
npx vitest run
You should see 5 tests passing. The new "toggles a bookmark as favorite" test joins the original four.
Test failing with "Unable to find role"?
Make sure the new test is inside the describe block, not after its closing }). Check that you have the render(<App />) call at the top of the test.
Also verify that the regex /favorite/i in getByRole matches the button text in your App.jsx.
Help me debug my favorites toggle test.
Write the filter test
The filter test is the most complex test in the suite. It simulates a multi-step workflow: add two bookmarks, favorite one, enable the filter, and then check that only the favorited bookmark is visible.
What is queryByText?
When you want to assert that something is not on the page, you cannot use getByText. If the element is missing, getByText throws an error and your test crashes before the assertion even runs.
Instead, use queryByText. It returns null when the element is missing, which lets you write expect(screen.queryByText('Vite')).not.toBeInTheDocument() without crashing.
This test is longer than the previous ones, so you'll build it in two parts. Start by adding the setup portion below the favorites toggle test, still inside the describe block.
- Add the first half of the filter test, which sets up the test data by adding two bookmarks:
it('filters bookmarks to show only favorites', async () => {
render(<App />)
const user = userEvent.setup()
// Add the first bookmark
await user.type(
screen.getByRole('textbox', { name: /title/i }),
'React Docs'
)
await user.type(
screen.getByRole('textbox', { name: /url/i }),
'https://react.dev'
)
await user.click(screen.getByRole('button', { name: /add bookmark/i }))
// Add a second bookmark
await user.type(
screen.getByRole('textbox', { name: /title/i }),
'Vite'
)
await user.type(
screen.getByRole('textbox', { name: /url/i }),
'https://vite.dev'
)
await user.click(screen.getByRole('button', { name: /add bookmark/i }))
Why add two bookmarks?
Filtering only makes sense with multiple items. One bookmark gets favorited and the other does not. After enabling the filter, the test can verify the favorited one stays and the other disappears.
- Directly below the last line you just added (still inside the same test function), add the interaction and assertion code:
// Favorite only the first bookmark
const favoriteButtons = screen.getAllByRole('button', {
name: /favorite/i,
})
await user.click(favoriteButtons[0])
// Enable the filter
await user.click(
screen.getByRole('checkbox', { name: /show favorites only/i })
)
// The favorited bookmark stays, the other is gone
expect(screen.getByText('React Docs')).toBeInTheDocument()
expect(screen.queryByText('Vite')).not.toBeInTheDocument()
})
What does this code do?
- getAllByRole returns an array of all matching elements. Since both bookmarks have a "Favorite" button, the array has two entries. favoriteButtons[0] targets the first one.
- getByRole('checkbox', { name: /show favorites only/i }) finds the checkbox by its label text, just like textboxes use their labels.
- getByText('React Docs') confirms the favorited bookmark is still visible.
- queryByText('Vite') returns null because the non-favorited bookmark was filtered out. The .not.toBeInTheDocument() matcher confirms it.
- Save src/App.test.jsx.
Seeing a syntax error?
Make sure the closing }) of the filter test sits on its own line, inside the describe block. The two code chunks above form one complete test function. If you see unexpected token errors, check that nothing is missing between the two chunks.
Help me fix a syntax error in my filter test.
Run all six tests
You now have six tests covering rendering, form submission, validation, input clearing, favorites, and filtering. Before you run them, think about what you expect to see.
- Do you expect all six tests to pass, or will any fail? Why?
- Run the full test suite:
npx vitest run
You should see 6 tests passing with green checkmarks. Every feature in your bookmark manager now has an automated test backing it up.
One or more tests failing?
Read the error message carefully. If it says "Found multiple elements", check that you used getAllByRole (not getByRole) for the Favorite buttons in the filter test.
If you see "received value must be an HTMLElement", double-check you used queryByText (not getByText) for the absence assertion.
Help me debug my failing tests.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/App.test.jsx.
ⓧ I'd like to double check the full code
Scroll up through your src/App.test.jsx and confirm these six tests appear in order inside the describe('Bookmark Manager') block:
- renders the heading and empty state
- adds a bookmark when the form is submitted
- clears the form after adding a bookmark
- does not add a bookmark with empty fields
- toggles a bookmark as favorite
- filters bookmarks to show only favorites
Also verify the file starts with these three imports:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import App from './App'
Secret mission
Add Delete with Confirmation
Your bookmark manager lets you add and favorite bookmarks, but there is no way to remove them. In this mission, you will add a Delete button with a two-step confirmation flow and write three new tests that cover every outcome: showing the confirmation, confirming the delete, and cancelling.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files, or delete them entirely.
Resources you used:
- The bookmark-manager project folder on your Desktop (includes all source code, tests, and node_modules).
Keep everything running
No action needed. Choose this if you want to keep experimenting with your bookmark manager app or add more tests.
- Your project files stay in the bookmark-manager folder on your Desktop.
- You can restart the dev server anytime with npm run dev and re-run your tests with npx vitest run.
- Node.js remains installed on your machine. It was already installed before this project.
Pause - I'll come back to this later
Stop any running processes to free up memory. Your files stay on disk so you can pick up where you left off.
- If the Vite dev server is still running in your terminal, press Ctrl+C to stop it.
- Your project files remain in the bookmark-manager folder on your Desktop, ready for when you come back.
Delete - I don't want to use this again
Remove the entire project folder to free up disk space. This deletes all source code, tests, and installed packages.
- If the Vite dev server is still running, press Ctrl+C to stop it.
- Delete the project folder by running these commands in your terminal:
macOS
cd ~/Desktop
rm -rf bookmark-manager
Windows
cd ~\Desktop
rmdir /s /q bookmark-manager
- Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see bookmark-manager in the listing.
Nice Work!
Nice Work!
Nice work! You just built a fully tested bookmark manager app using React Testing Library and Vitest.
You've learned how to:
- Built a bookmark manager React app and set up a complete testing stack with Vite, Vitest, and React Testing Library from scratch.
- Wrote user-centric tests using render, screen, getByRole, getByText, and userEvent that verify features the way a real user interacts with them.
- Fixed a brittle test that broke when a second input field was added, and learned to use accessible name queries like getByRole('textbox', { name: /title/i }) to write resilient tests.
- Secret Mission: Completed an optional challenge to push your testing skills further.
Ready to quiz yourself?