Skip to main content
This guide covers the testing setup and strategies used in the Shopify Subscriptions Reference App, including unit tests, component tests, and integration tests.

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.tsx next to Component.tsx)
  • Use descriptive test names that explain the behavior
  • Group related tests using describe blocks
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"
  }
}
These ensure consistent locale and timezone settings across environments.

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

Build docs developers (and LLMs) love