Skip to content

Commit

Permalink
feat(tests): Setup unit testing framework (#393)
Browse files Browse the repository at this point in the history
* Setup test for Recordings.tsx; begin by mocking out child components

* Add second test to help troubleshoot slow test runs. Edit Jest configuration to fix slow test runs. Add empty test-setup.js file for potential future use. Add React Testing Library dependency

* Update tests using React Testing Library

* Add Enzyme rendering comparison to test

* Continue working on Recordings test

* Add children prop to Jest mocks

* Mock out child components

* Test now works with useContext and useEffect hooks

* Commit all changes so far, including dependencies

* Modify app.test.tsx to use React Testing Library. Add new dependencies for userEvent

* Start working on mocking fetch

* Mock out all the services which are shared across the app, helping isolate components which depend on the ServiceContext. Delete outdated snapshot causing error

* Temporarily delete failing tests

* fixup! About and Recordings tests now working

* Refactor

* Refactor

* Re-upload App test file to fix the Git commit history

* Fix newlines

* fixup! Fix newlines

* Delete App test

* fixup! Delete App test

* fixup! Delete App test

* fixup! Delete App test

* fixup! Delete App test

* fixup! Delete App test

* fixup! Delete App test

* Provide direct path to the Jest binary

* Re-install dependencies and update lock file

* Re-add configurations for performance increase

* Add typings for react-test-renderer

* Reorganize tests into separate folder and add snapshot testing

* Experiment with mocking Tabs onSelect

* Fix Jest parsing error

* Add comments explaining configurations

* Test activeTab state changes

* Unmock Patternfly components in order to follow RTL best practices

* Update tests and snapshots with best practices

* Update import paths for @app/ components

* Update About test to correctly test for logo

* Refactor/cleanup formatting. Add test for TargetView title

* Render Recordings.tsx snapshot with the archive enabled

* Minor formatting

* Change ids to be more descriptive

* Revert enum referencing change

* Cleanup props usage in mocks

* Remove unused import

* Disable adding license to snapshots

* Test fix formatting by adding terminating semicolon

* fixup! Test fix formatting by adding terminating semicolon

* Formatting

* Remove then re-install new dependencies properly through yarn add

* Update Recordings component reference snapshot

* Create Jest CI configuration separate from local

* Test CI testing with --maxWorkers=50%

* Test CI testing with no --coverage

* Test CI testing with max. num. usable threads

* Finalize testing configurations for local and CI environments. Delegate code coverage output decision to these configurations

* fixup! Finalize testing configurations for local and CI environments. Delegate code coverage output decision to these configurations

* Add TESTING.md draft file with important tips for testing

* Exclude TESTING from license configuration

* Finish writing the TESTING.md file

* fixup! Finish writing the TESTING.md file

* Update build script to include tests

Co-authored-by: Andrew Azores <aazores@redhat.com>
  • Loading branch information
Hareet Dhillon and andrewazores authored Apr 3, 2022
1 parent 4b5bcfb commit cd3f58c
Show file tree
Hide file tree
Showing 13 changed files with 725 additions and 83 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ jobs:
- run: yarn license:check
# - run: yarn lint
- run: yarn build
- run: yarn test:ci
79 changes: 79 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Unit Testing

Refer to this document for information on how to unit test Cryostat Web.

## LIBRARIES

* [Jest](https://jestjs.io/) is a Javascript testing framework used to create, run and structure unit tests. Jest also provides built-in mocking capabilities.

* [React Testing Library (RTL)](https://testing-library.com/docs/react-testing-library/intro/) is used to test the React components comprising Cryostat Web. It gives you the ability to render components into their [HTML Document Object Model (DOM)](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Introduction) representation (i.e. what the user “sees” when they visit a webpage) and [query](https://testing-library.com/docs/queries/about/)/assert on the nodes and objects in the DOM. For example, a node could be a `<button />` element that we query for and perform assertions or actions (such as a “click”) on (i.e. what the user “does” when they interact with the Cryostat Web UI).

* [Test Renderer](https://reactjs.org/docs/test-renderer.html) is used to render components into their React virtual DOM representation, a lightweight abstraction of the actual HTML DOM, consisting of pure Javascript. The render result is used to perform [snapshot testing](https://jestjs.io/docs/snapshot-testing).

## CONFIGURATION

* `jest.config.js` contains various configuration [options](https://jestjs.io/docs/configuration) for Jest.

* `test-setup.js` allows you to set up the testing framework before any tests are run. This file is designated by the `setupFilesAfterEnv` flag in `jest.config.js`.

* `package.json` contains the `test` and `test:ci` scripts which run the Jest test suite with different CLI options for local and Github CI testing, respectively.

## UNIT TESTING

### Overview

Use Jest's [`describe`](https://jestjs.io/docs/api#describename-fn) function to group related unit tests into a single block. The tests themselves are denoted using the [`test`](https://jestjs.io/docs/api#testname-fn-timeout) or its alias `it`. Jest also provides an extensive list of ["matchers"](https://jestjs.io/docs/expect) for making assertions. These Jest utilities do not need to be imported.

In order to render the component under test into its HTML DOM representation and perform queries on this representation, use RTL's `render` function in conjunction with `screen`, both of which can be imported from `@testing-library/react`. After the `render` call, the `screen` object can be [`queried`](https://testing-library.com/docs/queries/about) for DOM nodes/elements, which in turn can be asserted on using the aforementioned Jest matchers. There is typically one `render` call per unit test.

### Tips

* If you insert `screen.debug()` after the `render` call for the component under test and then run the test suite, the HTML DOM representation of the component will be output to the CLI.

* The `toBeInTheDocument` matcher is convenient for when you want to simply assert on the presence of an element in the HTML DOM. However, it is not offered by Jest but instead imported from `@testing-library/jest-dom`.

* The `within` function from `@testing-library/react` can be used to perform queries within nested elements in the HTML DOM.

* Import [`userEvent`](https://testing-library.com/docs/ecosystem-user-event) from RTL's companion library `@testing-library/user-event` in order to simulate user actions such as clicking a button.

## MOCKING

### Overview

Refer to the Jest documentation for various mocking techniques, including [mock functions](https://jestjs.io/docs/mock-functions) and more advanced strategies such as [manual mocks](https://jestjs.io/docs/manual-mocks).

The decision to mock out a component during testing should adhere to RTL's guiding principle that [“the more your tests resemble the way your software is used, the more confidence they can give you”](https://testing-library.com/docs/guiding-principles/). Therefore, when unit testing a component make an effort to only mock out API calls, child components that belong to Cryostat Web (since they’ll have their own unit tests), and the shared services that are propagated throughout the app using the `ServiceContext`. Any third-party child components, such as those belonging to Patternfly, should be left unmocked if possible.

### Tips

* [`jest.mock`](https://jestjs.io/docs/jest-object#jestmockmodulename-factory-options) implementations need to be defined outside the `describe` block housing the unit tests in the test file.

* Make sure to import the component under test last. In Jest, any `jest.mock` calls are automatically hoisted to the top of the file, above the imports. This ensures that when modules are imported, Jest knows to replace the real implementations with the mocked versions. However, the actual mock implementation code isn’t processed until the component under test is imported, which is why it’s important to do this import last so that any imported modules used inside the implementations will not end up undefined.

* Use [`jest.requireActual`](https://jestjs.io/docs/jest-object#jestrequireactualmodulename) when you need the actual implementation of a mocked module. It can also be used to partially mock modules, allowing you to pick and choose which functions you want to mock or leave untouched.

* Unlike `jest.mock`, [`jest.doMock`](https://jestjs.io/docs/jest-object#jestdomockmodulename-factory-options) calls are not hoisted to the top of files. This is useful for when you want to mock a module differently across tests in the same file.

* Even though it is possible to test props directly by interacting with the mock instances receiving them, props should instead be indirectly tested by querying the rendered HTML DOM. Remember, from the user perspective all they see is this render result while having no knowledge of the underlying props used.

## SNAPSHOT TESTING

### Overview

Snapshot testing helps ensure that we stay on top of any changes to our UI. It’s a complement to regular unit testing, in which we render React components, take a serialized snapshot of the result, and compare it to a reference snapshot file to see if anything has changed. Snapshot files are committed to version control alongside their corresponding tests and are included in the review process.

When the Jest test suite runs, a new snapshot will be created for every component under test and compared to the reference snapshot in version control. If there is any discrepancy between the two snapshots a diff will be output to the command line. From here, it is up to you to determine whether the difference is due to a bug or an intentional implementation change. This may warrant updating or adding more unit tests. When you are satisfied with the reasons behind the changed snapshot, you can update it to be the new reference snapshot by running the following command:

```
npm run test -- -u -t=”SPEC_NAME”
```

Where the `-u` flag tells Jest to update the snapshot and the `-t` flag specifies which test to update it for. `SPEC_NAME` is matched against the string passed into the `describe` call of the test file in question. For example, in `Recordings.test.tsx` the unit tests are housed inside of the `describe(‘<Recordings />’, ….)` block so in order to update the snapshot for the `Recordings` component, you would pass `-t=”<Recordings />”` to the above command.

### Tips

* Use the `create` function from the `react-test-renderer` library to render components into their React virtual DOM representation for snapshot testing. See [here](https://javascript.plainenglish.io/react-the-virtual-dom-comprehensive-guide-acd19c5e327a) for a more detailed discussion on the virtual DOM.

* If the component you would like to snapshot test uses `React.useEffect`, you may need to use the asynchronous `act` function from the `react-test-renderer` library to ensure the snapshot of the component is accurate. `React.useEffect` calls are run only after the render of a component is committed or "painted" to the screen. However, the nature of the virtual DOM is such that nothing is painted to the screen. Fortunately, the `act` function ensures that any state updates and enqueued effects will be executed alongside the render.

* Some PatternFly components use random, dynamic strings as `ids` which will then be displayed as elements in the rendered React virtual DOM. These strings change upon every render, causing snapshots to fail even though the component under test is still functionally the same. This can be remedied by supplying [custom `ids` as props](https://github.com/patternfly/patternfly-react/issues/3518) to the culprit PatternFly child components inside the source file of the component under test.
16 changes: 12 additions & 4 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ module.exports = {
// Automatically clear mock calls and instances between every test
clearMocks: true,

// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,

// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',

Expand Down Expand Up @@ -51,5 +48,16 @@ module.exports = {
// A map from regular expressions to paths to transformers
transform: {
"^.+\\.(ts|tsx)$": "ts-jest"
}
},

// A set of global variables that need to be available in all test environments
globals: {
'ts-jest': {
isolatedModules: true
}
},

// An array of regexp pattern strings that are matched against all source file paths before transformation.
// If the file path matches any of the patterns, it will not be transformed.
transformIgnorePatterns: ["/node_modules/(?!@patternfly)"]
};
2 changes: 1 addition & 1 deletion license-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
}
}
},
"ignore": [".git", "tags", "coverage", "stories", "__mocks__","**/.*", "README.md", "**/*.js", "yarn.lock", ".github/**/*", "src/app/assets/*"]
"ignore": [".git", "tags", "coverage", "stories", "__mocks__","**/.*", "README.md", "TESTING.md", "**/*.js", "yarn.lock", ".github/**/*", "src/app/assets/*", "src/test/**/*.snap"]
}
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
"license": "UPL",
"private": true,
"scripts": {
"build": "webpack --config webpack.prod.js",
"build": "webpack --config webpack.prod.js && npm run test",
"build:notests": "webpack --config webpack.prod.js",
"clean": "rimraf dist",
"start:dev": "webpack serve --hot --color --progress --config webpack.dev.js",
"test": "jest",
"test": "jest --maxWorkers=50% --coverage=true",
"test:ci": "jest --maxWorkers=50%",
"eslint": "eslint --ext .tsx,.js ./src/",
"license:check": "license-check-and-add check -f license-config.json",
"lint": "npm run license-check-and-add && npm run eslint",
Expand All @@ -21,10 +23,15 @@
"yarn:frzinstall": "yarn install --frozen-lockfile"
},
"devDependencies": {
"@testing-library/dom": "^8.11.3",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^12.1.4",
"@testing-library/user-event": "^13.5.0",
"@types/enzyme": "^3.10.9",
"@types/enzyme-adapter-react-16": "^1.0.6",
"@types/jest": "^27.0.2",
"@types/js-base64": "3.0.0",
"@types/react-test-renderer": "^17.0.1",
"@types/victory": "^33.1.5",
"@typescript-eslint/eslint-plugin": "^4.32.0",
"@typescript-eslint/parser": "^4.32.0",
Expand Down
6 changes: 3 additions & 3 deletions src/app/Recordings/Recordings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ export const Recordings = () => {

const cardBody = React.useMemo(() => {
return archiveEnabled ? (
<Tabs activeKey={activeTab} onSelect={(evt, idx) => setActiveTab(Number(idx))}>
<Tab eventKey={0} title="Active Recordings">
<Tabs id='recordings'activeKey={activeTab} onSelect={(evt, idx) => setActiveTab(Number(idx))}>
<Tab id='active-recordings' eventKey={0} title="Active Recordings">
<ActiveRecordingsTable archiveEnabled={true} />
</Tab>
<Tab eventKey={1} title="Archived Recordings">
<Tab id='archived-recordings' eventKey={1} title="Archived Recordings">
<ArchivedRecordingsTable />
</Tab>
</Tabs>
Expand Down
46 changes: 0 additions & 46 deletions src/app/__snapshots__/app.test.tsx.snap

This file was deleted.

72 changes: 46 additions & 26 deletions src/app/app.test.tsx → src/test/About/About.test.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
/*
* Copyright The Cryostat Authors
*
*
* The Universal Permissive License (UPL), Version 1.0
*
*
* Subject to the condition set forth below, permission is hereby granted to any
* person obtaining a copy of this software, associated documentation and/or data
* (collectively the "Software"), free of charge and under any and all copyright
* rights in the Software, and any and all patent rights owned or freely
* licensable by each licensor hereunder covering either (i) the unmodified
* Software as contributed to or provided by such licensor, or (ii) the Larger
* Works (as defined below), to deal in both
*
*
* (a) the Software, and
* (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if
* one is included with the Software (each a "Larger Work" to which the Software
* is contributed by such licensors),
*
*
* without restriction, including without limitation the rights to copy, create
* derivative works of, display, perform, and distribute the Software and make,
* use, sell, offer for sale, import, export, have made, and have sold the
* Software and the Larger Work(s), and to sublicense the foregoing rights on
* either these or other terms.
*
*
* This license is subject to the following condition:
* The above copyright notice and either this complete permission notice or at
* a minimum a reference to the UPL must be included in all copies or
* substantial portions of the Software.
*
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
Expand All @@ -35,29 +35,49 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from 'react';
import { App } from '@app/index';
import { mount, shallow } from 'enzyme';
import { Button } from '@patternfly/react-core';
import * as React from 'react';
import renderer from 'react-test-renderer'
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { CRYOSTAT_TRADEMARK } from '@app/About/AboutDescription';
import { About } from '@app/About/About';

describe('App tests', () => {
test('should render default App component', () => {
const view = shallow(<App />);
expect(view).toMatchSnapshot();
});
jest.mock('@app/BreadcrumbPage/BreadcrumbPage', () => {
return {
BreadcrumbPage: jest.fn((props) => {
return <div>
{props.pageTitle}
{props.children}
</div>
})
};
});

it('should render a nav-toggle button', () => {
const wrapper = mount(<App />);
const button = wrapper.find(Button);
expect(button.exists()).toBe(true);
jest.mock('@app/About/AboutDescription', () => {
return {
...jest.requireActual('@app/About/AboutDescription'),
AboutDescription: jest.fn(() => {
return <div>
AboutDescription
</div>
})
};
});

describe('<About />', () => {
it('renders correctly', () => {
const tree = renderer.create(<About />);
expect(tree.toJSON()).toMatchSnapshot();
});

it('should hide the sidebar when clicking the nav-toggle button', () => {
const wrapper = mount(<App />);
const button = wrapper.find('#nav-toggle').hostNodes();
expect(wrapper.find('#page-sidebar').hasClass('pf-m-expanded')).toBeTruthy();
button.simulate('click');
expect(wrapper.find('#page-sidebar').hasClass('pf-m-collapsed')).toBeTruthy();
expect(wrapper.find('#page-sidebar').hasClass('pf-m-expanded')).toBeFalsy();
it('contains the correct information', () => {
render(<About />);

expect(screen.getByText('About')).toBeInTheDocument();
const logo = screen.getByRole('img');
expect(logo).toHaveClass('pf-c-brand cryostat-logo');
expect(logo).toHaveAttribute('alt', 'Cryostat');
expect(logo).toHaveAttribute('src', 'test-file-stub');
expect(screen.getByText(CRYOSTAT_TRADEMARK)).toBeInTheDocument();
});
});
36 changes: 36 additions & 0 deletions src/test/About/__snapshots__/About.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<About /> renders correctly 1`] = `
<div>
About
<article
className="pf-c-card"
data-ouia-component-id="OUIA-Generated-Card-1"
data-ouia-component-type="PF4/Card"
data-ouia-safe={true}
id=""
>
<div
className="pf-c-card__header"
>
<img
alt="Cryostat"
className="pf-c-brand cryostat-logo"
src="test-file-stub"
/>
</div>
<div
className="pf-c-card__body"
>
<div>
AboutDescription
</div>
</div>
<div
className="pf-c-card__footer"
>
Copyright The Cryostat Authors, The Universal Permissive License (UPL), Version 1.0
</div>
</article>
</div>
`;
Loading

0 comments on commit cd3f58c

Please sign in to comment.