refactor(web): use vitest for frontend testing and coverage (#4946)

This PR drops Jest as a requirement and utilises Vitest for frontend testing and coverage collection during the dev workflow and unit testing.

Closes #4967

Signed-off-by: Amir Zarrinkafsh <nightah@me.com>
pull/5204/head
Amir Zarrinkafsh 2023-04-11 13:25:37 +10:00 committed by GitHub
parent ecdae9e5d2
commit 0312defcd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 2249 additions and 3517 deletions

1
web/.gitignore vendored
View File

@ -19,6 +19,7 @@
.env.test.local
.env.production.local
.eslintcache
.vitest-preview
npm-debug.log*
yarn-debug.log*

View File

@ -42,83 +42,14 @@
"build": "vite build",
"coverage": "VITE_COVERAGE=true vite build",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"test": "jest --coverage --no-cache",
"test": "vitest run --coverage",
"test:watch": "vitest --coverage",
"test:preview": "vitest-preview",
"report": "nyc report -r clover -r json -r lcov -r text"
},
"eslintConfig": {
"extends": "react-app"
},
"jest": {
"roots": [
"<rootDir>/src"
],
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*.d.ts"
],
"setupFilesAfterEnv": [
"<rootDir>/src/setupTests.js"
],
"testMatch": [
"<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"
],
"testEnvironment": "jsdom",
"transform": {
"^.+\\.(js|jsx|mjs|cjs|ts|tsx)$": [
"esbuild-jest",
{
"sourcemap": true
}
],
"^.+\\.(css|png|svg)$": "jest-transform-stub"
},
"transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$"
],
"moduleNameMapper": {
"^@root/(.*)$": [
"<rootDir>/src/$1"
],
"^@assets/(.*)$": [
"<rootDir>/src/assets/$1"
],
"^@components/(.*)$": [
"<rootDir>/src/components/$1"
],
"^@constants/(.*)$": [
"<rootDir>/src/constants/$1"
],
"^@hooks/(.*)$": [
"<rootDir>/src/hooks/$1"
],
"^@i18n/(.*)$": [
"<rootDir>/src/i18n/$1"
],
"^@layouts/(.*)$": [
"<rootDir>/src/layouts/$1"
],
"^@models/(.*)$": [
"<rootDir>/src/models/$1"
],
"^@services/(.*)$": [
"<rootDir>/src/services/$1"
],
"^@themes/(.*)$": [
"<rootDir>/src/themes/$1"
],
"^@utils/(.*)$": [
"<rootDir>/src/utils/$1"
],
"^@views/(.*)$": [
"<rootDir>/src/views/$1"
]
},
"watchPlugins": [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname"
],
"resetMocks": true
},
"browserslist": {
"production": [
">0.2%",
@ -140,17 +71,17 @@
"@limegrass/eslint-plugin-import-alias": "1.0.6",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "14.0.0",
"@types/jest": "29.5.0",
"@types/node": "18.15.11",
"@types/qrcode.react": "1.0.2",
"@types/react": "18.0.34",
"@types/react-dom": "18.0.11",
"@types/testing-library__jest-dom": "5.14.5",
"@types/zxcvbn": "4.4.1",
"@typescript-eslint/eslint-plugin": "5.58.0",
"@typescript-eslint/parser": "5.58.0",
"@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-istanbul": "0.30.0",
"esbuild": "0.17.16",
"esbuild-jest": "0.5.0",
"eslint": "8.38.0",
"eslint-config-prettier": "8.8.0",
"eslint-config-react-app": "7.0.1",
@ -161,11 +92,8 @@
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0",
"happy-dom": "9.1.9",
"husky": "8.0.3",
"jest": "29.5.0",
"jest-environment-jsdom": "29.5.0",
"jest-transform-stub": "2.0.0",
"jest-watch-typeahead": "2.2.2",
"prettier": "2.8.7",
"react-test-renderer": "18.2.0",
"typescript": "5.0.4",
@ -173,6 +101,8 @@
"vite-plugin-eslint": "1.8.1",
"vite-plugin-istanbul": "4.0.1",
"vite-plugin-svgr": "2.4.0",
"vite-tsconfig-paths": "4.1.0"
"vite-tsconfig-paths": "4.1.0",
"vitest": "0.30.0",
"vitest-preview": "0.0.1"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,34 @@
import React from "react";
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import NotificationBar from "@components/NotificationBar";
import NotificationsContext from "@hooks/NotificationsContext";
import { Notification } from "@models/Notifications";
const testNotification: Notification = {
message: "Test notification",
level: "success",
timeout: 3,
};
it("renders without crashing", () => {
render(<NotificationBar onClose={() => {}} />);
});
it("displays notification message and level correctly", async () => {
render(
<NotificationsContext.Provider value={{ notification: testNotification, setNotification: () => {} }}>
<NotificationBar onClose={() => {}} />
</NotificationsContext.Provider>,
);
const alert = await screen.getByRole("alert");
const message = await screen.findByText(testNotification.message);
expect(alert).toHaveClass(
`MuiAlert-filled${testNotification.level.charAt(0).toUpperCase() + testNotification.level.substring(1)}`,
{ exact: false },
);
expect(message).toHaveTextContent(testNotification.message);
});

View File

@ -0,0 +1,61 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach } from "vitest";
import PrivacyPolicyDrawer from "@components/PrivacyPolicyDrawer";
vi.mock("react-i18next", () => ({
withTranslation: () => (Component: any) => {
Component.defaultProps = { ...Component.defaultProps, t: (children: any) => children };
return Component;
},
Trans: ({ children }: any) => children,
useTranslation: () => {
return {
t: (str) => str,
i18n: {
changeLanguage: () => new Promise(() => {}),
},
};
},
}));
beforeEach(() => {
document.body.setAttribute("data-privacypolicyurl", "");
document.body.setAttribute("data-privacypolicyaccept", "false");
global.localStorage.clear();
});
it("renders privacy policy and accepts when Accept button is clicked", () => {
document.body.setAttribute("data-privacypolicyurl", "http://example.com/privacy-policy");
document.body.setAttribute("data-privacypolicyaccept", "true");
const { container } = render(<PrivacyPolicyDrawer />);
fireEvent.click(screen.getByText("Accept"));
expect(container).toBeEmptyDOMElement();
});
it("does not render when privacy policy is disabled", () => {
render(<PrivacyPolicyDrawer />);
expect(screen.queryByText("Privacy Policy")).toBeNull();
expect(screen.queryByText("You must view and accept the Privacy Policy before using")).toBeNull();
expect(screen.queryByText("Accept")).toBeNull();
});
it("does not render when acceptance is not required", () => {
document.body.setAttribute("data-privacypolicyurl", "http://example.com/privacy-policy");
render(<PrivacyPolicyDrawer />);
expect(screen.queryByText("Privacy Policy")).toBeNull();
expect(screen.queryByText("You must view and accept the Privacy Policy before using")).toBeNull();
expect(screen.queryByText("Accept")).toBeNull();
});
it("does not render when already accepted", () => {
global.localStorage.setItem("privacy-policy-accepted", "true");
const { container } = render(<PrivacyPolicyDrawer />);
expect(container).toBeEmptyDOMElement();
});

View File

@ -0,0 +1,30 @@
import React from "react";
import { render } from "@testing-library/react";
import PrivacyPolicyLink from "@components/PrivacyPolicyLink";
vi.mock("react-i18next", () => ({
withTranslation: () => (Component: any) => {
Component.defaultProps = { ...Component.defaultProps, t: (children: any) => children };
return Component;
},
Trans: ({ children }: any) => children,
useTranslation: () => {
return {
t: (str) => str,
i18n: {
changeLanguage: () => new Promise(() => {}),
},
};
},
}));
it("renders a link to the privacy policy with the correct text", () => {
document.body.setAttribute("data-privacypolicyurl", "http://example.com/privacy-policy");
const { getByRole } = render(<PrivacyPolicyLink />);
const link = getByRole("link");
expect(link).toHaveAttribute("href", "http://example.com/privacy-policy");
expect(link).toHaveTextContent("Privacy Policy");
});

View File

@ -1,9 +1,37 @@
import React from "react";
import { render } from "@testing-library/react";
import { act, render } from "@testing-library/react";
import TimerIcon from "@components/TimerIcon";
beforeEach(() => {
vi.useFakeTimers().setSystemTime(new Date(2023, 1, 1, 8));
});
afterEach(() => {
vi.useRealTimers();
});
it("renders without crashing", () => {
render(<TimerIcon width={32} height={32} period={30} />);
});
it("renders a timer icon with updating progress for a given period", async () => {
const { container } = render(<TimerIcon width={32} height={32} period={30} />);
const initialProgress =
container.firstElementChild!.firstElementChild!.nextElementSibling!.nextElementSibling!.getAttribute(
"stroke-dasharray",
);
expect(initialProgress).toBe("0 31.6");
act(() => {
vi.advanceTimersByTime(3000);
});
const updatedProgress =
container.firstElementChild!.firstElementChild!.nextElementSibling!.nextElementSibling!.getAttribute(
"stroke-dasharray",
);
expect(updatedProgress).toBe("3.16 31.6");
expect(Number(updatedProgress!.split(/\s(.+)/)[0])).toBeGreaterThan(Number(initialProgress!.split(/\s(.+)/)[0]));
});

View File

@ -2,12 +2,41 @@ import React from "react";
import { render } from "@testing-library/react";
import TypographyWithTooltip from "@components/TypographyWithTootip";
import TypographyWithTooltip, { Props } from "@components/TypographyWithTooltip";
const defaultProps: Props = {
variant: "h5",
value: "Example",
};
it("renders without crashing", () => {
render(<TypographyWithTooltip value={"Example"} variant={"h5"} />);
render(<TypographyWithTooltip {...defaultProps} />);
});
it("renders with tooltip without crashing", () => {
render(<TypographyWithTooltip value={"Example"} tooltip={"A tooltip"} variant={"h5"} />);
const props: Props = {
...defaultProps,
tooltip: "A tooltip",
};
render(<TypographyWithTooltip {...props} />);
});
it("renders the text correctly", () => {
const props: Props = {
...defaultProps,
value: "Test text",
};
const { getByText } = render(<TypographyWithTooltip {...props} />);
const element = getByText(props.value!);
expect(element).toBeInTheDocument();
});
it("renders the tooltip correctly", () => {
const props: Props = {
...defaultProps,
tooltip: "Test tooltip",
};
const { getByText } = render(<TypographyWithTooltip {...props} />);
const element = getByText(props.value!);
expect(element).toHaveAttribute("aria-label", props.tooltip);
});

View File

@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
import { ReactComponent as UserSvg } from "@assets/images/user.svg";
import PrivacyPolicyDrawer from "@components/PrivacyPolicyDrawer";
import PrivacyPolicyLink from "@components/PrivacyPolicyLink";
import TypographyWithTooltip from "@components/TypographyWithTootip";
import TypographyWithTooltip from "@components/TypographyWithTooltip";
import { getLogoOverride, getPrivacyPolicyEnabled } from "@utils/Configuration";
export interface Props {

View File

@ -1,10 +0,0 @@
import "@testing-library/jest-dom";
document.body.setAttribute("data-basepath", "");
document.body.setAttribute("data-duoselfenrollment", "true");
document.body.setAttribute("data-rememberme", "true");
document.body.setAttribute("data-resetpassword", "true");
document.body.setAttribute("data-resetpasswordcustomurl", "");
document.body.setAttribute("data-privacypolicyurl", "");
document.body.setAttribute("data-privacypolicyaccept", "false");
document.body.setAttribute("data-theme", "light");

View File

@ -0,0 +1,46 @@
import matchers, { TestingLibraryMatchers } from "@testing-library/jest-dom/matchers";
declare global {
namespace Vi {
interface JestAssertion<T = any> extends jest.Matchers<void, T>, TestingLibraryMatchers<T, void> {}
}
}
expect.extend(matchers);
const localStorageMock = (function () {
let store = {};
return {
getItem(key) {
return store[key];
},
setItem(key, value) {
store[key] = value;
},
clear() {
store = {};
},
removeItem(key) {
delete store[key];
},
getAll() {
return store;
},
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock });
document.body.setAttribute("data-basepath", "");
document.body.setAttribute("data-duoselfenrollment", "true");
document.body.setAttribute("data-rememberme", "true");
document.body.setAttribute("data-resetpassword", "true");
document.body.setAttribute("data-resetpasswordcustomurl", "");
document.body.setAttribute("data-privacypolicyurl", "");
document.body.setAttribute("data-privacypolicyaccept", "false");
document.body.setAttribute("data-theme", "light");

View File

@ -21,7 +21,7 @@
"dom.iterable",
"esnext"
],
"types": ["@types/jest", "vite/client", "vite-plugin-svgr/client"],
"types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,

View File

@ -5,18 +5,17 @@ import istanbul from "vite-plugin-istanbul";
import svgr from "vite-plugin-svgr";
import tsconfigPaths from "vite-tsconfig-paths";
// @ts-ignore
export default defineConfig(({ mode }) => {
const isCoverage = process.env.VITE_COVERAGE === "true";
const sourcemap = isCoverage ? "inline" : undefined;
const istanbulPlugin = isCoverage
? istanbul({
include: "src/*",
checkProd: false,
exclude: ["node_modules"],
extension: [".js", ".jsx", ".ts", ".tsx"],
checkProd: false,
forceBuildInstrument: true,
include: "src/*",
requireEnv: true,
})
: undefined;
@ -24,14 +23,11 @@ export default defineConfig(({ mode }) => {
return {
base: "./",
build: {
sourcemap,
outDir: "../internal/server/public_html",
emptyOutDir: true,
assetsDir: "static",
emptyOutDir: true,
outDir: "../internal/server/public_html",
rollupOptions: {
output: {
entryFileNames: `static/js/[name].[hash].js`,
chunkFileNames: `static/js/[name].[hash].js`,
assetFileNames: ({ name }) => {
if (name && name.endsWith(".css")) {
return "static/css/[name].[hash].[ext]";
@ -39,12 +35,26 @@ export default defineConfig(({ mode }) => {
return "static/media/[name].[hash].[ext]";
},
chunkFileNames: `static/js/[name].[hash].js`,
entryFileNames: `static/js/[name].[hash].js`,
},
},
sourcemap,
},
server: {
port: 3000,
open: false,
port: 3000,
},
test: {
coverage: {
provider: "istanbul",
},
environment: "happy-dom",
globals: true,
onConsoleLog(log) {
if (log.includes('No routes matched location "blank"')) return false;
},
setupFiles: ["src/setupTests.ts"],
},
plugins: [eslintPlugin({ cache: false }), istanbulPlugin, react(), svgr(), tsconfigPaths()],
};