Module Federation with Vite and Testing with Vitest
Module Federation was originally a Webpack 5 feature, but @originjs/vite-plugin-federation brought it to the Vite ecosystem. Testing Vite-based federated modules requires a different setup than Webpack — Vitest is the natural testing tool, and the module resolution strategy for federated imports needs specific configuration.
This guide covers setting up Vitest for Module Federation with Vite, testing federated components, and handling the federation runtime in tests.
Setup: Vite Module Federation Project
npm create vite@latest my-remote -- --template react-ts
npm install @originjs/vite-plugin-federation
npm install -D vitest @vitest/ui @testing-library/react jsdomRemote vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [
react(),
federation({
name: 'product-widget',
filename: 'remoteEntry.js',
exposes: {
'./ProductCard': './src/components/ProductCard/index.tsx',
'./useProductStore': './src/stores/useProductStore.ts',
},
shared: ['react', 'react-dom', 'zustand'],
}),
],
build: {
target: 'esnext',
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
},
});Shell vite.config.ts:
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [
react(),
federation({
name: 'shell',
remotes: {
'product-widget': 'http://localhost:5001/assets/remoteEntry.js',
},
shared: ['react', 'react-dom', 'zustand'],
}),
],
});Configuring Vitest for Federated Imports
The shell imports from remotes like import { ProductCard } from 'product-widget/ProductCard'. Vitest doesn't know about the federation runtime, so these imports fail. Use vi.mock or Vitest's alias config to redirect them:
// vitest.config.ts (shell)
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
alias: {
// Redirect federated imports to local mocks
'product-widget/ProductCard': './src/__mocks__/product-widget/ProductCard',
'product-widget/useProductStore': './src/__mocks__/product-widget/useProductStore',
'cart-widget/CartIcon': './src/__mocks__/cart-widget/CartIcon',
},
},
});Create the mock files:
// src/__mocks__/product-widget/ProductCard.tsx
export function ProductCard({
product,
onAddToCart,
}: {
product: { id: number; name: string; price: number };
onAddToCart: (p: any) => void;
}) {
return (
<div data-testid="mock-product-card" data-product-id={product.id}>
<span data-testid="product-name">{product.name}</span>
<span data-testid="product-price">${product.price}</span>
<button onClick={() => onAddToCart(product)}>Add to cart</button>
</div>
);
}Testing the Remote Module (Product Widget)
Test the actual ProductCard component — no federation involved at this level:
// product-widget/src/components/ProductCard/ProductCard.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductCard } from './index';
const defaultProps = {
product: {
id: 1,
name: 'Widget Pro',
price: 29.99,
stock: 10,
description: 'A great widget',
},
onAddToCart: vi.fn(),
};
describe('ProductCard', () => {
it('renders product information', () => {
render(<ProductCard {...defaultProps} />);
expect(screen.getByText('Widget Pro')).toBeInTheDocument();
expect(screen.getByText('$29.99')).toBeInTheDocument();
});
it('add to cart button calls handler with product', async () => {
const user = userEvent.setup();
const onAddToCart = vi.fn();
render(<ProductCard {...defaultProps} onAddToCart={onAddToCart} />);
await user.click(screen.getByRole('button', { name: /add to cart/i }));
expect(onAddToCart).toHaveBeenCalledWith(defaultProps.product);
});
it('out-of-stock products disable the button', () => {
render(
<ProductCard
{...defaultProps}
product={{ ...defaultProps.product, stock: 0 }}
/>
);
expect(screen.getByRole('button', { name: /add to cart/i })).toBeDisabled();
});
it('shows stock warning when stock is low', () => {
render(
<ProductCard
{...defaultProps}
product={{ ...defaultProps.product, stock: 2 }}
/>
);
expect(screen.getByText(/only 2 left/i)).toBeInTheDocument();
});
});Testing Zustand Store (Shared Dependency)
The useProductStore composable uses Zustand, which is a shared dependency. Test it in isolation:
// product-widget/src/stores/useProductStore.ts
import { create } from 'zustand';
interface ProductStore {
selectedCategory: string | null;
sortBy: 'name' | 'price' | 'stock';
setCategory: (category: string | null) => void;
setSortBy: (sort: 'name' | 'price' | 'stock') => void;
}
export const useProductStore = create<ProductStore>((set) => ({
selectedCategory: null,
sortBy: 'name',
setCategory: (category) => set({ selectedCategory: category }),
setSortBy: (sort) => set({ sortBy: sort }),
}));// product-widget/src/stores/useProductStore.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { act } from '@testing-library/react';
import { useProductStore } from './useProductStore';
describe('useProductStore', () => {
beforeEach(() => {
// Reset store between tests
useProductStore.setState({
selectedCategory: null,
sortBy: 'name',
});
});
it('starts with null category and name sort', () => {
const { selectedCategory, sortBy } = useProductStore.getState();
expect(selectedCategory).toBeNull();
expect(sortBy).toBe('name');
});
it('setCategory updates selected category', () => {
act(() => {
useProductStore.getState().setCategory('electronics');
});
expect(useProductStore.getState().selectedCategory).toBe('electronics');
});
it('setCategory can clear category with null', () => {
act(() => {
useProductStore.getState().setCategory('electronics');
useProductStore.getState().setCategory(null);
});
expect(useProductStore.getState().selectedCategory).toBeNull();
});
it('setSortBy updates sort preference', () => {
act(() => {
useProductStore.getState().setSortBy('price');
});
expect(useProductStore.getState().sortBy).toBe('price');
});
});Testing Shell Components with Mocked Remotes
// shell/src/pages/ProductsPage/ProductsPage.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ProductsPage from './ProductsPage';
// 'product-widget/ProductCard' is aliased to mock in vitest.config.ts
describe('ProductsPage', () => {
const mockProducts = [
{ id: 1, name: 'Widget A', price: 9.99, stock: 5 },
{ id: 2, name: 'Widget B', price: 19.99, stock: 0 },
];
beforeEach(() => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockProducts),
});
});
it('renders products from API', async () => {
render(<ProductsPage />);
// Wait for async data load
await waitFor(() => {
expect(screen.getAllByTestId('mock-product-card')).toHaveLength(2);
});
});
it('handles add to cart from product widget', async () => {
const user = userEvent.setup();
render(<ProductsPage />);
await waitFor(() => screen.getAllByTestId('mock-product-card'));
await user.click(screen.getAllByRole('button', { name: /add to cart/i })[0]);
// Cart count in header should update
expect(screen.getByTestId('cart-count')).toHaveTextContent('1');
});
});Testing the Federation Runtime Loading
For integration tests that test the actual Module Federation loading (not mocked), use a test server:
// tests/integration/federation-loading.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { preview } from 'vite';
import type { PreviewServer } from 'vite';
import puppeteer, { type Browser, type Page } from 'puppeteer';
describe('Module Federation loading', () => {
let remoteServer: PreviewServer;
let shellServer: PreviewServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
// Start the remote server
remoteServer = await preview({ configFile: 'packages/product-widget/vite.config.ts' });
// Start the shell server
shellServer = await preview({ configFile: 'packages/shell/vite.config.ts' });
browser = await puppeteer.launch();
page = await browser.newPage();
}, 30_000);
afterAll(async () => {
await browser.close();
await remoteServer.httpServer?.close();
await shellServer.httpServer?.close();
});
it('shell loads remote module without errors', async () => {
const errors: string[] = [];
page.on('pageerror', (err) => errors.push(err.message));
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
await page.goto('http://localhost:4173');
await page.waitForSelector('[data-testid="product-card"]', { timeout: 10_000 });
expect(errors).toHaveLength(0);
});
it('remote entry point returns valid JavaScript', async () => {
const response = await page.evaluate(async () => {
const res = await fetch('http://localhost:5001/assets/remoteEntry.js');
return { status: res.status, contentType: res.headers.get('content-type') };
});
expect(response.status).toBe(200);
expect(response.contentType).toContain('javascript');
});
});Performance Testing Federation Loading
Large federated bundles cause slow initial loads. Test loading performance:
// tests/performance/federation-perf.test.ts
import { test, expect } from '@playwright/test';
test('product widget loads within 2 seconds', async ({ page }) => {
const startTime = Date.now();
await page.goto('/products');
await page.waitForSelector('[data-testid="product-card"]');
const loadTime = Date.now() - startTime;
expect(loadTime).toBeLessThan(2000);
});
test('remote entry.js is smaller than 100KB', async ({ request }) => {
const response = await request.get('http://localhost:5001/assets/remoteEntry.js');
const content = await response.body();
const sizeKB = content.length / 1024;
expect(sizeKB).toBeLessThan(100);
});Summary
Testing Module Federation with Vite and Vitest:
- Configure aliases in
vitest.config.tsto redirect federated imports to local mocks - Test remotes independently — Vitest tests don't need the federation runtime
- Test shared stores (Zustand, Pinia) in complete isolation — reset state in
beforeEach - Test shell with mocked remotes — fast unit tests without starting remote servers
- Integration test the federation runtime — use
vite preview+ Puppeteer/Playwright to load actual federated modules - Monitor bundle size —
remoteEntry.jsand chunk sizes affect load performance
The Vite ecosystem's fast build times make it practical to run integration tests that actually start servers and load federated modules in CI — something that was too slow with Webpack. Use Vitest for unit and component tests, Playwright for composition E2E tests.