Testing

Learn about the recommended testing strategies for your customizations.

With your customization's basic functionalities in place, it's important to write tests to facilitate further development.

Integration tests

Writing integration tests is about putting yourself in the user's perspective and deriving test scenarios that focus on user interactions and workflows.

For instance, we can write a test to check that a page correctly renders a specific set of data in a table.

Mocking data

When writing tests, it's important to provide realistic test data.

To accomplish this, you can use the following approaches:

To get started, we need to configure the Mock Service Worker to create a mock server:

channels.spec.jsJavaScript
import { setupServer } from 'msw/node';
const mockServer = setupServer();
afterEach(() => mockServer.resetHandlers());
beforeAll(() =>
mockServer.listen({
onUnhandledRequest: 'error',
})
);
afterAll(() => mockServer.close());

In this test, we mock all necessary API requests. If a network request is not properly mocked, Mock Service Worker will let you know.

In the following example, our Channels page sends a FetchChannels GraphQL query. This means that we need to use the graphql.query handler matching the name of the query: FetchChannels.

channels.spec.jsJavaScript
import { graphql } from 'msw';
it('should render channels', async () => {
mockServer.use(
graphql.query('FetchChannels', (req, res, ctx) => {
return res(
ctx.data({
channels: {
// Mocked data
},
})
);
})
);
// Actual test...
});

The mocked data that we return should match the shape of the fields that we are requesting in our GraphQL query. We recommend that you use the Channel Test Data model to fulfill the exact data requirements.

channels.spec.jsJavaScript
import { graphql } from 'msw';
import { buildGraphqlList } from '@commercetools-test-data/core';
import * as ChannelMock from '@commercetools-test-data/channel';
it('should render channels', async () => {
mockServer.use(
graphql.query('FetchChannels', (req, res, ctx) => {
const totalItems = 20;
const itemsPerPage = 5;
return res(
ctx.data({
channels: buildGraphqlList(
Array.from({ length: itemsPerPage }).map((_, index) =>
ChannelMock.random().key(`channel-key-${index}`)
),
{
name: 'Channel',
total: totalItems,
}
),
})
);
})
);
// Actual test...
});

Testing the customization

To test the actual customization, you should use the test-utils package. This package provides the necessary setup to render a customization within a test environment.

Most of the time you want to render your customization from one of the top-level components, for example, your routes. This is a great way to also implicitly test your routes and to navigate between them.

We recommend writing a function, for example, renderApp (Custom Applications) or renderCustomView (Custom Views), to encapsulate the basic test setup. This ensures that your actual test remains as clean as possible.

The following example shows a basic test setup for Custom Applications:

Custom Applications: channels.spec.jsJavaScript
import {
fireEvent,
screen,
renderAppWithRedux,
} from '@commercetools-frontend/application-shell/test-utils';
import { entryPointUriPath } from '../../constants/application';
import ApplicationRoutes from '../../routes';
const renderApp = (options = {}) => {
const route = options.route || `/my-project/${entryPointUriPath}/channels`;
renderAppWithRedux(<ApplicationRoutes />, {
route,
entryPointUriPath,
// ...
...options,
});
};
it('should render channels', async () => {
mockServer.use(/* See mock setup */);
renderApp();
await screen.findByText('channel-key-0');
});

The following example shows a basic test setup for Custom Views:

Custom Views: channels.spec.jsJavaScript
import {
fireEvent,
screen,
renderCustomView,
} from '@commercetools-frontend/application-shell/test-utils';
import { entryPointUriPath } from '../../constants/application';
import ApplicationRoutes from '../../routes';
const renderView = (options = {}) => {
renderCustomView({
locale: options.locale || 'en',
projectKey: options.projectKey || 'my-project',
children: <ApplicationRoutes />,
});
};
it('should render Channels', async () => {
mockServer.use(/* See mock setup */);
renderView();
await screen.findByText('Channel no. 0');
});

This simple test implicitly tested the following things:

  • The routes work.
  • The channels page renders.
  • The data is fetched.
  • The data is displayed on the page.

At this point, you can enhance the test to do other things. For example, you can test the user interactions:

  • We can simulate that the user clicks on a table row, opening the channels detail page.
  • We can simulate that the user clicks on an "Add channel" button, fills out the form, and saves a new channel.
  • We can simulate that the user paginates through the table, or use search and filters.

Let's enhance our test to paginate to the second page. First, we need to adjust our GraphQL mock to return results based on the offset from the query variables. Then, we need to interact with the pagination button to go to the next page.

channels.spec.jsJavaScript
it('should render channels and paginate to second page', async () => {
mockServer.use(
graphql.query('FetchChannels', (req, res, ctx) => {
// Simulate a server side pagination.
const { offset } = req.variables;
const totalItems = 25; // 2 pages
const itemsPerPage = offset === 0 ? 20 : 5;
return res(
ctx.data({
channels: buildGraphqlList(
Array.from({ length: itemsPerPage }).map((_, index) =>
ChannelMock.random().key(
`channel-key-${offset === 0 ? index : 20 + index}`
)
),
{
name: 'Channel',
total: totalItems,
}
),
})
);
})
);
renderApp();
// First page
await screen.findByText('channel-key-0');
expect(screen.queryByText('channel-key-22')).not.toBeInTheDocument();
// Go to second page
fireEvent.click(screen.getByLabelText('Next page'));
// Second page
await screen.findByText('channel-key-22');
expect(screen.queryByText('channel-key-0')).not.toBeInTheDocument();
});

Testing user permissions

User permissions are bound to a Project and can vary depending on the permissions assigned to the Team where the user is a member of.

