This guide covers the testing setup and strategies used in the Shopify Subscriptions Reference App, including unit tests, component tests, and integration tests.Documentation Index
Fetch the complete documentation index at: https://mintlify.com/Shopify/subscriptions-reference-app/llms.txt
Use this file to discover all available pages before exploring further.
Test Setup
The app uses Vitest as the test runner with React Testing Library for component tests.Test Configuration
File:vitest.config.ts
import graphql from '@rollup/plugin-graphql';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
react(),
tsconfigPaths({
projects: [
'./tsconfig.json',
'./extensions/buyer-subscriptions/tsconfig.json',
'./extensions/admin-subs-action/tsconfig.json',
'./extensions/thank-you-page/tsconfig.json',
],
}),
graphql(),
],
test: {
globals: true,
environment: 'happy-dom',
pool: 'threads',
setupFiles: [
'./test/setup-test-env.ts',
'./test/setup-i18n.ts',
'./test/setup-graphql-matchers.ts',
'./test/setup-app-bridge.tsx',
'./test/setup-address-mocks.tsx',
],
include: [
'./app/**/*.test.[jt]s?(x)',
'./extensions/admin-subs-action/**/*.test.[jt]s?(x)',
'./config/**/*.test.[jt]s?(x)',
'./extensions/buyer-subscriptions/**/*.test.[jt]s?(x)',
'./extensions/thank-you-page/**/*.test.[jt]s?(x)',
],
exclude: ['./extensions/**/node_modules/**'],
},
});
Test Environment Setup
File:test/setup-test-env.ts
import '@testing-library/jest-dom/vitest';
import '@testing-library/react';
import '@shopify/react-testing/matchers';
import { beforeAll, vi } from 'vitest';
import { API_KEY, API_SECRET_KEY, APP_URL, SCOPE } from './constants';
// Set environment variables
process.env.SCOPES = SCOPE;
process.env.SHOPIFY_APP_URL = APP_URL;
process.env.SHOPIFY_API_KEY = API_KEY;
process.env.SHOPIFY_API_SECRET = API_SECRET_KEY;
globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { isDisabled: true };
beforeAll(() => {
global.IS_REACT_ACT_ENVIRONMENT = false;
});
// Silence specific console warnings
const originalError = console.error;
console.error = vi.fn((message) => {
if (
typeof message !== 'string' ||
(!message.includes('Warning: The tag <%s> is unrecognized') &&
!message.includes('inside a test was not wrapped in act(...)') &&
!message.includes('Warning: Received `%s` for a non-boolean attribute'))
) {
originalError(message);
}
});
Running Tests
The app provides several test commands:# Run all tests
pnpm test
# Run tests in watch mode
pnpm test --watch
# Run tests with coverage
pnpm test --coverage
# Run specific test file
pnpm test path/to/test.test.tsx
# Run extension-specific tests
pnpm test:ci:buyer-subscriptions
pnpm test:ci:admin-subs-action
pnpm test:ci:thank-you-page
Testing Strategies
Component Testing
Test React components using React Testing Library. Example: Testing the Form component File:app/components/Form/Form.test.tsx
import { describe, expect, it, vi, afterEach } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { z } from 'zod';
import { Form } from './Form';
import { mockShopify } from '#/setup-app-bridge';
import { mountRemixStubWithAppContext } from '#/test-utils';
import { json, Link, useActionData, useLoaderData } from '@remix-run/react';
import { SubmitButton } from '../SubmitButton';
const DEFAULT_VALUES = {
defaultValues: {
name: '',
},
} as const;
const mockLoaderData = vi.fn().mockReturnValue(DEFAULT_VALUES);
const mockActionData = vi.fn().mockReturnValue(null);
const loader = async () => {
return json(await mockLoaderData());
};
const action = async () => {
return json(await mockActionData());
};
function TestFormRoute() {
const { defaultValues } = useLoaderData<typeof DEFAULT_VALUES>();
const actionData = useActionData<{ message: string }>();
const schema = z.object({});
return (
<Form schema={schema} defaultValues={defaultValues} action="/">
<h1>Test form</h1>
<div>{actionData ? actionData.message : null}</div>
<label>
Name
<input type="text" name="name" id="name" defaultValue={defaultValues.name} />
</label>
<Link to="/other">Go back</Link>
<SubmitButton>Submit</SubmitButton>
</Form>
);
}
function mountFormRoute() {
mountRemixStubWithAppContext({
routes: [
{
id: 'test-form',
path: '/',
Component: () => <TestFormRoute />,
loader,
action,
},
],
remixStubProps: {
initialEntries: ['/'],
hydrationData: {
loaderData: { 'test-form': DEFAULT_VALUES },
},
},
});
}
describe('Form', () => {
afterEach(() => {
vi.clearAllMocks();
});
describe('before submitting', () => {
describe('save bar', () => {
it('shows when a text field is changed', async () => {
mountFormRoute();
expect(mockShopify.saveBar.show).not.toHaveBeenCalled();
const name = screen.getByLabelText('Name');
await userEvent.type(name, 'My test name');
expect(mockShopify.saveBar.show).toHaveBeenCalledOnce();
});
it('hides when a text field is changed and then reset', async () => {
mountFormRoute();
expect(mockShopify.saveBar.hide).toHaveBeenCalledOnce();
const name = screen.getByLabelText('Name');
await userEvent.type(name, 'My test name');
await userEvent.clear(name);
expect(mockShopify.saveBar.hide).toHaveBeenCalledTimes(2);
});
});
describe('submit button', () => {
it('is disabled only when the form is clean', async () => {
mountFormRoute();
const submit = screen.getByRole('button', { name: 'Submit' });
expect(submit).toHaveAttribute('aria-disabled', 'true');
const name = screen.getByLabelText('Name');
await userEvent.type(name, 'My test name');
expect(submit).not.toHaveAttribute('aria-disabled', 'true');
});
});
});
describe('after submitting', () => {
it('maintains the values', async () => {
mountFormRoute();
const name: HTMLInputElement = screen.getByLabelText('Name');
await userEvent.type(name, 'My test name');
mockLoaderData.mockReturnValue({ defaultValues: { name: 'My test name' } });
mockActionData.mockResolvedValue({ message: 'Submit successful' });
const submit = screen.getByRole('button', { name: 'Submit' });
await userEvent.click(submit);
await vi.waitFor(() => expect(mockActionData).toHaveBeenCalledOnce());
await screen.findByText('Submit successful');
expect(mockShopify.saveBar.leaveConfirmation).not.toHaveBeenCalled();
expect(name.value).toBe('My test name');
});
});
it('blocks navigation when the form is changed', async () => {
mountFormRoute();
const name = screen.getByLabelText('Name');
await userEvent.type(name, 'My test name');
const link = screen.getByText('Go back');
await userEvent.click(link);
expect(mockShopify.saveBar.leaveConfirmation).toHaveBeenCalledOnce();
});
});
Testing User Interactions
Use@testing-library/user-event for realistic user interactions:
import userEvent from '@testing-library/user-event';
import { screen } from '@testing-library/react';
it('handles button clicks', async () => {
render(<MyComponent />);
const button = screen.getByRole('button', { name: 'Submit' });
await userEvent.click(button);
expect(mockSubmitHandler).toHaveBeenCalled();
});
it('handles text input', async () => {
render(<MyComponent />);
const input = screen.getByLabelText('Email');
await userEvent.type(input, 'test@example.com');
expect(input).toHaveValue('test@example.com');
});
it('handles form submission', async () => {
render(<MyComponent />);
const nameInput = screen.getByLabelText('Name');
const emailInput = screen.getByLabelText('Email');
const submitButton = screen.getByRole('button', { name: 'Submit' });
await userEvent.type(nameInput, 'John Doe');
await userEvent.type(emailInput, 'john@example.com');
await userEvent.click(submitButton);
expect(mockSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
});
});
Mocking GraphQL
The app includes GraphQL matchers for testing: File:test/setup-graphql-matchers.ts
import { expect } from 'vitest';
import type { GraphQLRequest } from '@shopify-internal/graphql-testing';
import {
createGraphQLMatcher,
findMatchingRequest,
} from '@shopify-internal/graphql-testing';
export const toHavePerformedGraphQLOperation = createGraphQLMatcher(
(request: GraphQLRequest, operation: string) => {
const match = findMatchingRequest(request, operation);
return {
pass: match !== undefined,
message: () =>
match
? `Expected not to perform GraphQL operation ${operation}`
: `Expected to perform GraphQL operation ${operation}`,
};
}
);
expect.extend({
toHavePerformedGraphQLOperation,
});
Testing Remix Routes
Use the test utility for mounting Remix routes:import { mountRemixStubWithAppContext } from '#/test-utils';
it('loads and displays data', async () => {
const mockData = {
subscriptionContracts: [
{ id: '1', status: 'ACTIVE' },
{ id: '2', status: 'PAUSED' },
],
};
mountRemixStubWithAppContext({
routes: [
{
path: '/contracts',
Component: ContractsPage,
loader: () => json(mockData),
},
],
remixStubProps: {
initialEntries: ['/contracts'],
},
});
expect(await screen.findByText('Active')).toBeInTheDocument();
expect(await screen.findByText('Paused')).toBeInTheDocument();
});
Mocking Shopify App Bridge
The test setup includes App Bridge mocks: File:test/setup-app-bridge.tsx
import { vi } from 'vitest';
export const mockShopify = {
saveBar: {
show: vi.fn(),
hide: vi.fn(),
leaveConfirmation: vi.fn(),
},
modal: {
show: vi.fn(),
hide: vi.fn(),
},
toast: {
show: vi.fn(),
},
};
vi.mock('@shopify/app-bridge-react', () => ({
useAppBridge: () => mockShopify,
Provider: ({ children }: { children: React.ReactNode }) => children,
}));
Testing Webhooks
Test webhook handlers by simulating webhook requests:import { describe, it, expect, vi } from 'vitest';
import { action } from './webhooks.subscription_contracts.create';
describe('subscription_contracts/create webhook', () => {
it('sends welcome email for checkout subscriptions', async () => {
const mockRequest = new Request('http://localhost/webhooks', {
method: 'POST',
headers: {
'X-Shopify-Topic': 'subscription_contracts/create',
'X-Shopify-Shop-Domain': 'test-shop.myshopify.com',
},
body: JSON.stringify({
admin_graphql_api_id: 'gid://shopify/SubscriptionContract/1',
admin_graphql_api_origin_order_id: 'gid://shopify/Order/123',
}),
});
const response = await action({ request } as any);
expect(response.status).toBe(200);
expect(mockEmailJob.enqueue).toHaveBeenCalled();
});
it('skips email for non-checkout subscriptions', async () => {
const mockRequest = new Request('http://localhost/webhooks', {
method: 'POST',
headers: {
'X-Shopify-Topic': 'subscription_contracts/create',
'X-Shopify-Shop-Domain': 'test-shop.myshopify.com',
},
body: JSON.stringify({
admin_graphql_api_id: 'gid://shopify/SubscriptionContract/1',
admin_graphql_api_origin_order_id: null,
}),
});
const response = await action({ request } as any);
expect(response.status).toBe(200);
expect(mockEmailJob.enqueue).not.toHaveBeenCalled();
});
});
Testing Models
Test server-side models and GraphQL operations:import { describe, it, expect, vi } from 'vitest';
import { createSellingPlanGroup } from '~/models/SellingPlan/SellingPlan.server';
describe('SellingPlan.server', () => {
describe('createSellingPlanGroup', () => {
it('creates a selling plan group with correct variables', async () => {
const mockGraphql = vi.fn().mockResolvedValue({
json: async () => ({
data: {
sellingPlanGroupCreate: {
sellingPlanGroup: { id: 'gid://shopify/SellingPlanGroup/1' },
userErrors: [],
},
},
}),
});
const result = await createSellingPlanGroup(mockGraphql, {
name: 'Test Plan',
merchantCode: 'TEST',
productIds: ['gid://shopify/Product/1'],
productVariantIds: [],
discountDeliveryOptions: [
{
id: 'new-1',
deliveryInterval: 'MONTH',
deliveryFrequency: 1,
discountValue: 10,
},
],
discountType: 'PERCENTAGE',
offerDiscount: true,
currencyCode: 'USD',
});
expect(result.sellingPlanGroupId).toBe('gid://shopify/SellingPlanGroup/1');
expect(mockGraphql).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
variables: expect.objectContaining({
input: expect.objectContaining({
name: 'Test Plan',
merchantCode: 'TEST',
}),
}),
})
);
});
it('returns user errors when creation fails', async () => {
const mockGraphql = vi.fn().mockResolvedValue({
json: async () => ({
data: {
sellingPlanGroupCreate: {
sellingPlanGroup: null,
userErrors: [{ field: 'name', message: 'Name is required' }],
},
},
}),
});
const result = await createSellingPlanGroup(mockGraphql, {
name: '',
merchantCode: 'TEST',
productIds: [],
productVariantIds: [],
discountDeliveryOptions: [],
discountType: 'PERCENTAGE',
offerDiscount: false,
currencyCode: 'USD',
});
expect(result.userErrors).toHaveLength(1);
expect(result.userErrors[0].message).toBe('Name is required');
});
});
});
Best Practices
Test Organization
- Co-locate tests with components (
Component.test.tsxnext toComponent.tsx) - Use descriptive test names that explain the behavior
- Group related tests using
describeblocks
Test Coverage
- Aim for high coverage of critical paths
- Test edge cases and error conditions
- Don’t test implementation details, test behavior
Mocking
- Mock external dependencies (API calls, webhooks)
- Use factories for test data generation
- Keep mocks simple and focused
Assertions
- Use semantic queries (
getByRole,getByLabelText) - Test accessibility with ARIA roles
- Verify user-visible behavior, not internal state
Continuous Integration
The app includes CI-specific test commands:{
"scripts": {
"test:ci:buyer-subscriptions": "LC_ALL=en_US.UTF-8 dotenv -c ci vitest extensions/buyer-subscriptions",
"test:ci:admin-subs-action": "LC_ALL=en_US.UTF-8 dotenv -c ci vitest extensions/admin-subs-action",
"test:ci:thank-you-page": "LC_ALL=en_US.UTF-8 dotenv -c ci vitest extensions/thank-you-page"
}
}
Debugging Tests
Visual Debugging
import { screen } from '@testing-library/react';
it('debugs component state', () => {
render(<MyComponent />);
// Print the DOM tree
screen.debug();
// Print a specific element
const button = screen.getByRole('button');
screen.debug(button);
});
Async Debugging
import { waitFor, screen } from '@testing-library/react';
it('waits for async operations', async () => {
render(<AsyncComponent />);
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
});
Next Steps
Setup and Configuration
Configure your development environment
Creating Selling Plans
Build subscription plans