Testing Shared Components Across Multiple Monorepo Apps
Shared components must work consistently across all apps using them. Testing is how you ensure that a Button update does not break your marketing site, dashboard, and admin app. This article covers unit testing components with Vitest and React Testing Library, integration testing across workspaces, visual regression testing, and setting up shared test utilities and fixtures.
Setting Up Component Testing Infrastructure
Start by adding test dependencies to your component library workspace:
cd packages/ui-library
pnpm add -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
Create a vitest.config.ts:
// packages/ui-library/vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.stories.tsx', 'src/**/*.test.tsx'],
},
},
});
Create a test setup file:
// packages/ui-library/src/test/setup.ts
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
afterEach(() => {
cleanup();
});
Add a test script to package.json:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
Writing Unit Tests for Components
Test each component in isolation. Create a test file alongside the component:
// packages/ui-library/src/components/Button.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, userEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('renders with text content', () => {
render(<Button variant="primary">Click Me</Button>);
expect(screen.getByText('Click Me')).toBeInTheDocument();
});
it('handles click events', async () => {
const handleClick = vi.fn();
render(
<Button variant="primary" onClick={handleClick}>
Click
</Button>
);
await userEvent.click(screen.getByText('Click'));
expect(handleClick).toHaveBeenCalledOnce();
});
it('applies correct variant class', () => {
const { container } = render(
<Button variant="danger">Delete</Button>
);
const button = container.querySelector('button');
expect(button).toHaveClass('bg-red-600');
});
it('applies size class correctly', () => {
const { container } = render(
<Button variant="primary" size="lg">
Large Button
</Button>
);
const button = container.querySelector('button');
expect(button).toHaveClass('px-6 py-3 text-lg');
});
it('is accessible with keyboard navigation', async () => {
const handleClick = vi.fn();
render(
<Button variant="primary" onClick={handleClick}>
Click
</Button>
);
const button = screen.getByRole('button');
button.focus();
expect(button).toHaveFocus();
await userEvent.keyboard('{Enter}');
expect(handleClick).toHaveBeenCalledOnce();
});
});
Run tests:
pnpm test
This runs all tests in the library and shows coverage.
Testing Hooks in Isolation
Test custom hooks with @testing-library/react:
// packages/ui-library/src/hooks/useAsync.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useAsync } from './useAsync';
describe('useAsync', () => {
it('returns initial loading state', () => {
const asyncFn = async () => ({ id: 1 });
const { result } = renderHook(() => useAsync(asyncFn));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
});
it('resolves data on success', async () => {
const mockData = { id: 1, name: 'Test' };
const asyncFn = async () => mockData;
const { result } = renderHook(() => useAsync(asyncFn));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBeNull();
});
it('handles errors gracefully', async () => {
const mockError = new Error('API Error');
const asyncFn = async () => {
throw mockError;
};
const { result } = renderHook(() => useAsync(asyncFn));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toEqual(mockError);
expect(result.current.data).toBeNull();
});
});
Integration Testing Across Workspaces
Test that your component library works correctly in consuming apps. Create integration tests in each app:
// apps/web/src/__tests__/integration.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Button, Card, useAsync } from '@myapp/ui-library';
describe('Component Integration', () => {
it('Button from ui-library renders in web app', () => {
render(<Button variant="primary">Test</Button>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
it('Card from ui-library wraps content correctly', () => {
render(
<Card>
<h2>Test Content</h2>
</Card>
);
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('useAsync hook fetches data from API', async () => {
const TestComponent = () => {
const { data, loading } = useAsync(
() => Promise.resolve({ success: true })
);
if (loading) return <div>Loading...</div>;
return <div>{JSON.stringify(data)}</div>;
};
render(<TestComponent />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
});
Run tests in each app:
cd apps/web
pnpm test
Visual Regression Testing
Visual regression testing catches unintended style changes. Use @playwright/test for visual snapshots:
pnpm add -D @playwright/test
Create a visual test:
// packages/ui-library/src/components/Button.visual.spec.ts
import { expect, test } from '@playwright/test';
test('Button variants match snapshot', async ({ page }) => {
await page.goto('http://localhost:6006/?path=/story/components-button--primary');
await expect(page).toHaveScreenshot('button-primary.png');
});
test('Button sizes match snapshot', async ({ page }) => {
await page.goto('http://localhost:6006/?path=/story/components-button--all-sizes');
await expect(page).toHaveScreenshot('button-sizes.png');
});
Run visual tests:
pnpm exec playwright test
Playwright captures screenshots and compares them to baselines. If styles change unexpectedly, the test fails and shows a diff.
Running Tests in CI with Turborepo
Configure Turborepo to run tests across all packages in CI. In turbo.json:
{
"tasks": {
"test": {
"cache": true,
"outputs": [".coverage/**"],
"inputs": ["src/**", "__tests__/**", "jest.config.js"]
}
}
}
In CI, run all tests:
turbo test
Turborepo runs tests in dependency order and caches results.
Creating Shared Test Utilities
Create a test-utils package for fixtures and helpers shared across workspaces:
mkdir -p packages/test-utils
cd packages/test-utils
pnpm init
Create shared test utilities:
// packages/test-utils/src/index.ts
import React from 'react';
import { render, RenderOptions } from '@testing-library/react';
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {}
export function renderWithTheme(
ui: React.ReactElement,
options?: CustomRenderOptions
) {
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<div className="theme-light">
{children}
</div>
);
return render(ui, { wrapper: Wrapper, ...options });
}
export { screen, userEvent } from '@testing-library/react';
Use shared utilities in your tests:
import { renderWithTheme, screen, userEvent } from '@myapp/test-utils';
describe('Button', () => {
it('renders with theme wrapper', () => {
renderWithTheme(<Button variant="primary">Test</Button>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
});
Key Takeaways
- Set up Vitest and React Testing Library in your component library to test components in isolation.
- Write unit tests for each component variant, state, and interaction.
- Test custom hooks with
renderHookandwaitForto handle async state. - Create integration tests in consuming apps to ensure components work in real contexts.
- Use visual regression testing to catch unintended style changes automatically.
- Create a shared
test-utilspackage with test fixtures and helpers. - Run tests across all packages in CI with
turbo testto ensure no regressions.
Frequently Asked Questions
Should I test component styling?
Test that classes are applied correctly (via toHaveClass), but do not test computed styles. Styling is visual; use visual regression tests instead.
How do I test components with external dependencies?
Mock external dependencies with vi.mock(). Example:
vi.mock('@myapp/design-tokens', () => ({
colors: { primary: '#007bff' }
}));
How do I test async components?
Use waitFor to wait for state changes:
const { result } = renderHook(() => useAsync(fetchData));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
Should I test Redux or state management?
Test that components respond correctly to props. Test state management separately. Avoid testing implementation details.
How do I improve test coverage?
Run pnpm test:coverage and identify uncovered lines. Aim for 80%+ coverage. Prioritize:
- User interactions (clicks, form inputs).
- Conditional rendering.
- Edge cases and errors.
- Accessibility (roles, labels).