BETA

Testing

commercetools tries to provide first-class tooling for testing your application, so that you have a variety of options to choose from, whatever fits best with you.

commercetools recommends to use Jest as your testing framework. You can use our pre-configured configuration from the @commercetools-frontend/jest-preset-mc-app package.

Test utils for <ApplicationShell>

The @commercetools-frontend/application-shell package contains test-utils to simulate the components-under-test as if it was rendered by the <ApplicationShell>. The test-utils build on top of the @testing-library/react to allow writing user integration tests. See also Testing strategies.

When writing tests, you want to focus on testing the application specific logic. The test-utils abstract away the necessary setup logic of the Application Shell and provide different options to influence the behavior of the application context, including:

  • <IntlProvider>: for Internationalization and Localization via thereact-intl.
  • <ApolloProvider>: for GraphQL requests via the react-apollo.
  • <ConfigureFlopFlip>: for feature toggling via the flopflip.
  • <ApplicationContextProvider>: for holding context information about the Merchant Center application, like user, project, environment, dataLocale, and permissions via the application-shell-connectors.
  • <Router>: for routing via the react-router.

Usage

import { renderApp } from '@commercetools-frontend/application-shell/test-utils';
describe('rendering', () => {
it('should render a button', async () => {
const rendered = renderApp(<MyApplication />)
await rendered.findByLabelText('Submit');
})
})

@testing-library/react

@testing-library/react allows you to interact with the component using the DOM. It is a great testing library due to its philosophy of testing from a user-perspective, instead of testing the implementation. The assertions are written against the produced DOM, and the component-under-test is interacted with using DOM events.

The render method exposed by @testing-library/react is used to render your component and returns a bunch of getters to query the DOM produced by the component-under-test. <ApplicationShell>s test-utils export an enhanced renderApp method which adds more context to the component-under-test, so that it can be rendered as-if it was rendered by <ApplicationShell> itself.

All exports of @testing-library/react are re-exported from test-utils.

Basic concepts

This section introduces you to testing with test-utils.

We assume to have a component that renders the authenticated user's first name.

const FirstName = () => {
const user = useApplicationContext(context => context.user);
return (
<span>{`First name: ${user.firstName}`}</span>
);
};

This component uses the ApplicationContext which allows to access the context information provided by the <ApplicationShell>.

We can now test that the name is rendered:

import { renderApp } from '@commercetools-frontend/application-shell/test-utils';
describe('rendering', () => {
it('should render the authenticated users first name', async () => {
const rendered = renderApp(<FirstName />);
await rendered.findByText('First name: Sheldon');
});
});

This test renders the <FirstName> component and then verifies that the name "Sheldon" gets printed. "Sheldon" is the name of our default user in tests.

We can make the test more robust by explicitly declaring the authenticated users first name. This ensures the test keeps working even when the defaults change.

import { renderApp } from '@commercetools-frontend/application-shell/test-utils';
describe('rendering', () => {
it('should render the authenticated users first name', async () => {
const rendered = renderApp(<FirstName />, {
user: {
firstName: 'Leonard',
},
});
await rendered.findByText('First name: Leonard');
});
});

Here we explicitly assign a new user's firstName. The data we pass in gets merged with the default data.

When passing null for user the default user will not be added to the context and the component-under-test will get rendered as-if no user was authenticated. This also works for project and environment as you will see below.

The same applies for the other available properties of the application context: project, environment, etc.

Available methods

This section describes the methods exported by @commercetools-frontend/application-shell/test-utils.

The test-utils additionally re-export all the public methods of @testing-library/react for convenience.

renderApp(ui: ReactElement, options: Object)

