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
parent
ecdae9e5d2
commit
0312defcd7
|
@ -19,6 +19,7 @@
|
|||
.env.test.local
|
||||
.env.production.local
|
||||
.eslintcache
|
||||
.vitest-preview
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
5406
web/pnpm-lock.yaml
5406
web/pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
|
@ -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");
|
||||
});
|
|
@ -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]));
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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");
|
|
@ -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");
|
|
@ -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,
|
||||
|
|
|
@ -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()],
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue