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 调用保留给集成测试套件。

端到端测试

端到端测试验证从商家安装应用到执行关键操作的完整用户旅程。

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 中引入的店面模拟环境。它允许你测试应用对店面的影响——主题应用扩展、脚本标签和动态内容——而不影响实际商店。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(黑色星期五/网络星期一)和新品发售期间会经历大量流量峰值。你的应用必须优雅地处理这些情况。

使用 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 应用赢得商家信任,一次通过应用审核,并在高流量事件中安然无恙。尽早投资测试基础设施——它会通过减少支持负担和加快迭代速度来带来回报。