Testing
In the previous sections we learned several aspects of developing your Custom Application, like fetching data, building your User Interface, etc.
At this point the basic functionalities of your application are in place. It's time to write some tests to facilitate further development.
Write tests. Not too many. Mostly integration.
When it comes to testing applications there are several approaches available. You've probably heard about the Testing Pyramid to describe the different levels of testing.
Recommended testing strategies
To test your Custom Applications, we recommend using the React Testing Library for integration tests (or user flow tests).
This library describes the testing problem as following:
You want to write maintainable tests for your React components. As a part of this goal, you want your tests to avoid including implementation details of your components and rather focus on making your tests give you the confidence for which they are intended. As part of this, you want your test base to be maintainable in the long run so refactors of your components (changes to implementation but not functionality) don't break your tests and slow you and your team down.
This is the recommended approach for writing component and UI tests.
To facilitate writing integration tests we additionally provide some test-utils
with the necessary setup to test a Custom Application.
For End-to-End tests we recommend using Cypress. See End-to-End for more information.
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, in our Channels page we can write a test to check that the page renders some data in a table and maybe paginate to page two.
Mocking data
One of the first things to consider when writing tests is about the test data. Of course mocking network requests is important but so is providing "realistic" test data.
In our Custom Application we recommend to implement the following approaches:
- Use the Mock Service Worker library to mock network requests.
- Use the Test Data library to use realistic test data models.
Let's set things up. First, we need to configure Mock Service Worker to create a mock server.
import { setupServer } from 'msw/node';const mockServer = setupServer();afterEach(() => mockServer.resetHandlers());beforeAll(() =>mockServer.listen({onUnhandledRequest: 'error',}));afterAll(() => mockServer.close());
In our test we then mock all necessary API requests. If a network request is not properly mocked, Mock Service Worker will let you know.
Our Channels page sends a FetchChannels
GraphQL query, therefore we need to use the graphql.query
handler matching the name of the query: FetchChannels
.
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 need to return should match the shape of the fields we are requesting in our GraphQL query.
In theory we can simply hardcode some random object but this does not scale well as your Custom Application grows.
A better way to fulfill the data requirements is to use a Test Data model for a Channel
.
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 application
To test the actual Custom Application, you should use the test-utils
package, as it provides the necessary setup to render a Custom Application within a test environment.
Most of the time you want to render your application 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
, to encapsulate the basic test setup, so that your actual test remains as clean as possible.
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');});
With this simple test we implicitly have tested the following things:
- The routes work.
- The channels page renders.
- The data is fetched.
- The data is displayed on the page.
From here you can enhance the test to do other things, especially testing the user interactions. For example:
- 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.
You get the idea.
Let's enhance our test to paginate to page two. 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.
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 pagesconst 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 pageawait screen.findByText('channel-key-0');expect(screen.queryByText('channel-key-22')).not.toBeInTheDocument();// Go to second pagefireEvent.click(screen.getByLabelText('Next page'));// Second pageawait screen.findByText('channel-key-22');expect(screen.queryByText('channel-key-0')).not.toBeInTheDocument();});
This should give you a basic idea on how you can approach testing your Custom Application.
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 them 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 withcan
. For examplecanViewChannels
.value
: Iftrue
, the resource permission is applied to the user.
In our example application, we can apply this as following:
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,});};
To help define the list of applied permissions, you can use the helper function mapResourceAccessToAppliedPermissions
.
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
To complement unit and integration tests, we recommend to also write End-to-End tests using Cypress.
Cypress is a feature-rich and developer friendly tool to write End-to-End tests and it's very easy to use for testing Custom Applications.
Assuming you have already set up and installed Cypress, we recommend to install the following packages:
@testing-library/cypress
: provides commands to select elements using React/Dom Testing Library.@commercetools-frontend/cypress
: provides commands specific to Custom Applications.
Define a .env
file in the cypress
folder, containing some of the required environment variables:
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.
In the cypress/plugins/index.js
file, you need to configure the task
for Custom Applications and to load the environment variables:
const customApplications = require('@commercetools-frontend/cypress/task');module.exports = (on, cypressConfig) => {// Load the configif (!process.env.CI) {const path = require('path');const envPath = path.join(__dirname, '../.env');require('dotenv').config({ path: envPath });}on('task', {...customApplications,});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,},};};
In the cypress/support/commands.js
you need to import the following commands:
import '@testing-library/cypress/add-commands';import '@commercetools-frontend/cypress/add-commands';
We also recommend to define some constants:
export const projectKey = Cypress.env('PROJECT_KEY');// TODO: define the actual `entryPointUriPath` of your Custom Applicationexport const entryPointUriPath = '';export const applicationBaseRoute = `/${projectKey}/${entryPointUriPath}`;
Finally, in the cypress.json
you should set the baseUrl
to http://localhost:3001
.
At this point the setup is done. You can start writing your tests.
import { entryPointUriPath } from '../support/constants';describe('Channels', () => {beforeEach(() => {cy.loginByOidc({ entryPointUriPath });});it('should render page', () => {cy.findByText('Channels list').should('exist');});});