ArgumentTypeConcernDescription
uiReact ElementReactReact Element to render.
options.localeStringLocalizationDetermines the UI language and number format. Is used to configure <IntlProvider>. Only core messages will be available during tests, no matter the locale. The locale can be a full IETF language tag, although the Merchant Center is currently only available in a limited set of languages.
options.dataLocaleStringLocalizationSets the locale which is used to display LocalizedStrings.
options.mocksArrayApolloAllows mocking requests made with Apollo. mocks is forwarded as the mocks argument to MockedProvider.
options.addTypenameBooleanApolloIf queries are lacking __typename (which happens when mocking) it’s important to pass addTypename: false, which is the default. See MockedProvider.addTypename for more information.
options.routeStringRouting The route the user is on, like /test-project/products. Defaults to /.
options.historyObjectRoutingBy default a memory-history is generated which has the provided options.route set as its initial history entry. It's possible to pass a custom history as well. In that case, we recommend using the factory function createEnhancedHistory from the @commercetools-frontend/browser-history package, as it contains the enhanced location with the parsed query object.
options.adapterObjectFeature TogglesThe FlopFlip adapter to use when configuring flopflip. Defaults to memoryAdapter.
options.flagsObjectFeature TogglesAn object whose keys are feature-toggle keys and whose values are their toggle state. Use this to test your component with different feature toggle combinations. Example: { betaUserProfile: true }.
options.environmentObjectRuntime configurationAllows to set the applicationContext.environment. The passed object gets merged with the tests default environment. Pass null to completely remove the environment, which renders the ui as if no environment was given.
options.userObjectApplication ContextAllows to set the applicationContext.user. The passed object gets merged with the tests default user. Pass null to completely remove the user, which renders the ui as if no user was authenticated.
options.projectObjectApplication ContextAllows to set the applicationContext.project. The passed object gets merged with the tests default project. Pass null to completely remove the project which renders the ui outside of a project context.

Additional return values

Calling renderApp returns the same object returned by the original render method of @testing-library/react, plus the additional entries:

EntryTypeDescription
historyObjectThe history created by renderApp which is passed to the router. It can be used to simulate location changes and so on.
userObjectThe user object used to configure <ApplicationContextProvider>, so the result of merging the default user with options.user. Note that this is not the same as applicationContext.user. Can be undefined when no user is authenticated (when options.user was null).
projectObjectThe project object used to configure <ApplicationContextProvider>, so the result of merging the default project with options.project. Note that this is not the same as applicationContext.project. Can be undefined when no project was set (when options.project was null).
environmentObjectThe environment object used to configure <ApplicationContextProvider>, so the result of merging the default environment with options.environment. Note that this is not the same as applicationContext.environment. Can be undefined when no environment was set (when options.environment was null).

renderAppWithRedux(ui: ReactElement, options: Object)

This render function simply wraps the renderApp with some extra components related to Redux. It is recommended to use this render function if some of your component-under-test uses Redux connect.

The function accepts all options from renderApp, plus the following:

ArgumentTypeConcernDescription
options.storeObjectReduxA custom redux store.
options.storeStateObjectReduxPass an initial state to the default Redux store.
options.sdkMocksArrayReduxAllows mocking requests made with @commercetools-frontend/sdk (Redux). The sdkMocks is forwarded as mocks to the SDK test-utils.
options.mapNotificationToComponentFunctionReduxPass a function to map a notification to a custom component.

