Yesterday I shared how to set up a modern testing environment using Vitest + React Testing Library.
Today — here’s what that setup looks like in action.
Let’s walk through the classic Red → Green → Refactor TDD loop using a simple “Add Task” feature
🟥 Step 1: Write a Failing Test (Red)
Before writing any UI, you define what the user should be able to do.
In App.test.tsx:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import App from "./App";
test("user can add a new task", async () => {
render(<App />);
const input = screen.getByRole("textbox", { name: /add task/i });
const button = screen.getByRole("button", { name: /add/i });
await userEvent.type(input, "Learn TDD");
await userEvent.click(button);
// ✅ Expectation: the new task appears in the list
expect(screen.getByText("Learn TDD")).toBeInTheDocument();
});
🧠 What's happening:
- You describe behavior, not implementation.
- You query by what a user would see.
- You expect the UI to update.
This test will fail at first — because you haven't written any logic to handle "adding a task."
That's Red in the Red → Green → Refactor cycle.
🟩 Step 2: Implement Just Enough Code (Green)
Now write the minimal code to make the test pass.
In App.tsx:
import React from "react";
function App() {
const [tasks, setTasks] = React.useState<string[]>([]);
const [taskName, setTaskName] = React.useState("");
const onAddTask = () => {
if (!taskName.trim()) return;
setTasks([...tasks, taskName]);
setTaskName("");
};
return (
<div>
<h1>Tasks</h1>
<label htmlFor="task-input">Add Task:</label>
<input
id="task-input"
value={taskName}
onChange={(e) => setTaskName(e.target.value)}
/>
<button onClick={onAddTask}>Add</button>
<ul>
{tasks.map((t, index) => (
<li key={index}>{t}</li>
))}
</ul>
</div>
);
}
export default App;
🧠 Why this works:
- The test types into the textbox and clicks "Add."
- The component updates its
tasksstate → React re-renders → the new task appears. - The test now passes.
That's Green.
🟦 Step 3: Refactor (Optional)
Now that it's passing, you can safely refactor without breaking behavior.
Example improvement:
type Task = { id: number; title: string; isCompleted: boolean };
const [tasks, setTasks] = React.useState<Task[]>([]);
You can change the implementation details freely — your test still passes because it only checks user-visible output, not internal state.
That's Refactor.
🔁 TDD Cycle Recap
| Phase | What You Do | What Happens |
|---|---|---|
| 🟥 Red | Write a test that fails | Defines the expected behavior |
| 🟩 Green | Write minimal code to pass | Implement only what's necessary |
| 🟦 Refactor | Improve your code | Clean up without breaking tests |
💡 Why This Matters
This TDD process ensures:
- You never write code without a clear purpose.
- You only build what's needed to make the test pass.
- Your tests describe how the app behaves, not how it's implemented.
- You gain confidence that refactoring won't break anything visible to users.
🧭 Summary
| Concept | Role |
|---|---|
| React Testing Library | Lets you test UI from the user's perspective |
| Virtual DOM | In-memory structure for rendering during tests |
| Queries (screen.getBy*) | Mimic user interactions (click, type, etc.) |
| TDD | Forces small, confident, behavior-driven development steps |
| jest-dom | Makes assertions human-readable (toBeInTheDocument) |
🗒️ Remember
When you test what users see (not what you write in state), your tests become stable, your code becomes safer to refactor, and you start thinking like a product developer, not just an engineer.
Top comments (0)