By default, the test-utils do not assign any pre-defined permissions. You need to explicitly provide the permissions in your test setup. The following fields can be used to assign the different granular permission values:

  • project.allAppliedPermissions: a list of applied resource permissions that the user should have for the given Project. A resource permission is an object with the following shape:

    • name: the name of the user permissions prefixed with can. For example canViewChannels.
    • value: if true, the resource permission is applied to the user.

In our Custom Application example, we can apply this as follows:

Custom Applications: channels.spec.jsJavaScript
import { renderAppWithRedux } from '@commercetools-frontend/application-shell/test-utils';
import { entryPointUriPath } from '../../constants/application';
import ApplicationRoutes from '../../routes';
const renderApp = (options = {}) => {
const route = options.route || `/my-project/${entryPointUriPath}/channels`;
renderAppWithRedux(<ApplicationRoutes />, {
route,
entryPointUriPath,
project: {
allAppliedPermissions: [
{
name: 'canViewChannels',
value: true,
},
],
},
...options,
});
};

In our Custom View example, we can apply this as follows:

Custom Views: channels.spec.jsJavaScript
import { renderCustomView } from '@commercetools-frontend/application-shell/test-utils';
import { entryPointUriPath } from '../../constants/application';
import ApplicationRoutes from '../../routes';
const renderView = (options = {}) => {
renderCustomView({
locale: options.locale || 'en',
projectKey: options.projectKey || 'my-project',
projectAllAppliedPermissions: [
{
name: 'canView',
value: true,
},
],
children: <ApplicationRoutes />,
});
};

To help define the list of applied permissions, you can use the helper function mapResourceAccessToAppliedPermissions.

In our Custom Application example, we can apply this as follows:

Custom Application: channels.spec.jsJavaScript
import {
renderAppWithRedux,
mapResourceAccessToAppliedPermissions,
} from '@commercetools-frontend/application-shell/test-utils';
import { PERMISSIONS } from '../../constants';
const renderApp = (options = {}) => {
const route = options.route || `/my-project/${entryPointUriPath}/channels`;
renderAppWithRedux(<ApplicationRoutes />, {
route,
entryPointUriPath,
project: {
allAppliedPermissions: mapResourceAccessToAppliedPermissions([
PERMISSIONS.View,
]),
},
...options,
});
};

End-to-end tests

To complement unit and integration tests, we recommend that you write end-to-end tests using Cypress. Cypress is a feature-rich and developer friendly tool that makes it easy to test your customizations.

After you set up and install Cypress, we recommend that you install the following packages:

  • @testing-library/cypress: provides commands to select elements using React/Dom Testing Library.
  • @commercetools-frontend/cypress: provides commands specific to customizations.

Define a .env file in the cypress folder, containing some of the required environment variables:

cypress/.envTerminal
CYPRESS_LOGIN_USER=""
CYPRESS_LOGIN_PASSWORD=""
CYPRESS_PROJECT_KEY=""

The .env file should be git ignored. On CI, you can define environment variables within the CI job.

If you have multiple Custom Views in your repository, you need to differentiate which Custom View is currently being tested. You can do this by providing the CYPRESS_PACKAGE_NAME variable, which refers to the name specified in the Custom View's package.json.

In the cypress.config.ts file, you need to configure the task for the customizations and to load the environment variables:

cypress.config.tsTypeScript
import path from 'path';
import { defineConfig } from 'cypress';
import {
customViewConfig,
customApplicationConfig,
} from '@commercetools-frontend/cypress/task';
export default defineConfig({
retries: 1,
video: false,
e2e: {
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
async setupNodeEvents(on, cypressConfig) {
// Load the config
if (!process.env.CI) {
const envPath = path.join(__dirname, 'cypress/.env');
console.log('Loading environment variables from', envPath);
const dotenv = await import('dotenv');
dotenv.config({ path: envPath });
}
on('task', { customApplicationConfig });
on('task', { customViewConfig });
return {
...cypressConfig,
env: {
...cypressConfig.env,
LOGIN_USER: process.env.CYPRESS_LOGIN_USER,
LOGIN_PASSWORD: process.env.CYPRESS_LOGIN_PASSWORD,
PROJECT_KEY: process.env.CYPRESS_PROJECT_KEY,
PACKAGE_NAME: process.env.CYPRESS_PACKAGE_NAME,
},
};
},
baseUrl: 'http://localhost:3001',
},
});

If you have both Custom Applications and Custom Views in the same repository you can configure both tasks in the cypress.config.ts as shown in the previous example.

In the cypress/support/commands.ts, you need to import the following commands:

cypress/support/commands.tsTypeScript
import '@testing-library/cypress/add-commands';
import '@commercetools-frontend/cypress/add-commands';

We also recommend that you define constants:

cypress/support/constants.tsTypeScript
export const projectKey = Cypress.env('PROJECT_KEY');
// TODO: define the actual `entryPointUriPath` of your Custom Application
export const entryPointUriPath = '';
export const applicationBaseRoute = `/${projectKey}/${entryPointUriPath}`;

At this point the setup is done. You can start writing your tests.

For Custom Applications

For Custom Views

cypress/e2e/channels.cy.tsTypeScript
import { entryPointUriPath, applicationBaseRoute } from '../support/constants';
describe('Channels', () => {
beforeEach(() => {
cy.loginToMerchantCenter({
entryPointUriPath,
initialRoute: applicationBaseRoute,
});
});
it('should render page', () => {
cy.findByText('Channels list').should('exist');
});
});
cypress/e2e/channels.cy.tsTypeScript
describe('Channels', () => {
beforeEach(() => {
cy.loginToMerchantCenterForCustomView();
});
it('should render page', () => {
cy.findByText('Channels list').should('exist');
});
});