Welcome back to our ongoing Design Challenge series, where we tackle tricky design patterns and break them down into practical solutions. In a previous challenge, we introduced the Headless Component Pattern—a way to encapsulate logic in a “container” component without forcing a specific UI on our consumers. This pattern keeps our code clean and modular, but it also raises a big question:
How do we test a component that has no direct UI to click on?
That’s the challenge we’ll tackle today with our ApprovalService
example.
The Problem
We built ApprovalService
to handle approvals—fetching data, updating status, etc.—but it renders no UI elements. If there’s nothing to click, how can we validate user-driven interactions?
To complicate matters, we don’t want to hit a real backend when we test. We need a strategy that:
Simulates UI so we can mimic real user behavior (like clicking an “Approve” button).
Stubs or mocks network requests so we don’t make actual fetch calls.
In short, we need to ensure ApprovalService
is still thoroughly tested from an end user’s perspective, without chaining ourselves to real endpoints or complex parent components.
The Purpose: Why Test a Headless Component?
One major goal of the Headless Component Pattern is reusability—we can drop ApprovalService
into any UI context without worrying about re-implementing the approval logic. But reusability is moot if it’s not well-tested.
Prevent Regressions: Make sure we aren’t breaking fundamental logic when we or other teams add new UIs on top of
ApprovalService
.Encourage Decoupling: Validating the logic in isolation forces us to keep
ApprovalService
free from UI-specific dependencies.Streamline Collaboration: Frontend and backend teams can work in parallel; we just stub or mock requests so
ApprovalService
doesn’t rely on an actual service endpoint.
With the “why” in mind, let’s jump into how.
Step 1: Harness a Mock User Interface
Since ApprovalService
itself doesn’t render UI, we’ll provide a minimal interface in the test file—a “mock panel” with just two buttons (“Approve” and “Decline”). This test harness simulates the user’s perspective.
const MockApprovalPanel = ({
isDone,
handleApprove,
handleDecline,
}: {
isDone: boolean;
handleApprove: () => void;
handleDecline: () => void;
}) => {
if (isDone) {
return <div>Approval is resolved</div>;
}
return (
<div>
<button onClick={handleApprove}>Approve</button>
<button onClick={handleDecline}>Decline</button>
</div>
);
};
Then we wrap this mock panel around our ApprovalService
:
it("renders resolved message when done", async () => {
render(
<ApprovalService id="completed-approval">
{({ isDone, handleApprove, handleDecline }) => (
<MockApprovalPanel
isDone={isDone}
handleApprove={handleApprove}
handleDecline={handleDecline}
/>
)}
</ApprovalService>
);
// Now we can test user interactions or final states.
// expect(screen.getByText("Approval is resolved")).toBeInTheDocument();
});
With this mock UI, we can easily confirm:
When
ApprovalService
determines an approval is already complete, we display the correct message.Clicking “Approve” or “Decline” triggers the right events.
Step 2: Stubbing the Backend Service
Option A: Mock fetch
If ApprovalService
uses fetch
internally, you can provide a fake fetch
in the test:
const handleApprove = () => {
fetch(`/rest/approval/${id}/approve`, { method: "POST" })
.then((r) => r.json())
.then((data) => setDone(data.isDone));
};
By returning predefined data, we confirm that user clicks trigger the correct endpoint—without hitting a real server. However, this test is tightly coupled to fetch
. If you later switch to axios
, you’d have to adjust your mock.
Option B: Use msw
or mirage.js
Tools like MSW or Mirage.js intercept all network calls in a test environment and return pre-configured responses. Your test then remains agnostic about how ApprovalService
actually fetches data. Here’s a sample handler with MSW:
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/rest/approval/:id", (res) => {
const id = res.params.id;
if (id === "completed-approval") {
return HttpResponse.json({ id, isDone: true });
}
return HttpResponse.json({ id, isDone: false });
}),
// ...
];
Then, in a global test setup:
import { setupServer } from 'msw/node'
import { handlers } from './src/mocks/handlers';
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
And our test code becomes straightforward:
it("renders resolved message when done", async () => {
render(
<ApprovalService id="completed-approval">
{({ isDone, handleApprove, handleDecline }) => (
<MockApprovalPanel
isDone={isDone}
handleApprove={handleApprove}
handleDecline={handleDecline}
/>
)}
</ApprovalService>
);
// If the server returns "isDone": true for 'completed-approval'
expect(await screen.findByText("Approval is resolved")).toBeVisible();
});
Or testing the “approve” interaction:
it("accepts the approval", async () => {
render(
<ApprovalService id="incompleted-approval">
{({ isDone, handleApprove, handleDecline }) => (
<MockApprovalPanel
isDone={isDone}
handleApprove={handleApprove}
handleDecline={handleDecline}
/>
)}
</ApprovalService>
);
const approveButton = screen.getByRole("button", { name: "Approve" });
await userEvent.click(approveButton);
expect(await screen.findByText("Approval is resolved")).toBeVisible();
});
Bonus: Testing the “Real” UI
In Design Challenge 002, we split out the render props from ApprovalService
into a full UI component—ApprovalPanel
. That begs the question: should we test ApprovalPanel
directly?
Option 1: Extract & Test the UI in Isolation
Simply move all the UI logic into its own component:
const ActualApprovalPanel = ({ isDone, handleApprove, handleDecline }) => {
if (isDone) {
return <div>The request has been resolved</div>;
}
return (
<div className={classes.container}>
<h2>This request requires your approval</h2>
<div className={classes.buttonsContainer}>
<Button onClick={handleApprove} appearance="Primary">
Approve
</Button>
<Button onClick={handleDecline}>Decline</Button>
</div>
</div>
);
};
const ApprovalPanel = ({ id }) => {
return (
<ApprovalService id={id}>
{({ isDone, handleApprove, handleDecline }) => (
<ActualApprovalPanel
isDone={isDone}
handleApprove={handleApprove}
handleDecline={handleDecline}
/>
)}
</ApprovalService>
);
};
Now, testing ActualApprovalPanel
is a simple matter of verifying buttons and text. You don’t even need to worry about the network call unless you’re doing a more integrated test with ApprovalService
.
Option 2: Mock ApprovalService
You could also mock out the entire ApprovalService
in your test to confirm your UI’s rendering logic:
vi.mock("../ApprovalService", () => ({
ApprovalService: ({ children }) => {
const mockProps = {
isDone: false,
handleApprove: mockApprove,
handleDecline: mockDecline,
};
return children(mockProps);
},
}));
Then you confirm the button triggers the mock:
it("handles approve correctly", async () => {
render(<ApprovalPanel id="approval-id" />);
const approveButton = screen.getByRole("button", { name: "Approve" });
await userEvent.click(approveButton);
expect(mockApprove).toHaveBeenCalled();
});
However, extracting and testing the UI in isolation is usually cleaner. It avoids deep mocking and keeps tests focused on either the logic or the presentation, rather than both at once.
Conclusion: Why This Matters
Headless components can dramatically improve reusability and decoupling in your React apps. But the big takeaway of this design challenge is:
Separating Logic from UI doesn’t mean you lose the ability to test user flows.
With a mock UI (or a minimal harness), you can replicate interactions just as if you had a real UI.
Stubbing Network Calls is crucial for fast, reliable tests. Whether you use a simple mock of
fetch
, or a more comprehensive tool like MSW, you’ll keep your tests hermetic and easy to maintain.
As always, I’d love to hear how you’re approaching headless components and their tests. Drop a comment or reply to this newsletter with your insights!
Stay tuned for more Design Challenges, where we’ll continue unravelling tricky patterns and turning them into clear, testable, and maintainable solutions.
Happy coding!
— Juntao Qiu