Skip to main content

測試策略

Shopify 應用程式在一個複雜的環境中運行——嵌入式 iframe、OAuth 流程、webhook 處理、GraphQL API 和店面渲染。穩健的測試策略涵蓋每一層,讓您有信心應用程式在全方位的商家場景中都能正確運作。本模組帶您了解單元測試、整合測試、端對端測試,以及 Shopify 測試生態系統中最新的工具。

使用 Vitest 進行單元測試

Vitest 是使用 Remix 建構的現代 Shopify 應用程式推薦的測試執行器。它速度快、原生支援 ESM,並與 Shopify 應用程式範本整合良好。

設定 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 提供了在開發環境中對真實 Shopify 商店測試您的應用程式的工具。

# 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');
});
});
warning

針對真實 Shopify API 的整合測試會消耗 API 速率限制。在 CI 中謹慎執行——通常只在針對 main 的 pull request 上執行,而不是每次推送都執行。單元測試使用模擬回應,將即時 API 呼叫保留給整合測試套件。

端對端測試

E2E 測試驗證完整的使用者旅程,從商家安裝您的應用程式到執行關鍵操作。

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 (Winter '26)

SimGym 是 Shopify 在 Winter '26 中推出的店面模擬環境。它讓您可以測試應用程式對店面的影響——佈景主題應用程式擴充、script tags 和動態內容——而不影響真實商店。SimGym 啟動一個帶有合成流量模式的隔離店面,讓您在部署前衡量效能影響。

# 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 會產生詳細報告,包含 Web Vitals 影響、渲染時間和 JavaScript 執行成本。將它作為 CI 管線中的關卡使用:

# .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 用於 A/B 測試(Winter '26)

Rollouts

Winter '26 推出了 Rollouts,這是 Shopify 為應用程式內建的 A/B 測試框架。Rollouts 讓您可以漸進式地將新功能部署給一定百分比的商家,並在全面推出前衡量對關鍵指標的影響。

// 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 商家在閃購活動、BFCM(Black Friday / Cyber Monday)和限量發售期間經歷巨大的流量高峰。您的應用程式必須優雅地處理這些情況。

使用 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
danger

永遠不要對 Shopify 的生產 API 或真實商家商店執行負載測試。只對您自己的基礎設施進行負載測試。在預備環境中使用模擬的 Shopify 回應來模擬負載下的真實 API 行為。

測試策略摘要

LayerToolWhat It TestsFrequency
UnitVitestBusiness logic, components, utilsEvery commit
IntegrationVitest + Shopify CLIAPI interactions, data flowsEvery PR
E2EPlaywrightFull user journeysPre-release
StorefrontSimGymPerformance impact on storefrontPre-release
A/BRolloutsFeature effectivenessPost-release
Loadk6Throughput and latency under stressPre-BFCM, quarterly

經過充分測試的 Shopify 應用程式贏得商家信任,第一次就通過應用程式審核,並在高流量事件中安然無恙。儘早投資測試基礎設施——它會在降低支援負擔和加快迭代速度方面帶來回報。