Some examples:

  • Using a different application locale

    import { renderApp } from '@commercetools-frontend/application-shell/test-utils';
    const Flag = props => {
    const intl = useIntl();
    if (intl.locale.startsWith('en-US')) return '🇺🇸';
    if (intl.locale.startsWith('en')) return '🇬🇧';
    if (intl.locale.startsWith('de')) return '🇩🇪';
    return '🏳️';
    };
    describe('Flag', () => {
    it('should render the british flag when the locale is english', async () => {
    const rendered = renderApp(<Flag />);
    await rendered.findByText('🇬🇧');
    });
    it('should render the german flag when the locale is german', async () => {
    const rendered = renderApp(<Flag />, { locale: 'de' });
    await rendered.findByText('🇩🇪');
    });
    });
  • Using a different locale for data localization

    import { renderApp } from '@commercetools-frontend/application-shell/test-utils';
    const ProductName = props => (
    <ApplicationContext
    render={applicationContext =>
    props.product.name[applicationContext.project.dataLocale]
    }
    />
    );
    const ProductName = (props) => {
    const projectDataLocale = useApplicationContext(context => context.project.dataLocale);
    return (
    <span>{`Product name: ${props.product.name[projectDataLocale]}`}</span>
    );
    };
    describe('ProductName', () => {
    const partyParrot = {
    name: { en: 'Party Parrot', de: 'Party Papagei' },
    };
    it('should render the product name in the given data locale', async () => {
    const rendered = renderApp(<ProductName product={partyParrot} />, {
    dataLocale: 'en',
    });
    await rendered.findByText('Product name: Party Parrot');
    });
    it('should render the product name in the given data locale', async () => {
    const rendered = renderApp(<ProductName product={partyParrot} />, {
    dataLocale: 'de',
    });
    await rendered.findByText('Product name: Party Papagei');
    });
    });
  • Using GraphQL mocks

    import gql from 'graphql-tag';
    export const BankAccountBalanceQuery = gql`
    query BankAccountBalanceQuery {
    account {
    balance
    }
    }
    `;
    export const BankAccountBalance = props => (
    <Query
    query={BankAccountBalanceQuery}
    variables={{ token: props.token }}
    >
    {payload => {
    if (!payload || !payload.data || !payload.data.account) {
    return <span>'Loading..'</span>;
    };
    return <span>`Your balance is ${payload.data.account.balance}`</span>;
    }}
    </Query>
    );
    import { renderApp } from '@commercetools-frontend/application-shell/test-utils';
    import {
    BankAccountBalance,
    BankAccountBalanceQuery,
    } from './bank-account-balance';
    describe('BankAccountBalance', () => {
    it('should render the balance', async () => {
    const rendered = renderApp(<BankAccountBalance token="foo-bar" />, {
    mocks: [
    {
    request: {
    query: BankAccountBalanceQuery,
    variables: { token: 'foo-bar' },
    },
    result: { data: { account: { balance: 300 } } },
    },
    ],
    });
    await rendered.findByText('Loading...');
    await wait(() => {
    expect(rendered.queryByText('Your balance is 300€')).toBeInTheDocument();
    });
    });
    });
  • Using SDK mocks

    import { useOnActionError } from '@commercetools-frontend/actions-global';
    import {
    actions as sdkActions,
    useAsyncDispatch,
    } from '@commercetools-frontend/sdk';
    const initialState = {
    isLoading: true,
    }
    const reducer = (state = initialState, action) => {
    switch (action.type) {
    case 'success':
    return { isLoading: false, data: action.payload };
    case 'failure':
    return { isLoading: false, error: action.payload };
    default
    return state
    }
    };
    const BankAccountBalance = props => {
    const [state, dispatch] = React.useReducer(reducer, initialState);
    const dispatchFetchAction = useAsyncDispatch();
    const onActionError = useOnActionError();
    React.useEffect(() => {
    try {
    const response = await dispatchFetchAction(
    sdkActions.get({
    uri: '/account/balance',
    headers: {
    Authorization: props.token,
    },
    })
    );
    dispatch({ type: 'success', payload: response.balance });
    } catch (error) {
    dispatch({ type: 'failure', payload: error });
    onActionError(error);
    }
    }, [props.token]);
    if (state.isLoading) {
    return (<span>'Loading..'</span>);
    }
    return (<span>`Your balance is ${this.state.accountBalance}`</span>);
    };
    export default BankAccountBalance;
    import { renderAppWithRedux } from '@commercetools-frontend/application-shell/test-utils';
    import BankAccountBalance from './bank-account-balance';
    describe('BankAccountBalance', () => {
    it('should render the balance', async () => {
    const rendered = renderAppWithRedux(
    <BankAccountBalance token="foo-bar" />,
    {
    sdkMocks: [
    {
    action: {
    type: 'SDK',
    payload: {
    method: 'GET',
    uri: '/account/balance',
    headers: {
    Authorization: 'foo-bar',
    },
    },
    },
    response: {
    balance: 300,
    },
    },
    ],
    }
    );
    await rendered.findByText('Loading...');
    await wait(() => {
    expect(rendered.queryByText('Your balance is 300€')).toBeInTheDocument();
    });
    });
    });
  • Using feature toggles

    import { renderApp } from '@commercetools-frontend/application-shell/test-utils';
    import { useFeatureToggle } from '@flopflip/react-broadcast';
    const Profile = props => {
    const showAge = useFeatureToggle('experimentalAgeOnProfileFlag');
    return (
    <div>
    {props.name}
    {props.showAge && `(${props.age})`}
    </div>
    );
    };
    describe('Profile', () => {
    const baseProps = { name: 'Penny', age: 32 };
    it('should show no age when feature is toggled off', async () => {
    const rendered = renderApp(<Profile {...baseProps} />, {
    flags: { experimentalAgeOnProfileFlag: false },
    });
    await rendered.findByText('Penny');
    await wait(() => {
    expect(rendered.queryByText('32')).not.toBeInTheDocument();
    });
    });
    it('should show age when feature toggle is on', () => {
    const rendered = renderApp(<Profile {...baseProps} />, {
    flags: { experimentalAgeOnProfileFlag: true },
    });
    await rendered.findByText('Penny (32)');
    });
    });
  • Using the router

    import { Switch, Route, Redirect } from 'react-router-dom';
    import { renderApp } from '@commercetools-frontend/application-shell/test-utils';
    const ProductTabs = () => (
    <Switch>
    <Route path="/products/:productId/general" render={() => 'General'} />
    <Route path="/products/:productId/pricing" render={() => 'Pricing'} />
    {/* Define a catch-all route */}
    <Redirect from="/products/:productId" to="/products/:productId/general" />
    </Switch>
    );
    describe('router', () => {
    it('should redirect to "general" when no tab is given', async () => {
    const rendered = renderApp(<ProductTabs />, {
    route: '/products/party-parrot',
    });
    await rendered.findByText('General');
    });
    it('should render "general" when on general tab', async () => {
    const rendered = renderApp(<ProductTabs />, {
    route: '/products/party-parrot/general',
    });
    await rendered.findByText('General');
    });
    it('should render "pricing" when on pricing tab', async () => {
    const rendered = renderApp(<ProductTabs />, {
    route: '/products/party-parrot/pricing',
    });
    await rendered.findByText('Pricing');
    });
    });

