As a frontend developer working with Remix v2 and React Router v7, setting up a testing environment can be tricky. After battling through configuration issues and TypeScript conflicts, I've created this guide to solve three specific challenges:
- Configuring Vitest to work with Remix's path aliases
- Properly mocking React Router's useOutletContext hook with TypeScript support
- Handling window.matchMedia errors when testing UI components (especially with libraries like Mantine)
Let's dive into a complete solution that addresses all these issues!
Required Dependencies
First, install these packages:
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
Optional but recommended:
npm install -D @vitest/ui @vitest/coverage-v8
Package.json Scripts
Add these testing scripts:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"coverage": "vitest run --coverage"
}
}
Configuration Files
Create a separate vitest.config.ts
in your project root:
import path from 'path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['**/*.{test,spec}.{ts,tsx}']
},
resolve: {
alias: {
'~': path.resolve(__dirname, './app')
}
}
});
Create vitest.setup.ts
:
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
// Mock window.matchMedia for required components
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
Test Organization
For a feature-based folder structure (remix-flat-routes):
app/routes/feature+/
├── __tests__/
│ ├── routes/
│ │ └── index.test.tsx
│ ├── components/
│ │ └── FeatureCard.test.tsx
│ └── hooks/
│ └── useFeature.test.ts
This structure offers several advantages:
- Tests are co-located with the features they test, making them easier to find
- The separation between route, component, and hook tests clarifies the test's purpose
- It follows the same organization as your source code, maintaining consistency
- When using flat routes, this structure ensures tests are excluded from the route generation
Mocking React Router's useOutletContext
One of the trickiest parts of testing Remix components is mocking the useOutletContext
hook:
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { MantineProvider } from '@mantine/core';
import { OutletContext } from '~/types';
import YourComponent from '../_components/YourComponent';
// Mock useOutletContext with TypeScript type safety
// replace 'react-router' with '@remix-run/react' if using remix
vi.mock('react-router', () => ({
// Preserve all original exports from react-router
...vi.importActual('react-router'), // or '@remix-run/react'
// Override only the useOutletContext function
// The 'satisfies' operator ensures type safety without changing the return type
useOutletContext: () => ({ language: 'en' } satisfies Partial<OutletContext>) // Using Partial allows us to mock only what we need
}));
describe('YourComponent', () => {
it('should render correctly', () => {
render(<YourComponent />);
// Your assertions here
});
});
A Complete Component Test Example
Here's a real-world example testing a component that displays course metadata:
import { MantineProvider } from '@mantine/core';
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { OutletContext } from '~/types';
import CourseMetaDataSummary from '../_components/CourseMetaDataSummary';
vi.mock('react-router', () => ({
...vi.importActual('react-router'),
useOutletContext: () => ({ language: 'en' } satisfies Partial<OutletContext>)
}));
describe('CourseMetaDataSummary', () => {
it('should render lessons count as 8', () => {
render(
<MantineProvider>
<CourseMetaDataSummary lessons_count={8} members_count={20} />
</MantineProvider>
);
const lessonsCount = screen.getByText('8 Lessons');
expect(lessonsCount).toBeInTheDocument();
});
it('should render members count as 20', () => {
render(
<MantineProvider>
<CourseMetaDataSummary lessons_count={8} members_count={20} />
</MantineProvider>
);
const membersCount = screen.getByText('20 Members');
expect(membersCount).toBeInTheDocument();
});
it('should render 1 lesson and 1 member', () => {
render(
<MantineProvider>
<CourseMetaDataSummary lessons_count={1} members_count={1} />
</MantineProvider>
);
const lessonsCount = screen.getByText('1 Lesson');
const membersCount = screen.getByText('1 Member');
expect(lessonsCount).toBeInTheDocument();
expect(membersCount).toBeInTheDocument();
});
});
Common Gotchas & Solutions
-
Path resolution issues
- Add the
resolve.alias
configuration to match your tsconfig paths
- Add the
-
Mocking useOutletContext
- Use
vi.importActual
to preserve the rest of the module - Use TypeScript's
satisfies
operator for type safety
- Use
-
Components requiring
window.matchMedia
- Add the matchMedia mock to
vitest.setup.ts
- Add the matchMedia mock to
Top comments (0)