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, '[email protected]');
expect(input).toHaveValue('[email protected]');
});
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, '[email protected]');
await userEvent.click(submitButton);
expect(mockSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: '[email protected]',
});
});
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