Testing Strategies
Shopify apps operate in a complex environment -- embedded iframes, OAuth flows, webhook processing, GraphQL APIs, and storefront rendering. A robust testing strategy covers every layer and gives you confidence that your app works correctly across the full spectrum of merchant scenarios. This module walks you through unit testing, integration testing, end-to-end testing, and the newest tools in the Shopify testing ecosystem.
Unit Testing with Vitest
Vitest is the recommended test runner for modern Shopify apps built with Remix. It is fast, has native ESM support, and integrates cleanly with the Shopify app template.
Setting Up Vitest
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
// vitest.config.js
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./test/setup.js'],
include: ['**/*.test.{js,jsx,ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
exclude: [
'node_modules/',
'test/',
'**/*.config.*',
'**/types/**',
],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
resolve: {
alias: {
'~': path.resolve(__dirname, 'app'),
},
},
});
// test/setup.js
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
afterEach(() => {
cleanup();
});
// Mock Shopify App Bridge globally
vi.mock('@shopify/app-bridge-react', () => ({
useAppBridge: () => ({
dispatch: vi.fn(),
subscribe: vi.fn(),
getState: vi.fn(),
}),
TitleBar: ({ title }) => <div data-testid="title-bar">{title}</div>,
useNavigate: () => vi.fn(),
}));
Testing Business Logic
// services/pricing-calculator.test.js
import { describe, it, expect } from 'vitest';
import { calculateDiscount, applyVolumePrice } from './pricing-calculator';
describe('PricingCalculator', () => {
describe('calculateDiscount', () => {
it('applies percentage discount correctly', () => {
const result = calculateDiscount({
originalPrice: 100.0,
discountType: 'percentage',
discountValue: 15,
});
expect(result).toEqual({
discountedPrice: 85.0,
savings: 15.0,
discountApplied: true,
});
});
it('does not allow negative prices', () => {
const result = calculateDiscount({
originalPrice: 10.0,
discountType: 'fixed',
discountValue: 20.0,
});
expect(result.discountedPrice).toBe(0);
expect(result.savings).toBe(10.0);
});
it('handles zero-price items gracefully', () => {
const result = calculateDiscount({
originalPrice: 0,
discountType: 'percentage',
discountValue: 50,
});
expect(result.discountedPrice).toBe(0);
expect(result.discountApplied).toBe(false);
});
it('rounds to two decimal places for currency precision', () => {
const result = calculateDiscount({
originalPrice: 19.99,
discountType: 'percentage',
discountValue: 33.33,
});
expect(result.discountedPrice).toBe(13.33);
});
});
describe('applyVolumePrice', () => {
const tiers = [
{ minQuantity: 1, maxQuantity: 9, pricePerUnit: 10.0 },
{ minQuantity: 10, maxQuantity: 49, pricePerUnit: 8.5 },
{ minQuantity: 50, maxQuantity: Infinity, pricePerUnit: 7.0 },
];
it('selects the correct tier for quantity', () => {
expect(applyVolumePrice(5, tiers)).toBe(10.0);
expect(applyVolumePrice(25, tiers)).toBe(8.5);
expect(applyVolumePrice(100, tiers)).toBe(7.0);
});
it('uses the boundary quantity correctly', () => {
expect(applyVolumePrice(10, tiers)).toBe(8.5);
expect(applyVolumePrice(50, tiers)).toBe(7.0);
});
});
});
Testing React Components with Polaris
// components/ProductCard.test.jsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { PolarisTestProvider } from '@shopify/polaris';
import enTranslations from '@shopify/polaris/locales/en.json';
import { ProductCard } from './ProductCard';
function renderWithPolaris(component) {
return render(
<PolarisTestProvider i18n={enTranslations}>
{component}
</PolarisTestProvider>
);
}
describe('ProductCard', () => {
const mockProduct = {
id: 'gid://shopify/Product/123',
title: 'Test Widget',
status: 'ACTIVE',
totalInventory: 42,
featuredImage: { url: 'https://cdn.shopify.com/test.jpg' },
};
it('renders product information correctly', () => {
renderWithPolaris(<ProductCard product={mockProduct} />);
expect(screen.getByText('Test Widget')).toBeInTheDocument();
expect(screen.getByText('42 in stock')).toBeInTheDocument();
});
it('shows warning badge for low inventory', () => {
const lowStockProduct = { ...mockProduct, totalInventory: 3 };
renderWithPolaris(<ProductCard product={lowStockProduct} />);
expect(screen.getByText('Low stock')).toBeInTheDocument();
});
it('calls onSync when sync button is clicked', async () => {
const onSync = vi.fn().mockResolvedValue({ success: true });
renderWithPolaris(<ProductCard product={mockProduct} onSync={onSync} />);
fireEvent.click(screen.getByRole('button', { name: /sync/i }));
await waitFor(() => {
expect(onSync).toHaveBeenCalledWith('gid://shopify/Product/123');
});
});
});
Testing Webhook Handlers
// webhooks/orders-paid.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import crypto from 'crypto';
import { handleOrderPaid } from './orders-paid';
describe('Orders Paid Webhook', () => {
let mockReq, mockRes;
beforeEach(() => {
mockRes = {
status: vi.fn().mockReturnThis(),
send: vi.fn(),
};
});
function createSignedRequest(body) {
const rawBody = JSON.stringify(body);
const hmac = crypto
.createHmac('sha256', process.env.SHOPIFY_API_SECRET || 'test-secret')
.update(rawBody, 'utf8')
.digest('base64');
return {
headers: { 'x-shopify-hmac-sha256': hmac },
rawBody,
body,
get: (header) => hmac,
};
}
it('processes a valid paid order', async () => {
const orderPayload = {
id: 456,
total_price: '99.99',
line_items: [{ product_id: 789, quantity: 2 }],
};
mockReq = createSignedRequest(orderPayload);
await handleOrderPaid(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
});
it('rejects requests with invalid HMAC', async () => {
mockReq = {
headers: { 'x-shopify-hmac-sha256': 'invalid-hmac' },
rawBody: '{}',
body: {},
get: () => 'invalid-hmac',
};
await handleOrderPaid(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(401);
});
});
Integration Testing with Shopify CLI
Shopify CLI provides tools for testing your app against real Shopify stores in a development context.
# Start your app in development mode with a test store
shopify app dev --store=your-test-store.myshopify.com
# Run your integration tests against the running dev server
SHOPIFY_TEST_STORE=your-test-store.myshopify.com npm run test:integration
Testing GraphQL Queries Against Shopify
// test/integration/products-api.test.js
import { describe, it, expect, beforeAll } from 'vitest';
import { createTestClient } from '../helpers/shopify-test-client';
describe('Products API Integration', () => {
let client;
beforeAll(async () => {
client = await createTestClient();
});
it('fetches products with correct fields', async () => {
const response = await client.query({
data: {
query: `{
products(first: 5) {
edges {
node {
id
title
status
totalInventory
}
}
}
}`,
},
});
const products = response.body.data.products.edges;
expect(products.length).toBeGreaterThan(0);
expect(products[0].node).toHaveProperty('id');
expect(products[0].node).toHaveProperty('title');
});
});
Integration tests that hit real Shopify APIs consume API rate limits. Run them sparingly in CI -- typically only on pull requests targeting main, not on every push. Use mocked responses for unit tests and reserve live API calls for integration suites.
End-to-End Testing
E2E tests verify the complete user journey from a merchant installing your app to performing key actions.
Playwright Setup for Shopify Apps
// playwright.config.js
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './test/e2e',
timeout: 60000, // Shopify admin can be slow
retries: 2,
use: {
baseURL: process.env.APP_URL || 'http://localhost:3000',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
],
});
// test/e2e/app-dashboard.spec.js
import { test, expect } from '@playwright/test';
test.describe('App Dashboard', () => {
test.beforeEach(async ({ page }) => {
// Navigate to the app within Shopify admin
await page.goto('/app');
// Wait for App Bridge to initialize
await page.waitForSelector('[data-testid="app-loaded"]');
});
test('displays product sync status', async ({ page }) => {
await page.click('text=Product Sync');
await expect(page.locator('[data-testid="sync-status"]')).toBeVisible();
await expect(page.locator('[data-testid="last-sync-time"]')).not.toBeEmpty();
});
test('can trigger manual sync', async ({ page }) => {
await page.click('text=Product Sync');
await page.click('button:has-text("Sync Now")');
// Wait for sync to complete
await expect(page.locator('[data-testid="sync-progress"]')).toBeVisible();
await expect(page.locator('text=Sync complete')).toBeVisible({
timeout: 30000,
});
});
});
SimGym for Storefront Testing
SimGym is Shopify's storefront simulation environment introduced in Winter '26. It allows you to test your app's storefront impact -- theme app extensions, script tags, and dynamic content -- without affecting a live store. SimGym spins up an isolated storefront with synthetic traffic patterns, enabling you to measure performance impact before deployment.
# Install SimGym CLI extension
shopify plugins install @shopify/simgym
# Run a storefront simulation with your app's theme extension
shopify simgym run \
--theme-extension=./extensions/theme-block \
--traffic-profile=holiday-sale \
--duration=300 \
--report=./simgym-report.html
SimGym generates a detailed report including Web Vitals impact, render timing, and JavaScript execution costs. Use it as a gate in your CI pipeline:
# .github/workflows/storefront-perf.yml
- name: SimGym Performance Test
run: |
shopify simgym run \
--theme-extension=./extensions/theme-block \
--traffic-profile=standard \
--assert-lcp-under=2500 \
--assert-cls-under=0.1 \
--assert-inp-under=200
Rollouts for A/B Testing (Winter '26)
Winter '26 introduces Rollouts, Shopify's built-in A/B testing framework for apps. Rollouts lets you progressively deploy new features to a percentage of merchants and measure the impact on key metrics before full rollout.
// services/rollouts.js
import { shopifyApi } from '@shopify/shopify-api';
export async function checkFeatureFlag(session, featureName) {
const client = new shopify.clients.Graphql({ session });
const response = await client.query({
data: {
query: `query CheckRollout($feature: String!) {
appRollout(feature: $feature) {
enabled
variant
metadata
}
}`,
variables: { feature: featureName },
},
});
return response.body.data.appRollout;
}
// Usage in a route loader
export async function loader({ request }) {
const session = await getSession(request);
const newDashboard = await checkFeatureFlag(session, 'new-dashboard-v2');
return json({
useNewDashboard: newDashboard.enabled,
dashboardVariant: newDashboard.variant, // 'control' or 'treatment'
});
}
Load Testing for High-Traffic Events
Shopify merchants experience massive traffic spikes during flash sales, BFCM (Black Friday / Cyber Monday), and product drops. Your app must handle these gracefully.
Load Testing with k6
// test/load/webhook-throughput.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';
const failureRate = new Rate('failed_requests');
export const options = {
stages: [
{ duration: '1m', target: 50 }, // Ramp up
{ duration: '5m', target: 200 }, // Sustained peak (simulates BFCM)
{ duration: '2m', target: 500 }, // Spike (flash sale start)
{ duration: '1m', target: 50 }, // Cool down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
failed_requests: ['rate<0.01'], // Less than 1% failure rate
},
};
export default function () {
const payload = JSON.stringify({
id: Math.floor(Math.random() * 1000000),
total_price: '59.99',
line_items: [{ product_id: 123, quantity: 1 }],
});
const res = http.post(`${__ENV.APP_URL}/webhooks/orders-create`, payload, {
headers: {
'Content-Type': 'application/json',
'X-Shopify-Hmac-Sha256': 'test-hmac', // Use valid HMAC in staging
'X-Shopify-Shop-Domain': 'load-test.myshopify.com',
},
});
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
failureRate.add(res.status !== 200);
sleep(0.1);
}
# Run the load test
k6 run --env APP_URL=https://staging.your-app.com test/load/webhook-throughput.js
Never run load tests against Shopify's production APIs or a live merchant store. Load test only your own infrastructure. Use mock Shopify responses in your staging environment to simulate realistic API behavior under load.
Testing Strategy Summary
| Layer | Tool | What It Tests | Frequency |
|---|---|---|---|
| Unit | Vitest | Business logic, components, utils | Every commit |
| Integration | Vitest + Shopify CLI | API interactions, data flows | Every PR |
| E2E | Playwright | Full user journeys | Pre-release |
| Storefront | SimGym | Performance impact on storefront | Pre-release |
| A/B | Rollouts | Feature effectiveness | Post-release |
| Load | k6 | Throughput and latency under stress | Pre-BFCM, quarterly |
A well-tested Shopify app earns merchant trust, passes app review on the first attempt, and survives high-traffic events without incident. Invest in testing infrastructure early -- it pays dividends in reduced support burden and faster iteration speed.