Testing permissions

User permissions are bound to a project and can vary depending on the permissions assigned to the team where the user belongs to.

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

  • allAppliedPermissions: pass a list of 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 resource, prefixed with can. For example, canManageProjectSettings, canViewOrders, etc.
    • value: true if the resource should be applied or not.
  • allAppliedActionRights: pass a list of action rights that the user should have for the given project. An action right is an object with the following shape:
    • group: the group of the permission where the action right should be applied to. For example, orders, products, etc.
    • name: the name of the action right, prefixed with can. For example, canEditPrices, canPublishProducts, etc.
    • value: true if the resource should be applied or not.
  • allAppliedDataFences: pass a list of data fences that the user should have for the given project. A data fence is an object with the following shape:
    • type: the type of data fence. For example, store.
    • group: the group of the permission where the action right should be applied to. For example, orders, products, etc.
    • name: the name of the resource, prefixed with can. For example, canManageProjectSettings, canViewOrders, etc.
    • value: true if the resource should be applied or not.

Permissions are managed by the @commercetools-frontend/permissions package.

import { renderApp } from '@commercetools-frontend/application-shell/test-utils';
const DeleteProductButton = () => {
const canManageProducts = useIsAuthorized({
demandedPermissions: ['ManageProducts'],
});
return (
<button type="button" onClick={() => {}} disabled={!canManageProducts}>
{'Delete Product'}
</button>
);
};
describe('DeleteProductButton', () => {
it('should be disabled when the user does not have permission to manage products', async () => {
const rendered = renderApp(<DeleteProductButton />, {
permissions: { canManageProducts: false },
});
await wait(() => {
expect(rendered.queryByText('Delete Product')).toBeDisabled();
});
});
it('should be enabled when the user has permission to manage products', async () => {
const rendered = renderApp(<DeleteProductButton />, {
permissions: { canManageProducts: true },
});
await wait(() => {
expect(rendered.queryByText('Delete Product')).not.toBeDisabled();
});
});
});