Unit Testing React Components
Set up a robust testing environment for your React Shopify theme. Learn to write unit tests with Vitest and React Testing Library, mock Shopify data, and test components in isolation.
Testing React components in a Shopify theme presents unique challenges. Your components depend on data from Liquid, interact with Shopify’s APIs, and need to work across a complex rendering pipeline. In this lesson, we’ll set up a testing environment that handles these challenges and write tests that give you confidence in your code.
Why Test Theme Components?
You might wonder if testing is worth the effort for a Shopify theme. Consider these scenarios:
- A variant selector that breaks when a product has more than 3 options
- A cart drawer that fails silently when the API returns an unexpected format
- A price display that shows the wrong currency symbol after a locale change
These bugs slip through manual testing because they only appear under specific conditions. Automated tests catch them before they reach production.
Setting Up the Test Environment
We’ll use Vitest (a fast, Vite-native test runner) and React Testing Library (which encourages testing components the way users interact with them).
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdomConfigure Vitest in your vite.config.ts:
import { defineConfig } from 'vite';import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()], test: { globals: true, environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.{test,spec}.{ts,tsx}'], coverage: { provider: 'v8', reporter: ['text', 'html'], exclude: ['src/test/**', '**/*.d.ts'], }, }, resolve: { alias: { '@': '/src', }, },});Create the setup file:
import '@testing-library/jest-dom';import { cleanup } from '@testing-library/react';import { afterEach, vi } from 'vitest';
// Clean up after each testafterEach(() => { cleanup();});
// Mock window.matchMedia (used by responsive hooks)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(), })),});
// Mock IntersectionObserver (used by lazy loading)class MockIntersectionObserver { observe = vi.fn(); unobserve = vi.fn(); disconnect = vi.fn();}
Object.defineProperty(window, 'IntersectionObserver', { writable: true, value: MockIntersectionObserver,});Add test scripts to package.json:
{ "scripts": { "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage" }}Creating Test Fixtures
Shopify data has specific shapes. Create fixtures that mirror real Shopify objects:
import type { Product, Variant, ProductImage } from '@/types/shopify';
export function createMockProduct(overrides: Partial<Product> = {}): Product { return { id: 'gid://shopify/Product/123456789', handle: 'test-product', title: 'Test Product', description: 'A product for testing purposes.', vendor: 'Test Vendor', productType: 'Test Type', tags: ['test', 'sample'], availableForSale: true, priceRange: { minVariantPrice: { amount: '29.99', currencyCode: 'USD' }, maxVariantPrice: { amount: '49.99', currencyCode: 'USD' }, }, variants: [createMockVariant()], images: [createMockImage()], options: [ { name: 'Size', values: ['Small', 'Medium', 'Large'] }, { name: 'Color', values: ['Red', 'Blue'] }, ], ...overrides, };}
export function createMockVariant(overrides: Partial<Variant> = {}): Variant { return { id: 'gid://shopify/ProductVariant/987654321', title: 'Small / Red', price: { amount: '29.99', currencyCode: 'USD' }, compareAtPrice: null, availableForSale: true, quantityAvailable: 10, selectedOptions: [ { name: 'Size', value: 'Small' }, { name: 'Color', value: 'Red' }, ], image: createMockImage(), ...overrides, };}
export function createMockImage(overrides: Partial<ProductImage> = {}): ProductImage { return { id: 'gid://shopify/ProductImage/111222333', url: 'https://cdn.shopify.com/test-image.jpg', altText: 'Test product image', width: 800, height: 800, ...overrides, };}import type { Cart, CartItem } from '@/types/shopify';
export function createMockCart(overrides: Partial<Cart> = {}): Cart { return { id: 'gid://shopify/Cart/abc123', checkoutUrl: 'https://test-store.myshopify.com/cart/c/abc123', totalQuantity: 2, cost: { subtotalAmount: { amount: '59.98', currencyCode: 'USD' }, totalAmount: { amount: '59.98', currencyCode: 'USD' }, totalTaxAmount: null, }, lines: [createMockCartItem()], ...overrides, };}
export function createMockCartItem(overrides: Partial<CartItem> = {}): CartItem { return { id: 'gid://shopify/CartLine/xyz789', quantity: 1, merchandise: { id: 'gid://shopify/ProductVariant/987654321', title: 'Small / Red', product: { title: 'Test Product', handle: 'test-product', }, image: { url: 'https://cdn.shopify.com/test-image.jpg', altText: 'Test product', }, price: { amount: '29.99', currencyCode: 'USD' }, }, cost: { totalAmount: { amount: '29.99', currencyCode: 'USD' }, }, ...overrides, };}Writing Your First Component Test
Let’s test a ProductPrice component that displays formatted prices:
import { formatMoney } from '@/lib/money';import styles from './ProductPrice.module.css';
interface ProductPriceProps { price: { amount: string; currencyCode: string }; compareAtPrice?: { amount: string; currencyCode: string } | null;}
export function ProductPrice({ price, compareAtPrice }: ProductPriceProps) { const hasDiscount = compareAtPrice && parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
return ( <div className={styles.priceWrapper}> <span className={hasDiscount ? styles.salePrice : styles.regularPrice}> {formatMoney(price)} </span> {hasDiscount && ( <span className={styles.comparePrice}> <span className="sr-only">Original price:</span> {formatMoney(compareAtPrice)} </span> )} </div> );}Now write tests for it:
import { render, screen } from '@testing-library/react';import { describe, it, expect } from 'vitest';import { ProductPrice } from './ProductPrice';
describe('ProductPrice', () => { it('renders the regular price', () => { render( <ProductPrice price={{ amount: '29.99', currencyCode: 'USD' }} /> );
expect(screen.getByText('$29.99')).toBeInTheDocument(); });
it('renders sale price with compare-at price', () => { render( <ProductPrice price={{ amount: '19.99', currencyCode: 'USD' }} compareAtPrice={{ amount: '29.99', currencyCode: 'USD' }} /> );
// Current price should be visible expect(screen.getByText('$19.99')).toBeInTheDocument();
// Compare-at price should be visible expect(screen.getByText('$29.99')).toBeInTheDocument();
// Screen reader text for accessibility expect(screen.getByText('Original price:')).toBeInTheDocument(); });
it('does not show compare price if it equals current price', () => { render( <ProductPrice price={{ amount: '29.99', currencyCode: 'USD' }} compareAtPrice={{ amount: '29.99', currencyCode: 'USD' }} /> );
// Should only have one price element const prices = screen.getAllByText('$29.99'); expect(prices).toHaveLength(1); });
it('handles different currencies', () => { render( <ProductPrice price={{ amount: '29.99', currencyCode: 'EUR' }} /> );
expect(screen.getByText(/€29[.,]99/)).toBeInTheDocument(); });});Testing Interactive Components
Components with user interaction require simulating events. Let’s test a quantity selector:
import { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { describe, it, expect, vi } from 'vitest';import { QuantitySelector } from './QuantitySelector';
describe('QuantitySelector', () => { it('renders with initial value', () => { render( <QuantitySelector value={3} onChange={vi.fn()} /> );
expect(screen.getByRole('spinbutton')).toHaveValue(3); });
it('increments value when plus button is clicked', async () => { const user = userEvent.setup(); const onChange = vi.fn();
render( <QuantitySelector value={1} onChange={onChange} /> );
await user.click(screen.getByRole('button', { name: /increase/i }));
expect(onChange).toHaveBeenCalledWith(2); });
it('decrements value when minus button is clicked', async () => { const user = userEvent.setup(); const onChange = vi.fn();
render( <QuantitySelector value={5} onChange={onChange} /> );
await user.click(screen.getByRole('button', { name: /decrease/i }));
expect(onChange).toHaveBeenCalledWith(4); });
it('disables decrement button at minimum value', () => { render( <QuantitySelector value={1} onChange={vi.fn()} min={1} /> );
expect(screen.getByRole('button', { name: /decrease/i })).toBeDisabled(); });
it('disables increment button at maximum value', () => { render( <QuantitySelector value={10} onChange={vi.fn()} max={10} /> );
expect(screen.getByRole('button', { name: /increase/i })).toBeDisabled(); });
it('allows direct input within valid range', async () => { const user = userEvent.setup(); const onChange = vi.fn();
render( <QuantitySelector value={1} onChange={onChange} max={99} /> );
const input = screen.getByRole('spinbutton'); await user.clear(input); await user.type(input, '15');
expect(onChange).toHaveBeenLastCalledWith(15); });
it('clamps input to valid range', async () => { const user = userEvent.setup(); const onChange = vi.fn();
render( <QuantitySelector value={1} onChange={onChange} max={10} /> );
const input = screen.getByRole('spinbutton'); await user.clear(input); await user.type(input, '50'); await user.tab(); // Trigger blur to validate
// Should clamp to max expect(onChange).toHaveBeenLastCalledWith(10); });});Testing Components with State
For components that manage their own state, test the behavior rather than the implementation:
import { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { describe, it, expect, vi } from 'vitest';import { VariantSelector } from './VariantSelector';import { createMockProduct, createMockVariant } from '@/test/fixtures/products';
describe('VariantSelector', () => { const product = createMockProduct({ options: [ { name: 'Size', values: ['Small', 'Medium', 'Large'] }, { name: 'Color', values: ['Red', 'Blue', 'Green'] }, ], variants: [ createMockVariant({ id: 'variant-1', title: 'Small / Red', selectedOptions: [ { name: 'Size', value: 'Small' }, { name: 'Color', value: 'Red' }, ], availableForSale: true, }), createMockVariant({ id: 'variant-2', title: 'Medium / Red', selectedOptions: [ { name: 'Size', value: 'Medium' }, { name: 'Color', value: 'Red' }, ], availableForSale: true, }), createMockVariant({ id: 'variant-3', title: 'Large / Blue', selectedOptions: [ { name: 'Size', value: 'Large' }, { name: 'Color', value: 'Blue' }, ], availableForSale: false, // Sold out }), ], });
it('renders all option groups', () => { render( <VariantSelector product={product} selectedVariantId="variant-1" onVariantChange={vi.fn()} /> );
expect(screen.getByRole('group', { name: /size/i })).toBeInTheDocument(); expect(screen.getByRole('group', { name: /color/i })).toBeInTheDocument(); });
it('marks the selected options', () => { render( <VariantSelector product={product} selectedVariantId="variant-1" onVariantChange={vi.fn()} /> );
const smallButton = screen.getByRole('radio', { name: 'Small' }); const redButton = screen.getByRole('radio', { name: 'Red' });
expect(smallButton).toBeChecked(); expect(redButton).toBeChecked(); });
it('calls onVariantChange when option is selected', async () => { const user = userEvent.setup(); const onVariantChange = vi.fn();
render( <VariantSelector product={product} selectedVariantId="variant-1" onVariantChange={onVariantChange} /> );
await user.click(screen.getByRole('radio', { name: 'Medium' }));
expect(onVariantChange).toHaveBeenCalledWith('variant-2'); });
it('indicates sold out variants', () => { render( <VariantSelector product={product} selectedVariantId="variant-1" onVariantChange={vi.fn()} /> );
// Large is only available with Blue, and that combination is sold out // The component should indicate this const largeButton = screen.getByRole('radio', { name: 'Large' }); expect(largeButton).toHaveAttribute('aria-disabled', 'true'); });});Mocking Hooks and Context
When components depend on hooks or context, mock them:
export function useCart() { // Real implementation using Zustand or context}
// src/test/mocks/useCart.tsimport { vi } from 'vitest';import { createMockCart } from '@/test/fixtures/cart';
export const mockUseCart = { cart: createMockCart(), isLoading: false, error: null, addItem: vi.fn(), updateQuantity: vi.fn(), removeItem: vi.fn(),};
export function createMockUseCart(overrides = {}) { return { ...mockUseCart, ...overrides, };}import { render, screen, waitFor } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { describe, it, expect, vi, beforeEach } from 'vitest';import { AddToCartButton } from './AddToCartButton';import { createMockUseCart } from '@/test/mocks/useCart';
// Mock the hook modulevi.mock('@/hooks/useCart', () => ({ useCart: vi.fn(),}));
import { useCart } from '@/hooks/useCart';
describe('AddToCartButton', () => { beforeEach(() => { vi.mocked(useCart).mockReturnValue(createMockUseCart()); });
it('renders with default text', () => { render(<AddToCartButton variantId="variant-1" />);
expect(screen.getByRole('button', { name: /add to cart/i })).toBeInTheDocument(); });
it('calls addItem when clicked', async () => { const user = userEvent.setup(); const addItem = vi.fn().mockResolvedValue(undefined);
vi.mocked(useCart).mockReturnValue(createMockUseCart({ addItem }));
render(<AddToCartButton variantId="variant-1" quantity={2} />);
await user.click(screen.getByRole('button'));
expect(addItem).toHaveBeenCalledWith('variant-1', 2); });
it('shows loading state while adding', async () => { const user = userEvent.setup();
// Create a promise we can control let resolveAdd: () => void; const addPromise = new Promise<void>((resolve) => { resolveAdd = resolve; });
const addItem = vi.fn().mockReturnValue(addPromise); vi.mocked(useCart).mockReturnValue(createMockUseCart({ addItem }));
render(<AddToCartButton variantId="variant-1" />);
await user.click(screen.getByRole('button'));
// Should show loading state expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true'); expect(screen.getByText(/adding/i)).toBeInTheDocument();
// Resolve the promise resolveAdd!();
await waitFor(() => { expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'false'); }); });
it('shows error state on failure', async () => { const user = userEvent.setup(); const addItem = vi.fn().mockRejectedValue(new Error('Network error'));
vi.mocked(useCart).mockReturnValue(createMockUseCart({ addItem }));
render(<AddToCartButton variantId="variant-1" />);
await user.click(screen.getByRole('button'));
await waitFor(() => { expect(screen.getByText(/try again/i)).toBeInTheDocument(); }); });
it('is disabled when variant is unavailable', () => { render(<AddToCartButton variantId="variant-1" disabled />);
expect(screen.getByRole('button')).toBeDisabled(); });});Testing Async Components
Components that fetch data need special handling:
import { render, screen, waitFor } from '@testing-library/react';import { describe, it, expect, vi, beforeEach } from 'vitest';import { ProductRecommendations } from './ProductRecommendations';import { createMockProduct } from '@/test/fixtures/products';
// Mock the API modulevi.mock('@/lib/shopify-api', () => ({ fetchRecommendations: vi.fn(),}));
import { fetchRecommendations } from '@/lib/shopify-api';
describe('ProductRecommendations', () => { beforeEach(() => { vi.clearAllMocks(); });
it('shows loading state initially', () => { vi.mocked(fetchRecommendations).mockReturnValue(new Promise(() => {}));
render(<ProductRecommendations productId="123" />);
expect(screen.getByLabelText(/loading/i)).toBeInTheDocument(); });
it('renders recommendations when loaded', async () => { const mockProducts = [ createMockProduct({ id: '1', title: 'Related Product 1' }), createMockProduct({ id: '2', title: 'Related Product 2' }), ];
vi.mocked(fetchRecommendations).mockResolvedValue(mockProducts);
render(<ProductRecommendations productId="123" />);
await waitFor(() => { expect(screen.getByText('Related Product 1')).toBeInTheDocument(); expect(screen.getByText('Related Product 2')).toBeInTheDocument(); }); });
it('shows empty state when no recommendations', async () => { vi.mocked(fetchRecommendations).mockResolvedValue([]);
render(<ProductRecommendations productId="123" />);
await waitFor(() => { expect(screen.queryByRole('list')).not.toBeInTheDocument(); }); });
it('shows error state on failure', async () => { vi.mocked(fetchRecommendations).mockRejectedValue(new Error('API Error'));
render(<ProductRecommendations productId="123" />);
await waitFor(() => { expect(screen.getByText(/unable to load/i)).toBeInTheDocument(); }); });});Testing Accessibility
Include accessibility checks in your tests:
import { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { describe, it, expect, vi } from 'vitest';import { Modal } from './Modal';
describe('Modal accessibility', () => { it('has correct ARIA attributes', () => { render( <Modal isOpen={true} onClose={vi.fn()} title="Test Modal"> <p>Modal content</p> </Modal> );
const dialog = screen.getByRole('dialog'); expect(dialog).toHaveAttribute('aria-modal', 'true'); expect(dialog).toHaveAttribute('aria-labelledby'); });
it('traps focus within the modal', async () => { const user = userEvent.setup();
render( <Modal isOpen={true} onClose={vi.fn()} title="Test Modal"> <button>First button</button> <button>Second button</button> </Modal> );
// Tab through elements await user.tab(); expect(screen.getByText('First button')).toHaveFocus();
await user.tab(); expect(screen.getByText('Second button')).toHaveFocus();
await user.tab(); // Should cycle back (focus trap) expect(screen.getByRole('button', { name: /close/i })).toHaveFocus(); });
it('closes on Escape key', async () => { const user = userEvent.setup(); const onClose = vi.fn();
render( <Modal isOpen={true} onClose={onClose} title="Test Modal"> <p>Content</p> </Modal> );
await user.keyboard('{Escape}');
expect(onClose).toHaveBeenCalled(); });
it('returns focus to trigger element on close', async () => { const user = userEvent.setup();
function TestComponent() { const [isOpen, setIsOpen] = useState(false); return ( <> <button onClick={() => setIsOpen(true)}>Open Modal</button> <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="Test"> <p>Content</p> </Modal> </> ); }
render(<TestComponent />);
const trigger = screen.getByText('Open Modal'); await user.click(trigger);
await user.keyboard('{Escape}');
expect(trigger).toHaveFocus(); });});Key Takeaways
-
Test behavior, not implementation: Focus on what users see and do, not internal state or methods.
-
Use realistic fixtures: Create mock data that mirrors actual Shopify API responses.
-
Mock at boundaries: Mock API calls and external dependencies, not internal functions.
-
Test error states: Users encounter errors. Make sure your components handle them gracefully.
-
Include accessibility tests: Test ARIA attributes, keyboard navigation, and focus management.
-
Keep tests fast: Unit tests should run in milliseconds. If they’re slow, you’re testing too much.
-
Colocate tests with components: Put
ComponentName.test.tsxnext toComponentName.tsxfor easy discovery. -
Use userEvent over fireEvent:
userEventsimulates real user behavior more accurately.
In the next lesson, we’ll expand to integration tests that verify how components work together with real Shopify data.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...