Testing and Debugging Advanced 10 min read

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:

  1. Component composition: Multiple components working together
  2. Data flow: Props passing through component trees
  3. API interactions: Components that fetch and mutate data
  4. 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.

Terminal window
npm install -D msw

Create handlers for Shopify’s AJAX API:

src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
import { createMockCart, createMockCartItem } from '@/test/fixtures/cart';
import { createMockProduct } from '@/test/fixtures/products';
// Track cart state across requests
let 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 tests
export function resetMockCart() {
mockCartState = createMockCart();
}

Set up MSW in your test environment:

src/test/setup.ts
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:

src/test/integration/add-to-cart.test.tsx
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 components
function 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:

src/test/integration/cart-operations.test.tsx
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:

src/test/integration/search.test.tsx
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:

src/test/integration/error-handling.test.tsx
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

  1. Reset state between tests: Use beforeEach to reset mocks and state to prevent test pollution.

  2. Test user journeys: Focus on complete user flows rather than individual operations.

  3. Mock at the network level: Use MSW to intercept requests rather than mocking internal functions.

  4. Keep tests deterministic: Avoid relying on timing; use waitFor with explicit conditions.

  5. Test error paths: Verify your application handles API failures, network errors, and edge cases.

  6. Use realistic data: Your fixtures should mirror actual Shopify API responses.

Key Takeaways

  1. Integration tests verify data flow: They catch bugs that unit tests miss by testing how components work together.

  2. MSW enables realistic API mocking: Intercept requests at the network level for accurate testing.

  3. Test complete user journeys: Add to cart, update quantity, checkout—test the flows users actually follow.

  4. Include error scenarios: Users encounter errors. Make sure your app recovers gracefully.

  5. Keep tests isolated: Reset state and handlers between tests to prevent flaky results.

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