Testing and Debugging Intermediate 12 min read

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).

Terminal window
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

Configure Vitest in your vite.config.ts:

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:

src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
// Clean up after each test
afterEach(() => {
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:

src/test/fixtures/products.ts
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,
};
}
src/test/fixtures/cart.ts
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:

src/components/product/ProductPrice/ProductPrice.tsx
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:

src/components/product/ProductPrice/ProductPrice.test.tsx
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:

src/components/ui/QuantitySelector/QuantitySelector.test.tsx
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:

src/components/product/VariantSelector/VariantSelector.test.tsx
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:

src/hooks/useCart.ts
export function useCart() {
// Real implementation using Zustand or context
}
// src/test/mocks/useCart.ts
import { 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,
};
}
src/components/cart/AddToCartButton/AddToCartButton.test.tsx
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 module
vi.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:

src/components/product/ProductRecommendations/ProductRecommendations.test.tsx
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 module
vi.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:

src/components/ui/Modal/Modal.test.tsx
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

  1. Test behavior, not implementation: Focus on what users see and do, not internal state or methods.

  2. Use realistic fixtures: Create mock data that mirrors actual Shopify API responses.

  3. Mock at boundaries: Mock API calls and external dependencies, not internal functions.

  4. Test error states: Users encounter errors. Make sure your components handle them gracefully.

  5. Include accessibility tests: Test ARIA attributes, keyboard navigation, and focus management.

  6. Keep tests fast: Unit tests should run in milliseconds. If they’re slow, you’re testing too much.

  7. Colocate tests with components: Put ComponentName.test.tsx next to ComponentName.tsx for easy discovery.

  8. Use userEvent over fireEvent: userEvent simulates 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...