Introduction
When I first shared my article on building a custom React router, I intentionally kept things simple: we manually tested our implementation and provided video or GIF footage as evidence that the router worked as expected. Automated testing wasn't a priority in that initial build; our goal was to focus on building the core functionality, learning, and iterating.
So why am I taking the time to write a follow-up article about testing the router that we previously built? It's a good question and one that touches on an important aspect of modern development practices.
Why Automated Testing Matters
In smaller, personal projects, it's easy to lean on manual testing or simple visual inspections to confirm that things work. However, in a larger organisation, or even in a growing codebase, manual testing alone quickly becomes insufficient. When you're working in a fast-paced environment with many contributors and moving parts, even a small change to a fundamental component like RouterLink can have ripple effects throughout the application. Without a solid suite of tests, you might find yourself second-guessing every change or, worse, introducing regressions that only surface much later.
Automated tests can significantly reduce the scope of uncertainty around these kinds of changes. With well-structured tests in place, you can make adjustments to your router and know that if the base tests pass, the core functionality remains intact. And if a downstream component fails, you can quickly identify the scope of the breakage and address it without having to manually click through the entire application to find the problem.
Beyond practical considerations, writing tests can also be an enjoyable mental exercise. It challenges you to think critically about edge cases, lifecycle management, and user interactions, especially when dealing with hooks and lazy-loaded components. In this article, we'll explore how to test the router hook, the RouterLink component, and the RouterRoot component in our custom router setup. Along the way, we'll discuss best practices and share patterns that can help you build confidence in your code while enabling faster, more reliable development.
Let's dive in!
Testing Environment Setup
We'll continue from our Vite TypeScript project setup. I've chose to use vitest
as our main testing tool and have it complemented by Testing Library. This is mainly due to the ease of integration provided by vitest
with Vite. jest
could still work but with a little more configuration. In any case, we would still need Testing Library as we'll be using renderHook
and render
, both of which don't seem to be available on vitest
.
To set up the environment, we'll just need a few more tools:
npm install -D vitest @testing-library/react @testing-library/dom @types/react @types/react-dom jsdom
We'll also need to configure vite.config.ts
file as well
/// <reference types="vitest/config" />
import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // or 'jsdom', 'node'
},
})
Testing the Router Hook
The router hook is the core piece we need to ensure works correctly. The tests for this are the most important, so we want to be thorough but concise. There are two main functions we want to test, and aside from unsubscribing from the store, there isn't much additional cleanup to worry about since we're not using any event listeners here. We just need to make sure there are no unintended side effects when another hook is unmounted.
First, we'll do a basic sanity check to ensure that the navigate function updates the path correctly. Since we intentionally added a setTimeout in our router hook, we'll need to use fake timers in our tests.
...
describe('useRouterHook', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('returns the current path', () => {
const {result} = renderHook(() => useRouterHook());
expect(result.current.data.path).toBe('/');
});
test('updates when navigate() is called', () => {
const {result} = renderHook(() => useRouterHook());
act(() => {
result.current.navigate('/about');
vi.runAllTimers();
});
expect(result.current.data.path).toBe('/about');
});
});
Next, we'll consider what happens if multiple components on the same page create their own instance of the hook. Would the navigate function correctly update the state in both components?
...
test('updates all mounted hook instances', () => {
const hookA = renderHook(() => useRouterHook());
const hookB = renderHook(() => useRouterHook());
act(() => {
hookB.result.current.navigate('/about');
vi.runAllTimers();
});
expect(hookA.result.current.data.path).toBe('/about');
expect(hookB.result.current.data.path).toBe('/about');
});
...
Finally, we'll check that our navigate function and state continue to work even after one of the hooks has been unmounted.
...
test('unsubscribes cleanly upon unmount', () => {
const hookA = renderHook(() => useRouterHook());
const hookB = renderHook(() => useRouterHook());
// Unmount hookB and navigate again
hookB.unmount();
act(() => {
hookA.result.current.navigate('/about');
vi.runAllTimers();
});
expect(hookA.result.current.data.path).toBe('/about');
});
...
You might notice that some of these test cases weren't covered in our manual testing. Some things are easier to test manually, while others, like ensuring proper cleanup on unmount, are easier to catch through automated testing.
Testing the Router Link Component
The RouterLink component is a very simple piece of the puzzle, but it also serves as our entry point to integration testing. It ensures that components can use the router hook to navigate programmatically.
...
const TEST_ROUTER_LINK_ABOUT_PAGE_TEST_ID = 'TEST_ROUTER_LINK_ABOUT_PAGE_TEST_ID';
describe('router-link', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('renders router link with clickable navigation', async () => {
const {result} = renderHook(() => useRouterHook());
render(<RouterLink data-testid={TEST_ROUTER_LINK_ABOUT_PAGE_TEST_ID} to={'/about'}>About Page</RouterLink>);
expect(result.current.data.path).toBe('/');
const routerLinkAboutPage = screen.getByTestId(TEST_ROUTER_LINK_ABOUT_PAGE_TEST_ID);
await act(async () => {
fireEvent.click(routerLinkAboutPage);
vi.runAllTimers();
});
expect(result.current.data.path).toBe('/about');
});
});
Testing the Router Root Component
The RouterRoot component has several important responsibilities:
- Listening to the router state provided by the hook and reacting to changes
- Listening to the browser's popstate event and responding based on the current pathname
- Rendering the appropriate child component
- Displaying a "loading" state while the new component is being loaded
We don't need to test scenarios where a route doesn't exist because, by design, our router simply returns null instead of displaying a 404 message. Because of the router's design, we can also assume there will only ever be one RouterRoot, which helps reduce the scope of our tests.
We'll start by writing a test that checks that the router loads without throwing an error or displaying a loading message when there are no routes defined. We'll also need to make some changes to router root to add the test ID.
export const CHILD_PAGE_CONTENT_ID = 'CHILD_PAGE_CONTENT_ID';
export const CHILD_PAGE_CONTENT_DATA = 'some content data here';
export const CHILD_SECOND_PAGE_CONTENT_ID = 'CHILD_SECOND_PAGE_CONTENT_ID';
export const CHILD_SECOND_PAGE_CONTENT_DATA = 'some other stuff here';
export const LOADING_INFO_COMPONENT_ID = 'LOADING_INFO_COMPONENT_ID';
...
return (
<div>
{isPending ? <div data-testid={LOADING_INFO_COMPONENT_ID}>Loading...</div> : null}
{readyElement}
</div>
);
...
...
test('renders with no routes', async () => {
await act(async () => {
render(<RouterRoot routes={{}}/>);
});
const element = screen.queryByTestId(LOADING_INFO_COMPONENT_ID);
expect(element).toBeNull();
});
Next, we'll check that the loading text is displayed when a route component is loading.
...
test('renders loading text when initialising', async () => {
await act(async () => {
render(<RouterRoot routes={testRoutes}/>);
});
const element = screen.queryByTestId(LOADING_INFO_COMPONENT_ID);
expect(element).toBeDefined();
});
Then, we'll confirm that the correct content renders once the component has loaded.
...
test('renders with routes with content', async () => {
await act(async () => {
render(<RouterRoot routes={testRoutes}/>);
});
const element = await screen.findByTestId(CHILD_PAGE_CONTENT_ID);
expect(element).toBeDefined();
expect(element.innerHTML).toBe(CHILD_PAGE_CONTENT_DATA);
});
Finally, we'll verify that content updates properly when navigating to a new page.
...
test('renders updated content with route changes', async () => {
const {result} = renderHook(() => useRouterHook());
await act(async () => {
render(<RouterRoot routes={testRoutes}/>);
});
vi.useFakeTimers();
await act(async () => {
result.current.navigate('/other-page');
vi.runAllTimers();
});
vi.useRealTimers();
const element = await screen.findByTestId(CHILD_SECOND_PAGE_CONTENT_ID);
expect(element).toBeDefined();
expect(element.innerHTML).toBe(CHILD_SECOND_PAGE_CONTENT_DATA);
});
We can take things a step further by checking whether certain state cleanups occur properly when the component is unmounted. This helps demonstrate that the component is pure (though, of course, tests alone can't guarantee that). This might involve mocking certain functions to observe event listener registration and cleanup.
We'll begin by ensuring that event listeners are correctly added when the component mounts and removed when it unmounts.
...
describe('router-root with event spying', () => {
let addSpy: MockInstance;
let removeSpy: MockInstance;
let originalAdd: typeof window.addEventListener;
let originalRemove: typeof window.removeEventListener;
let capturedHandler: EventListenerOrEventListenerObject | null = null;
beforeEach(() => {
originalAdd = window.addEventListener.bind(window);
originalRemove = window.removeEventListener.bind(window);
addSpy = vi.spyOn(window, 'addEventListener')
.mockImplementation(
(
type,
listener,
options
) => {
if (type === 'popstate') {
capturedHandler = listener;
}
originalAdd(type, listener, options);
});
removeSpy = vi.spyOn(window, 'removeEventListener')
.mockImplementation(
(
type,
listener,
options
) => {
originalRemove(type, listener, options);
}
);
vi.useFakeTimers();
});
afterEach(() => {
addSpy.mockRestore();
removeSpy.mockRestore();
capturedHandler = null;
vi.useRealTimers();
});
test('registers and then removes the popstate listener on unmount', async () => {
let componentRenderResult: RenderResult;
await act(async () => {
componentRenderResult = render(
<RouterRoot routes={{}}/>
);
});
expect(addSpy).toHaveBeenCalledWith('popstate', capturedHandler);
await act(async () => {
componentRenderResult.unmount();
});
expect(removeSpy).toHaveBeenCalledWith('popstate', capturedHandler);
});
});
Then, we'll check that the router store state does not continue to update after the RouterRoot component has been unmounted.
...
test('should not be listening after unmount', async () => {
let componentRenderResult: RenderResult;
await act(async () => {
componentRenderResult = render(
<RouterRoot routes={{}}/>
);
});
const {result} = renderHook(() => useRouterHook());
expect(result.current.data.path).toBe('/');
await act(async () => {
window.history.pushState(null, '', '/about');
fireEvent.popState(window, {});
vi.runAllTimers();
});
expect(result.current.data.path).toBe('/about');
await act(async () => {
componentRenderResult.unmount();
});
await act(async () => {
window.history.pushState(null, '', '/');
fireEvent.popState(window, {});
vi.runAllTimers();
});
expect(result.current.data.path).toBe('/about');
});
...
Limitations and Tradeoffs
No End-to-End Testing
We did not add end-to-end tests that simulate user flows spanning multiple components and routes. For this relatively straightforward router, E2E testing may feel like overkill as there are no multi-layered workflows (like checkout or search flows) that require it. However, in more complex applications or user journeys, E2E tests can catch integration issues that unit and integration tests might miss. There are also cases in which things are easier to test with E2E than to use integration or unit tests. For example, if there are many components with sub-components, you could use an E2E to cover most of these components' use cases rather than writing many unit tests to cover them individually.
Reliance on Mocks
Using mocks helps isolate our components and keeps tests fast, but it also carries its own trade-offs. When we mock parts of the router (for example, event listeners), there’s a chance that real-world behavior is obscured. Furthermore, if we change the underlying implementation, such as removing or changing how event listeners are registered, we'll need to update our mocks accordingly. Although we mitigate some of this by calling through to the original functions in our tests, there's still extra maintenance whenever the implementation changes.
No Performance Testing
Our test suite focuses exclusively on correctness and does not include performance profiling. In larger applications, especially those with many routes or heavy client-side navigation, performance can become a bottleneck. Adding benchmarks or stress tests could reveal areas to optimize (for example, lazy-loading strategies or avoiding unnecessary re-renders). However, most optimisation cases should already have been covered by the new React compiler. The main point of these tests would then be for catching potential performance regressions.
Conclusion
I hope that by walking through my thought process and these test cases, you've gained some insight into why and how to test a custom router effectively. Testing isn't just about preventing bugs, it's about building confidence in your code and enabling faster, safer iteration. The full code repository is available here: https://github.com/GIANTCRAB/your-own-react-router.
Based on your experience and knowledge, are there anything that you think should be added or improved upon? Leave your suggestions or comments below! Or if you have any questions, please feel free to ask. Collaboration is how our community grows. Happy coding!
Top comments (0)