ERM Unit Testing in RTL/Jest

Out of date

This information may be out of date. Practices for erm apps specifically have shifted from the stripes standard,  using instead a centralised library sitting between stripes-testing and our modules stripes-erm-testing. The approach to mocking etc has also shifted, and should now be more in line with the jest practices outlined in their documentation.

This document is a starting/reference guide for writing unit tests for the UI modules within ERM. 

RTL (React testing library) is a testing utility for React that has been adopted by the FOLIO community as the framework for writing Unit tests within the UI modules. 

This doc is a good place to start for understanding RTL and what problem it essentialy solves within the UI testing world.

Jest is the test runner which react recommends and works alongside RTL. Jest lets you access the dom and make assertions against it within your tests.

RTL/JEST setup within ERM:

    1. The setup for the RTL/jest infrastructure lies within the stripes-erm-components under the test/jest folder. (PR that set it up for reference)
    2. We have chosen stripes-erm-components to place this infrastructure because all the ERM UI modules have a dependency on stripes-erm-components which allows us to place the test infrastructure in just one module and pull it directly into the individual ui-* modules with a failry simple setup ( ui-agreements). The setup is pulled from this config file in erm-components
    3.  The __mock__ folder mocks the entire underlying stripes libraries, for eg: stripes-core, stripes-smart-components, stripesConfig, stripes-connect etc. These mocks are what let us not worry about any stripes dependencies we may have while testing, and just be concerned with testing the individul UI components in isolation.

Note: Its very important all the developers understand what each of the mock is actually mocking and also understand the jest configuration within the jest.config.js file. Understanding these details will help debug the tests quickly specially when the errors/issues within the tests are caused at the stripes level.

Writing RTL tests:

Before writing the RTL tests, its important to arrange where you would want to place the test file. RTL picks up test files with extension __filename__.test.jsStructurally its easier to group the test file alongside the actual component thats being tested. Refer to the AddToBasketButton folder that has the component and its corresponding test placed in the same folder. 

Once the test file has been created, we are good to write the tests.

This is a very nice blog that helps in setting up and writing tests in RTL/Jest.

An RTL test typically has 5 steps (reference)

    1. Imports
    2. Mock
    3. Arrange
    4. Act
    5. Assert

Imports:

This section includes all the necessary imports that are needed within the test file. Typically this includes:

    • Importing React
    • Importing the stripes mocks from erm-components
    • Importing the TestForm (if your component is rendered within a Form), renderWithIntl (function that renders the component to the dom with necessary translations)
    • userEvent thats used to trigger events such as clicks, hover etc
    • Finaly import the component that is being tested


Mock:

Apart from the stripes/intl mocks that were imported in the previous step from erm-components, this section includes mocks such as mocking an API request, or mocking  a callback (spy). We use jests mocking API for this purpose.

You can mocking an onSubmit or an onAdd callback like so:

or you can go one step further and mock the implementation itself:

This mock overrides the default onSubmit functionality and logs 'submit' to the console when its triggered.

Going through the mocks in erm-components should give you a better understanding of how mocking works.

Note: You can clear the mocks using the mockClear() helper from jest. This is useful when you want to mock the function only for a specific
test and want to revert to the default implementation before the next step.

Arrange: 

The next step is to render the component using either the render helper or renderWithIntl (which is just a wrapper around the render helper but also includes translations).

You can render the component simply by calling the render method. 

Likewise we can use the renderWithIntl helper instead to include translations.

Note: translationProperties returns list of translations that needs to be passed in to the renderWithIntl helper.
If we do not pass it, then a translation entry such as 'ui-agreements.agreement: agreement' would be rendered as
'ui-agreements.agreement' instead of 'agreement' in the dom.

Act:

This step is where we can trigger user actions such as clicks, hover etc.

Checkout the userEvent api here which we use to trigger these actions.

To trigger a button with a label 'Submit', you could use the userEvent.click api:

Assert:

Final step is to assert if your test is behaving as expected.

The following assertion expects the handleSubmit callback to be called once when the submit button is clicked.


Queries

Before moving on to an example, queries are an important aspect of testing and choosing the right query within your tests is crucial.

Queries are the methods that RTL gives you to find elements on the page. There are a number of ways in which we can grab an element on a page. 

The following table describes the selectors that you need to use as a priority.

Priority should be give according to the priorities listed here.

getByRole and getBylabelText need to be the highest priority to find an element on the DOM.

           To grab a button by the label Save & close, you can either use:

     1) getByRole('button', { name: 'Save & close' })
2) getBylabelText('Save & close')
     Note: You might be tempted to pass data-testid to an element and find an element using the getByTestId query. This attribute is 
provided by RTL only as an escape hatch and should be avoided wherever possible. The attribute is going to eventualy end up
on the dom and we do not want to pollute the dom with avoidable attributes.

Putting all the above steps together:

import React from 'react';
import '@folio/stripes-erm-components/test/jest/__mock__';
import { TestForm, renderWithIntl } from '@folio/stripes-erm-components/test/jest/helpers';
import userEvent from '@testing-library/user-event';
import BasketSelector from './BasketSelector';
const onAdd = jest.fn();
const onSubmit = jest.fn();

