Testing Frontend Modules: O3
Broadly speaking, when it comes to testing components in O3, there are three categories of tests:
Unit tests - verify that pieces individual, isolated pieces of functionality work together as expected.
Integration tests - verify that several units work together in harmony.
End to End (e2e) tests - a helper robot that behaves like a user to click around the app and verify that it functions correctly.
When working with a frontend module, its accompanying unit (and, sometimes, integration) tests will typically be colocated in a file named *.test.tsx. These tests typically follow the following pattern:
Integration tests typically render the full app while mocking out a few bits like network requests or database access.
Unit tests typically test pure functions and assert that they return a given output when given some input.
e2e tests typically run the entire application (both frontend and backend) and your test will interact with the app just like a typical user would.
ℹ️
Testing philosophy: The more your tests resemble the way your software is used, the more confidence they can give you.
Unit testing
We’re going to walk through an example test suite to understand the approach taken. In this case, we’ll use the module-management(opens in a new tab) frontend module. This application takes the Manage Module
page from the OpenMRS 2.x reference application and redesigns it to work within O3. It provides an interface for users to manage backend modules. It lists all the installed modules and allows admins to control the execution of modules via the exposed Start
, Stop
and Unload
actions. Users can also view detailed information about the listed modules.
Let’s look at how we could test the ModuleManagement
component. Looking at its code, we can see that it does several things:
It fetches module data from the backend using a custom SWR hook.
When data is loading, a loading state gets displayed.
When data is loaded but empty, an empty state is displayed.
When a problem occurs while fetching data, an error state is displayed.
When data gets fetched, a Carbon datatable containing information about existing modules is rendered.
If the user viewing the module list has sufficient privileges, action buttons get shown on the page.
We could write a test suite that tests each of these pathways.
Write the test
Create a new file next to module-management.component.tsx
named module-management.test.tsx
.
Setup a function that renders the component:
function renderModuleManagement() { renderWithSwr(<ModuleManagement />);}
Note: This renderWithSwr
helper function wraps a component in an SWR context which provides a global configuration for all SWR hooks.
Test that a loading state gets rendered
We don’t want to directly hit the database with our test and to avoid that, we could mock the data fetching logic as follows:
mockedOpenmrsFetch.mockReturnValueOnce({ data: { results: [] } });
This code mocks out the openmrsFetch
function, stubbing out its implementation and replacing it with a mock that returns an object with a results
property that's an empty array.
Next, we want to render the component:
await waitForElementToBeRemoved(() => [...screen.queryAllByRole(/progressbar/i)], { timeout: 4000,});
This code asks Jest to wait until the loader gets removed from the UI. This mimics waiting for a backend request to resolve. Next, we want to start writing our assertions:
These assertions:
Assert that a DOM element with the role
progressbar
should not be in the document.Assert that the DOM doesn’t contain an element of the role
table
.Assert that the text
There are no modules to display
gets rendered in the document.
At any point, you can run tests by calling yarn test
or yarn turbo test
(if turbo
is configured).
To evaluate the error state, you could write:
To test the happy path, we could write the following test:
There’s a lot going on here, so let’s attempt to break it down. To start off, we’re invoking the userEvent
setup before the component gets rendered. Next, we’re setting up an object called testModules
which mimics the data we’d typically expect to get back from the backend. We’re then setting up the mockedOpenmrsFetch
function to return the mock data. After that, we can now finally render the component. Because we’re simulating a delay, we’ll wait for the loading state to complete before running our assertions. Once that’s done, we’ll run assertions against the module management datatable, comparing table headers and row data against our expected data. We’ll then simulate searching through the list, exploring both the happy path and the empty state.
Whereas this test passes and can give us some amount of confidence that this component is working as expected, there are still a few weaknesses with this approach. For example:
Because we’re not looking at the API endpoint and the request parameters, we can’t tell if the user is making the correct request.
Because we’re mocking the backend, we can’t confidently predict what will happen if the real server is down, or if it returns an unexpected result.
Ultimately, to get the right testing strategy, you’ll likely require a mix of unit, integration, and e2e tests. You’ll also need to figure out the right level of mocking to apply. Mock too little, and you’re likely testing too many implementation details. Mock too much, and you’re likely sacrificing a lot of confidence.
Here's a cool video by Brandon on testing frontend modules:
Mocking Patterns
When writing tests, you’ll often need to mock out dependencies. Here are a few patterns you can use to write more effective, maintainable and type-safe tests for your frontend modules:
Core exports a module mock that can be used to test functionality in @openmrs/esm-framework
. You'll typically have a Jest configuration file that sets up the mock for you like so:
This line tells Jest to use the mock module for all imports of @openmrs/esm-framework
. This is useful because it allows you to test your module in isolation without having to worry about the framework.
The framework mock aggregates stubs for all the framework APIs that your module might use. If functionality is missing, you should first add it to the mock. You can do this by extending any of the existing stubs or adding new ones.
You should not need to use partial mocks
The framework mock should be sufficient for testing your module. If you find yourself needing to mock individual functions or classes, it's likely that the framework mock is missing functionality. You may have seen a pattern like this in the past:
End-to-end testing with Playwright
Playwright(opens in a new tab) is a testing framework that allows you to write reliable end-to-end tests in JavaScript. Great work by the OpenMRS QA team means that we now have Playwright set up across most of our key repositories.
This means that we can now write e2e tests for key user journeys in the OpenMRS frontend. These tests are setup to run both on commit and merge. Developers are encouraged to keep the tests passing, and wherever possible, consider extending them to fit any new use cases. Ideally, each pull request ought to be accompanied by the tests that assert that the logic presented works as expected.
To get started, you’ll typically need to install playwright:
We recommend installing the official Playwright VS Code plugin(opens in a new tab). This will give you access to the Playwright test runner, which you can use to run your tests. You can also use the npx playwright test
command to run your tests. We also recommend following the best practices(opens in a new tab) outlined in the Playwright documentation. This will help you write tests that are reliable and easy to maintain.
Writing tests
In general, it is recommended to read through the official Playwright docs(opens in a new tab) before writing new test cases. The project uses the official Playwright test runner and, generally, follows a very simple project structure:
When you want to write a new test case, start by creating a new spec in ./specs
. Depending on what you want to achieve, you might want to create new fixtures and/or page object models. To see examples, have a look at the existing code to see how these different concepts play together.
The spec files contain scenarios written in a BDD-like syntax. In this syntax, we utilize Playwright's test.step
calls and emphasize a user-centric focus by using the "I" keyword. For more information on BDD-like syntax, you can refer to the documentation at https://cucumber.io/docs/gherkin/reference/(opens in a new tab). This resource provides detailed information on Gherkin, the language used for writing BDD specifications. The code snippet below demonstrates how this BDD-like syntax is employed to write a test case:
Running tests
To run E2E tests locally, follow these steps:
Start the Development Server
Begin by spinning up a development server for the frontend module that you want to test. Ensure the server is running before proceeding.
Set Up Environment Variables
Copy the example environment variables into a new .env file by running the following command:
Run the tests in headed(opens in a new tab) and UI(opens in a new tab) modes:
To run a single test file, pass in the name of the test file that you want to run:
To run a set of test files from different directories, pass in the names of the directories that you want to run the tests in.
To run files that have the text conditions
in the file name, simply pass in these keywords to the CLI:
To run a test with a specific title, use the -g flag followed by the title of the test: