테스트 전략
Shopify 앱은 복잡한 환경에서 운영됩니다 -- 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.
Vitest를 사용한 단위 테스트
Vitest는 Remix로 구축된 최신 Shopify 앱에 권장되는 테스트 러너입니다. It is fast, has native ESM support, and integrates cleanly with the Shopify app template.
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(),
}));
비즈니스 로직 테스트
// 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);
});
});
});
Polaris를 사용한 React 컴포넌트 테스트
// 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');
});
});
});
Webhook 핸들러 테스트
// 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);
});
});
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
Shopify에 대한 GraphQL 쿼리 테스트
// 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.
엔드투엔드 테스트
E2E tests verify the complete user journey from a merchant installing your app to performing key actions.
Shopify 앱을 위한 Playwright 설정
// 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
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
A/B 테스트를 위한 Rollouts (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'
});
}
대규모 트래픽 이벤트를 위한 부하 테스트
Shopify merchants experience massive traffic spikes during flash sales, BFCM (Black Friday / Cyber Monday), and product drops. Your app must handle these gracefully.
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.
테스트 전략 요약
| 레이어 | 도구 | 테스트 대상 | 빈도 |
|---|---|---|---|
| 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.