const basketSelectorProps = {
'addButtonLabel':'add button',
'autoFocus':true,
'basket':[{
'id':'5650325f-52ef-4ac3-a77a-893911ccb666',
'class':'org.olf.kb.Pkg',
'name':'Edward Elgar:Edward Elgar E-Book Archive in Business & Management, Economics and Finance:Nationallizenz',
'suppressFromDiscovery':false,
'label': 'basketSelector'
}]
};

describe('BasketSelector', () => {
test('renders add To Basket button', () => {
const { getByText } = renderWithIntl(
<TestForm onSubmit={onSubmit}>
<BasketSelector {...basketSelectorProps}/>
</TestForm>
);

   expect(getByLabelText(/basketSelector/i)).toBeInTheDocument();
userEvent.click(getByText('add button'));
expect(onAdd.mock.calls.length).toBe(1);
});
});

Running the tests locally

To run the test in your local workspace:

1) Navigate to the app folder (eg: ui-agreements) and run

      yarn test:jest

 This should run all the tests within the app.

2) If you would like to run one specific test, you can specify the name of the test you would like to run like:

      yarn test:jest BasketSelector;

Running Coverage report

Once you run the tests locally you should have an artifacts folder generated locally in your app folder.

To generate coverage report:

1) From your app folder navigate to:

    cd artifacts/coverage-jest/lcov-report/

 2) In this folder you should see an index.html file which you can statically serve by installing a simple npm module like serve and run:

    serve ./
This command will serve up a UI(defaults to localhost:5000) where you can see the coverage metrics

You can go into the specific file to see what lines/functions are missing in the code coverage and can add them to the tests accordingly.

Idealy we would like to have 100% coverage on all the components. Sometimes it feels too trivial to cover certain cases, i.e. the effort in writing the test outweighs the condition you are checking for and that decision is left to the developer.

Using Interactors

One of the advantages of having a custom design system and component library within FOLIO is that we don't start from scratch when writing tests that target an application using it. We know very precisely what the structure the DOM will take, and we can use that knowledge to our advantage when it comes to writing tests, both for manipulating the UI and also for making assertions against it.

Interactors provide us an abstraction between the HTML of our tests, so that not only are tests easy to write, but as the underlying components change and evolve, the tests do not need to change. 

Refer the documentation for interactors to find the list of all the interactors currently available. A good way to understand how tests need to be structured/written using the interactors is to differentiate between the traditional RTL approach and the interactors way. Imagine we are writing a test to assert that a button (rendered via the stripes component Button component) with a specific label is rendered.

           RTL approach:
expect(getByLabelText(/Add Button/)).toBeInTheDocument;

Using Interactors:
await Button('Add Button').exists();

The Button interactor here targets the Stripes component Button under the hood via a css selector which we dont have to worry about while writing the test and can just be confident that if there is a button in the dom with a label Add Button the interactor will catch that button.

The EresourceSections have a good set of tests that currently use the MCL, KeyValue, Button and TextField interactors. The documentation for the interactors as pointed above list all the different types of filters, actions and locators that the interactor accepts.

Writing tests for Route level components:

The top level Route components within ERM apps serves as a single component for fetching all the data at the top level required by all its nested children and defining the callback handlers to be passed to the child components. This makes testing these Route level components slightly tricky. The reason being  Route component acts more like a container but not strictly presentation which makes it difficult to write UI tests as there is no DOM to work against and test out the long list of callback handlers each of them contains. Sure, we can let the child components render as is without being mocked but we dont do that as an overall practise within ERM apps and let the tests be as 'unit'y as possible instead of breaking into the integration tests realm.

Testing the route component just to check if the mocked child component has been rendered to the dom as expected is one test scenario but we need lot more to actually test the handlers defined within the Route. To tackle this problem, we mock the child component in a way that also render mock Buttons with our custom callback handlers and we click them via our tests to trigger the callbacks and verify if they are doing the right thing.


Example scenario: Testing the AgreementEditRoute which renders an AgreementForm:


          The AgreementForm component receives the following handlers onBasketLinesAdded, OnClose, etc passed in from the Route component. To make sure that these callbacks are invoked me create mock Buttons and pass these callbacks which gets triggered on an OnClick of those Buttons.

          eg: 


           

            To render this Button to the dom, our AgreementForm mock needs to be like so:

           


To test the callback has been invoked when the Button is clicked:

queryUpdateMock (a mock that mocks the query.update() function call within the Route) in this case is what gets invoked when the callback handler for the onBasketLinesAdded is called.

Please refer to the test file https://github.com/folio-org/ui-agreements/blob/master/src/routes/AgreementEditRoute/AgreementEditRoute.test.js for more exmplaes

Important note: In the above scenario we are testing if the queryUpdateMock is what gets called, but ideally accordion to RTL philosophy, you test for what changes on the UI when the callback is invoked. If there is an option to test the visual changes on the UI based on the invoked callback, that should be preferred over testing out the implementation details of the callback.

Debugging

https://testing-library.com/docs/dom-testing-library/api-debugging/

TODO

FAQs

Q) What should I do if the screen.debug doesnt display the entire dom

A) The screen.debug has a default limit as to how many number of lines it can display from the dom. To increase this you can run your test such as:

          DEBUG_PRINT_LIMIT=10000 yarn test:jest

               That should suffice. If it doesnt, just increase the limit.