Integration Testing with Shopify Data
Write integration tests that verify how React components work together with Shopify APIs. Learn to mock the Storefront API, test data flows, and validate complete user journeys.
Unit tests verify components in isolation. Integration tests verify that components work together correctly—that data flows through your application as expected and that user interactions produce the right outcomes across multiple components. In this lesson, we’ll build integration tests for common Shopify theme scenarios.
Integration Testing Strategy
Integration tests sit between unit tests and end-to-end tests:
┌─────────────────────────────────────────────────────────────────┐│ TESTING PYRAMID │├─────────────────────────────────────────────────────────────────┤│ ││ ▲ ││ /·\ E2E Tests ││ /···\ (Playwright, Cypress) ││ /·····\ Slow, expensive, few ││ ───────── ││ /·········\ Integration Tests ││ /···········\ (Vitest + MSW) ││ /·············\ Medium speed, many ││ ───────────────── ││ /·················\ Unit Tests ││ /···················\ (Vitest + RTL) ││ /·····················\ Fast, cheap, most ││ ───────────────────────── ││ │└─────────────────────────────────────────────────────────────────┘For integration tests in a React Shopify theme, we focus on:
- Component composition: Multiple components working together
- Data flow: Props passing through component trees
- API interactions: Components that fetch and mutate data
- State synchronization: Shared state across components
Setting Up Mock Service Worker (MSW)
MSW intercepts network requests at the service worker level, letting us mock Shopify API responses without changing application code.
npm install -D mswCreate handlers for Shopify’s AJAX API:
import { http, HttpResponse } from 'msw';import { createMockCart, createMockCartItem } from '@/test/fixtures/cart';import { createMockProduct } from '@/test/fixtures/products';
// Track cart state across requestslet mockCartState = createMockCart();
export const handlers = [ // Get cart http.get('/cart.js', () => { return HttpResponse.json({ token: mockCartState.id, note: null, attributes: {}, total_price: 2999, total_weight: 0, item_count: mockCartState.totalQuantity, items: mockCartState.lines.map((line) => ({ id: line.id, quantity: line.quantity, variant_id: line.merchandise.id, title: line.merchandise.product.title, price: parseInt(line.cost.totalAmount.amount) * 100, line_price: parseInt(line.cost.totalAmount.amount) * 100 * line.quantity, final_price: parseInt(line.cost.totalAmount.amount) * 100, image: line.merchandise.image?.url, handle: line.merchandise.product.handle, variant_title: line.merchandise.title, })), requires_shipping: true, currency: 'USD', }); }),
// Add to cart http.post('/cart/add.js', async ({ request }) => { const body = await request.json() as { id: string; quantity: number };
const newItem = createMockCartItem({ id: `line-${Date.now()}`, quantity: body.quantity, merchandise: { id: body.id, title: 'Added Variant', product: { title: 'Added Product', handle: 'added-product' }, image: { url: 'https://cdn.shopify.com/added.jpg', altText: 'Added' }, price: { amount: '29.99', currencyCode: 'USD' }, }, });
mockCartState = { ...mockCartState, lines: [...mockCartState.lines, newItem], totalQuantity: mockCartState.totalQuantity + body.quantity, };
return HttpResponse.json({ id: newItem.id, quantity: newItem.quantity, variant_id: body.id, title: 'Added Product', price: 2999, }); }),
// Update cart http.post('/cart/change.js', async ({ request }) => { const body = await request.json() as { id: string; quantity: number };
mockCartState = { ...mockCartState, lines: mockCartState.lines.map((line) => line.id === body.id ? { ...line, quantity: body.quantity } : line ), };
return HttpResponse.json(mockCartState); }),
// Predictive search http.get('/search/suggest.json', ({ request }) => { const url = new URL(request.url); const query = url.searchParams.get('q') || '';
if (query.length < 2) { return HttpResponse.json({ resources: { results: { products: [] } } }); }
return HttpResponse.json({ resources: { results: { products: [ createMockProduct({ title: `${query} Product 1`, handle: `${query}-1` }), createMockProduct({ title: `${query} Product 2`, handle: `${query}-2` }), ], }, }, }); }),
// Product recommendations http.get('/recommendations/products.json', ({ request }) => { const url = new URL(request.url); const productId = url.searchParams.get('product_id');
return HttpResponse.json({ products: [ createMockProduct({ id: 'rec-1', title: 'Recommended 1' }), createMockProduct({ id: 'rec-2', title: 'Recommended 2' }), createMockProduct({ id: 'rec-3', title: 'Recommended 3' }), ], }); }),];
// Reset cart state between testsexport function resetMockCart() { mockCartState = createMockCart();}Set up MSW in your test environment:
import { beforeAll, afterAll, afterEach } from 'vitest';import { setupServer } from 'msw/node';import { handlers, resetMockCart } from './mocks/handlers';
export const server = setupServer(...handlers);
beforeAll(() => { server.listen({ onUnhandledRequest: 'error' });});
afterEach(() => { server.resetHandlers(); resetMockCart();});
afterAll(() => { server.close();});Testing the Add-to-Cart Flow
This integration test verifies the entire flow from clicking “Add to Cart” to seeing the updated cart drawer:
import { render, screen, waitFor, within } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { describe, it, expect, beforeEach } from 'vitest';import { AppProviders } from '@/components/providers/AppProviders';import { ProductPage } from '@/components/product/ProductPage';import { CartDrawer } from '@/components/cart/CartDrawer';import { Header } from '@/components/layout/Header';import { createMockProduct, createMockVariant } from '@/test/fixtures/products';
// Wrapper component that includes all necessary providers and shared componentsfunction TestApp({ children }: { children: React.ReactNode }) { return ( <AppProviders> <Header /> <main>{children}</main> <CartDrawer /> </AppProviders> );}
describe('Add to Cart Integration', () => { const product = createMockProduct({ title: 'Test Hoodie', variants: [ createMockVariant({ id: 'variant-small', title: 'Small', selectedOptions: [{ name: 'Size', value: 'Small' }], availableForSale: true, }), createMockVariant({ id: 'variant-medium', title: 'Medium', selectedOptions: [{ name: 'Size', value: 'Medium' }], availableForSale: true, }), ], });
it('adds item to cart and opens cart drawer', async () => { const user = userEvent.setup();
render( <TestApp> <ProductPage product={product} /> </TestApp> );
// Select a variant await user.click(screen.getByRole('radio', { name: 'Medium' }));
// Set quantity await user.click(screen.getByRole('button', { name: /increase quantity/i }));
// Add to cart await user.click(screen.getByRole('button', { name: /add to cart/i }));
// Wait for cart drawer to open await waitFor(() => { expect(screen.getByRole('dialog', { name: /cart/i })).toBeVisible(); });
// Verify item appears in cart const cartDrawer = screen.getByRole('dialog', { name: /cart/i }); expect(within(cartDrawer).getByText('Test Hoodie')).toBeInTheDocument(); expect(within(cartDrawer).getByText('Medium')).toBeInTheDocument(); });
it('updates cart count in header', async () => { const user = userEvent.setup();
render( <TestApp> <ProductPage product={product} /> </TestApp> );
// Get initial cart count const cartButton = screen.getByRole('button', { name: /cart/i }); expect(within(cartButton).getByText('0')).toBeInTheDocument();
// Add to cart await user.click(screen.getByRole('button', { name: /add to cart/i }));
// Wait for cart count to update await waitFor(() => { expect(within(cartButton).getByText('1')).toBeInTheDocument(); }); });
it('shows success feedback on button', async () => { const user = userEvent.setup();
render( <TestApp> <ProductPage product={product} /> </TestApp> );
const addButton = screen.getByRole('button', { name: /add to cart/i });
await user.click(addButton);
// Should show loading state await waitFor(() => { expect(addButton).toHaveTextContent(/adding/i); });
// Should show success state await waitFor(() => { expect(addButton).toHaveTextContent(/added/i); });
// Should return to default state await waitFor( () => { expect(addButton).toHaveTextContent(/add to cart/i); }, { timeout: 3000 } ); });});Testing Cart Operations
Test the complete cart experience—updating quantities, removing items, applying discounts:
import { render, screen, waitFor, within } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { describe, it, expect, beforeEach } from 'vitest';import { AppProviders } from '@/components/providers/AppProviders';import { CartPage } from '@/components/cart/CartPage';import { createMockCart, createMockCartItem } from '@/test/fixtures/cart';import { server } from '@/test/setup';import { http, HttpResponse } from 'msw';
function TestApp({ children }: { children: React.ReactNode }) { return <AppProviders>{children}</AppProviders>;}
describe('Cart Operations Integration', () => { beforeEach(() => { // Set up initial cart state with items const initialCart = createMockCart({ totalQuantity: 2, lines: [ createMockCartItem({ id: 'line-1', quantity: 1, merchandise: { id: 'variant-1', title: 'Small / Red', product: { title: 'T-Shirt', handle: 't-shirt' }, image: { url: 'https://cdn.shopify.com/tshirt.jpg', altText: 'T-Shirt' }, price: { amount: '25.00', currencyCode: 'USD' }, }, cost: { totalAmount: { amount: '25.00', currencyCode: 'USD' } }, }), createMockCartItem({ id: 'line-2', quantity: 1, merchandise: { id: 'variant-2', title: 'One Size', product: { title: 'Hat', handle: 'hat' }, image: { url: 'https://cdn.shopify.com/hat.jpg', altText: 'Hat' }, price: { amount: '15.00', currencyCode: 'USD' }, }, cost: { totalAmount: { amount: '15.00', currencyCode: 'USD' } }, }), ], cost: { subtotalAmount: { amount: '40.00', currencyCode: 'USD' }, totalAmount: { amount: '40.00', currencyCode: 'USD' }, totalTaxAmount: null, }, });
server.use( http.get('/cart.js', () => { return HttpResponse.json({ token: initialCart.id, item_count: initialCart.totalQuantity, items: initialCart.lines.map((line) => ({ id: line.id, quantity: line.quantity, variant_id: line.merchandise.id, title: line.merchandise.product.title, variant_title: line.merchandise.title, price: parseInt(line.cost.totalAmount.amount) * 100, line_price: parseInt(line.cost.totalAmount.amount) * 100 * line.quantity, image: line.merchandise.image?.url, handle: line.merchandise.product.handle, })), total_price: 4000, currency: 'USD', }); }) ); });
it('displays cart items correctly', async () => { render( <TestApp> <CartPage /> </TestApp> );
await waitFor(() => { expect(screen.getByText('T-Shirt')).toBeInTheDocument(); expect(screen.getByText('Hat')).toBeInTheDocument(); });
expect(screen.getByText('$40.00')).toBeInTheDocument(); });
it('updates item quantity', async () => { const user = userEvent.setup();
let updatedQuantity = 1;
server.use( http.post('/cart/change.js', async ({ request }) => { const body = await request.json() as { id: string; quantity: number }; updatedQuantity = body.quantity;
return HttpResponse.json({ item_count: updatedQuantity + 1, items: [ { id: 'line-1', quantity: updatedQuantity, variant_id: 'variant-1', title: 'T-Shirt', price: 2500, line_price: 2500 * updatedQuantity, }, { id: 'line-2', quantity: 1, variant_id: 'variant-2', title: 'Hat', price: 1500, line_price: 1500, }, ], total_price: 2500 * updatedQuantity + 1500, }); }) );
render( <TestApp> <CartPage /> </TestApp> );
await waitFor(() => { expect(screen.getByText('T-Shirt')).toBeInTheDocument(); });
// Find the T-Shirt line item and increase quantity const tshirtItem = screen.getByText('T-Shirt').closest('[data-testid="cart-item"]'); const increaseButton = within(tshirtItem!).getByRole('button', { name: /increase/i });
await user.click(increaseButton);
// Wait for optimistic update await waitFor(() => { const quantityInput = within(tshirtItem!).getByRole('spinbutton'); expect(quantityInput).toHaveValue(2); });
expect(updatedQuantity).toBe(2); });
it('removes item from cart', async () => { const user = userEvent.setup();
let removedItemId: string | null = null;
server.use( http.post('/cart/change.js', async ({ request }) => { const body = await request.json() as { id: string; quantity: number };
if (body.quantity === 0) { removedItemId = body.id; }
return HttpResponse.json({ item_count: 1, items: [ { id: 'line-2', quantity: 1, variant_id: 'variant-2', title: 'Hat', price: 1500, line_price: 1500, }, ], total_price: 1500, }); }) );
render( <TestApp> <CartPage /> </TestApp> );
await waitFor(() => { expect(screen.getByText('T-Shirt')).toBeInTheDocument(); });
// Find and click remove button for T-Shirt const tshirtItem = screen.getByText('T-Shirt').closest('[data-testid="cart-item"]'); const removeButton = within(tshirtItem!).getByRole('button', { name: /remove/i });
await user.click(removeButton);
// T-Shirt should be removed await waitFor(() => { expect(screen.queryByText('T-Shirt')).not.toBeInTheDocument(); });
// Hat should still be there expect(screen.getByText('Hat')).toBeInTheDocument(); expect(removedItemId).toBe('line-1'); });
it('shows empty cart state when all items removed', async () => { const user = userEvent.setup();
server.use( http.post('/cart/change.js', () => { return HttpResponse.json({ item_count: 0, items: [], total_price: 0, }); }) );
// Start with a single item cart server.use( http.get('/cart.js', () => { return HttpResponse.json({ item_count: 1, items: [ { id: 'line-1', quantity: 1, variant_id: 'variant-1', title: 'T-Shirt', price: 2500, }, ], total_price: 2500, }); }) );
render( <TestApp> <CartPage /> </TestApp> );
await waitFor(() => { expect(screen.getByText('T-Shirt')).toBeInTheDocument(); });
const removeButton = screen.getByRole('button', { name: /remove/i }); await user.click(removeButton);
await waitFor(() => { expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument(); }); });});Testing Search Integration
Test the predictive search flow from input to results:
import { render, screen, waitFor } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { describe, it, expect } from 'vitest';import { AppProviders } from '@/components/providers/AppProviders';import { SearchModal } from '@/components/search/SearchModal';
function TestApp({ children }: { children: React.ReactNode }) { return <AppProviders>{children}</AppProviders>;}
describe('Search Integration', () => { it('shows predictive results as user types', async () => { const user = userEvent.setup();
render( <TestApp> <SearchModal isOpen={true} onClose={() => {}} /> </TestApp> );
const searchInput = screen.getByRole('searchbox');
// Type a search query await user.type(searchInput, 'hoodie');
// Wait for debounce and results await waitFor( () => { expect(screen.getByText('hoodie Product 1')).toBeInTheDocument(); expect(screen.getByText('hoodie Product 2')).toBeInTheDocument(); }, { timeout: 1000 } ); });
it('shows no results message for empty search', async () => { const user = userEvent.setup();
// Override handler to return empty results server.use( http.get('/search/suggest.json', () => { return HttpResponse.json({ resources: { results: { products: [] } }, }); }) );
render( <TestApp> <SearchModal isOpen={true} onClose={() => {}} /> </TestApp> );
const searchInput = screen.getByRole('searchbox'); await user.type(searchInput, 'xyznonexistent');
await waitFor(() => { expect(screen.getByText(/no results found/i)).toBeInTheDocument(); }); });
it('clears results when input is cleared', async () => { const user = userEvent.setup();
render( <TestApp> <SearchModal isOpen={true} onClose={() => {}} /> </TestApp> );
const searchInput = screen.getByRole('searchbox');
// Type and wait for results await user.type(searchInput, 'hoodie');
await waitFor(() => { expect(screen.getByText('hoodie Product 1')).toBeInTheDocument(); });
// Clear input await user.clear(searchInput);
await waitFor(() => { expect(screen.queryByText('hoodie Product 1')).not.toBeInTheDocument(); }); });
it('navigates results with keyboard', async () => { const user = userEvent.setup();
render( <TestApp> <SearchModal isOpen={true} onClose={() => {}} /> </TestApp> );
const searchInput = screen.getByRole('searchbox'); await user.type(searchInput, 'hoodie');
await waitFor(() => { expect(screen.getByText('hoodie Product 1')).toBeInTheDocument(); });
// Navigate with arrow keys await user.keyboard('{ArrowDown}');
const firstResult = screen.getByText('hoodie Product 1').closest('a'); expect(firstResult).toHaveFocus();
await user.keyboard('{ArrowDown}');
const secondResult = screen.getByText('hoodie Product 2').closest('a'); expect(secondResult).toHaveFocus(); });});Testing Error Recovery
Verify that your application handles API failures gracefully:
import { render, screen, waitFor } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { describe, it, expect } from 'vitest';import { http, HttpResponse } from 'msw';import { server } from '@/test/setup';import { AppProviders } from '@/components/providers/AppProviders';import { ProductPage } from '@/components/product/ProductPage';import { createMockProduct } from '@/test/fixtures/products';
describe('Error Handling Integration', () => { it('shows error and allows retry on add-to-cart failure', async () => { const user = userEvent.setup();
let attempts = 0;
server.use( http.post('/cart/add.js', () => { attempts++; if (attempts === 1) { return HttpResponse.json( { message: 'Cart is full' }, { status: 422 } ); } return HttpResponse.json({ id: 'new-item', quantity: 1 }); }) );
render( <AppProviders> <ProductPage product={createMockProduct()} /> </AppProviders> );
const addButton = screen.getByRole('button', { name: /add to cart/i });
// First attempt fails await user.click(addButton);
await waitFor(() => { expect(screen.getByText(/try again/i)).toBeInTheDocument(); });
// Retry succeeds await user.click(addButton);
await waitFor(() => { expect(screen.getByText(/added/i)).toBeInTheDocument(); });
expect(attempts).toBe(2); });
it('handles network timeout gracefully', async () => { const user = userEvent.setup();
server.use( http.post('/cart/add.js', async () => { // Simulate timeout await new Promise((resolve) => setTimeout(resolve, 10000)); return HttpResponse.json({}); }) );
render( <AppProviders> <ProductPage product={createMockProduct()} /> </AppProviders> );
const addButton = screen.getByRole('button', { name: /add to cart/i }); await user.click(addButton);
// Should show loading state expect(addButton).toHaveAttribute('aria-busy', 'true');
// After timeout, should show error await waitFor( () => { expect(screen.getByText(/try again/i)).toBeInTheDocument(); }, { timeout: 6000 } ); });});Best Practices for Integration Tests
-
Reset state between tests: Use
beforeEachto reset mocks and state to prevent test pollution. -
Test user journeys: Focus on complete user flows rather than individual operations.
-
Mock at the network level: Use MSW to intercept requests rather than mocking internal functions.
-
Keep tests deterministic: Avoid relying on timing; use
waitForwith explicit conditions. -
Test error paths: Verify your application handles API failures, network errors, and edge cases.
-
Use realistic data: Your fixtures should mirror actual Shopify API responses.
Key Takeaways
-
Integration tests verify data flow: They catch bugs that unit tests miss by testing how components work together.
-
MSW enables realistic API mocking: Intercept requests at the network level for accurate testing.
-
Test complete user journeys: Add to cart, update quantity, checkout—test the flows users actually follow.
-
Include error scenarios: Users encounter errors. Make sure your app recovers gracefully.
-
Keep tests isolated: Reset state and handlers between tests to prevent flaky results.
-
Balance coverage and speed: Integration tests are slower than unit tests. Focus on critical paths.
In the next lesson, we’ll dive into debugging techniques for the Liquid-React bridge—tracking down issues in the data handoff between your Liquid templates and React components.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...