replace Jest with Vitest as the frontend test runner (#1163)

This commit is contained in:
sp.wack 2024-04-17 07:12:11 +03:00 committed by GitHub
parent a663302ba2
commit edeea95e7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1196 additions and 365 deletions

File diff suppressed because it is too large Load Diff

View File

@ -38,7 +38,7 @@
"scripts": {
"start": "vite",
"build": "tsc && vite build",
"test": "jest",
"test": "vitest",
"preview": "vite preview",
"make-i18n": "node scripts/make-i18n-translations.cjs",
"prelint": "npm run make-i18n",
@ -56,18 +56,10 @@
"prettier --write"
]
},
"jest": {
"preset": "ts-jest/presets/js-with-ts",
"testEnvironment": "jest-environment-jsdom",
"modulePaths": [
"<rootDir>/src"
]
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^29.5.12",
"@types/node": "^18.0.0 ",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
@ -85,14 +77,13 @@
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"husky": "^8.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jsdom": "^24.0.0",
"lint-staged": "^15.2.2",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.2",
"ts-jest": "^29.1.2",
"typescript": "^5.4.3"
"typescript": "^5.4.3",
"vitest": "^1.5.0"
},
"packageManager": "npm@10.5.0",
"volta": {

View File

@ -1,9 +1,15 @@
import { renderHook, act } from "@testing-library/react";
import { useTypingEffect } from "./useTypingEffect";
jest.useFakeTimers();
describe("useTypingEffect", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.clearAllTimers();
});
// This test fails because the hook improperly handles this case.
it.skip("should handle empty strings array", () => {
const { result } = renderHook(() => useTypingEffect([]));
@ -23,13 +29,13 @@ describe("useTypingEffect", () => {
// Fast-forward time by to simulate typing message
act(() => {
jest.advanceTimersByTime(msToRun - 1); // exclude the last character for testing
vi.advanceTimersByTime(msToRun - 1); // exclude the last character for testing
});
expect(result.current).toBe(message.slice(0, -1));
act(() => {
jest.advanceTimersByTime(1); // include the last character
vi.advanceTimersByTime(1); // include the last character
});
expect(result.current).toBe(message);
@ -46,13 +52,13 @@ describe("useTypingEffect", () => {
const msToRun = (message.length - 2) * 100 * playbackRate;
act(() => {
jest.advanceTimersByTime(msToRun - 1); // exclude the last character for testing
vi.advanceTimersByTime(msToRun - 1); // exclude the last character for testing
});
expect(result.current).toBe(message.slice(0, -1));
act(() => {
jest.advanceTimersByTime(1); // include the last character
vi.advanceTimersByTime(1); // include the last character
});
expect(result.current).toBe(message);
@ -67,7 +73,7 @@ describe("useTypingEffect", () => {
// Fast-forward to end of first string
act(() => {
jest.advanceTimersByTime(msToRunFirstString);
vi.advanceTimersByTime(msToRunFirstString);
});
expect(result.current).toBe(messages[0]); // Hello
@ -75,14 +81,14 @@ describe("useTypingEffect", () => {
// Fast-forward through the delay and through the second string
act(() => {
// TODO: Improve to clarify the expected timing
jest.runAllTimers();
vi.runAllTimers();
});
expect(result.current).toBe(messages[1]); // World
});
it("should call setTypingActive with false when typing completes without loop", () => {
const setTypingActiveMock = jest.fn();
const setTypingActiveMock = vi.fn();
renderHook(() =>
useTypingEffect(["Hello, world!", "This is a test message."], {
@ -94,7 +100,7 @@ describe("useTypingEffect", () => {
expect(setTypingActiveMock).not.toHaveBeenCalled();
act(() => {
jest.runAllTimers();
vi.runAllTimers();
});
expect(setTypingActiveMock).toHaveBeenCalledWith(false);
@ -102,7 +108,7 @@ describe("useTypingEffect", () => {
});
it("should call addAssistantMessageToChat with the typeThis argument when typing completes without loop", () => {
const addAssistantMessageToChatMock = jest.fn();
const addAssistantMessageToChatMock = vi.fn();
renderHook(() =>
useTypingEffect(["Hello, world!", "This is a test message."], {
@ -116,7 +122,7 @@ describe("useTypingEffect", () => {
expect(addAssistantMessageToChatMock).not.toHaveBeenCalled();
act(() => {
jest.runAllTimers();
vi.runAllTimers();
});
expect(addAssistantMessageToChatMock).toHaveBeenCalledTimes(1);
@ -127,7 +133,7 @@ describe("useTypingEffect", () => {
});
it("should call takeOneAndType when typing completes without loop", () => {
const takeOneAndTypeMock = jest.fn();
const takeOneAndTypeMock = vi.fn();
renderHook(() =>
useTypingEffect(["Hello, world!", "This is a test message."], {
@ -139,7 +145,7 @@ describe("useTypingEffect", () => {
expect(takeOneAndTypeMock).not.toHaveBeenCalled();
act(() => {
jest.runAllTimers();
vi.runAllTimers();
});
expect(takeOneAndTypeMock).toHaveBeenCalledTimes(1);

View File

@ -1,23 +1,25 @@
import * as jose from "jose";
import type { Mock } from "vitest";
import { fetchToken, validateToken, getToken } from "./auth";
jest.mock("jose", () => ({
decodeJwt: jest.fn(),
vi.mock("jose", () => ({
decodeJwt: vi.fn(),
}));
// SUGGESTION: Prefer using msw for mocking requests (see https://mswjs.io/)
global.fetch = jest.fn(() =>
global.fetch = vi.fn(() =>
Promise.resolve({
status: 200,
json: () => Promise.resolve({ token: "newToken" }),
}),
) as jest.Mock;
) as Mock;
Storage.prototype.getItem = vi.fn();
Storage.prototype.setItem = vi.fn();
describe("Auth Service", () => {
beforeEach(() => {
jest.clearAllMocks();
Storage.prototype.getItem = jest.fn();
Storage.prototype.setItem = jest.fn();
vi.clearAllMocks();
});
describe("fetchToken", () => {
@ -32,7 +34,7 @@ describe("Auth Service", () => {
});
it("throws an error if response status is not 200", async () => {
(fetch as jest.Mock).mockImplementationOnce(() =>
(fetch as Mock).mockImplementationOnce(() =>
Promise.resolve({ status: 401 }),
);
await expect(fetchToken()).rejects.toThrow("Get token failed.");
@ -41,17 +43,17 @@ describe("Auth Service", () => {
describe("validateToken", () => {
it("returns true for a valid token", () => {
(jose.decodeJwt as jest.Mock).mockReturnValue({ sid: "123" });
(jose.decodeJwt as Mock).mockReturnValue({ sid: "123" });
expect(validateToken("validToken")).toBe(true);
});
it("returns false for an invalid token", () => {
(jose.decodeJwt as jest.Mock).mockReturnValue({});
(jose.decodeJwt as Mock).mockReturnValue({});
expect(validateToken("invalidToken")).toBe(false);
});
it("returns false when decodeJwt throws", () => {
(jose.decodeJwt as jest.Mock).mockImplementation(() => {
(jose.decodeJwt as Mock).mockImplementation(() => {
throw new Error("Invalid token");
});
expect(validateToken("badToken")).toBe(false);
@ -60,15 +62,15 @@ describe("Auth Service", () => {
describe("getToken", () => {
it("returns existing valid token from localStorage", async () => {
(jose.decodeJwt as jest.Mock).mockReturnValue({ sid: "123" });
(Storage.prototype.getItem as jest.Mock).mockReturnValue("existingToken");
(jose.decodeJwt as Mock).mockReturnValue({ sid: "123" });
(Storage.prototype.getItem as Mock).mockReturnValue("existingToken");
const token = await getToken();
expect(token).toBe("existingToken");
});
it("fetches, validates, and stores a new token when existing token is invalid", async () => {
(jose.decodeJwt as jest.Mock)
(jose.decodeJwt as Mock)
.mockReturnValueOnce({})
.mockReturnValueOnce({ sid: "123" });
@ -78,7 +80,7 @@ describe("Auth Service", () => {
});
it("throws an error when fetched token is invalid", async () => {
(jose.decodeJwt as jest.Mock).mockReturnValue({});
(jose.decodeJwt as Mock).mockReturnValue({});
await expect(getToken()).rejects.toThrow("Token validation failed.");
});
});

View File

@ -1,3 +1,4 @@
import type { Mock } from "vitest";
import {
ResDelMsg,
ResFetchMsg,
@ -7,12 +8,12 @@ import {
import { clearMsgs, fetchMsgTotal, fetchMsgs } from "./session";
// SUGGESTION: Prefer using msw for mocking requests (see https://mswjs.io/)
global.fetch = jest.fn();
Storage.prototype.getItem = jest.fn();
global.fetch = vi.fn();
Storage.prototype.getItem = vi.fn();
describe("Session Service", () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
afterEach(() => {
@ -26,7 +27,7 @@ describe("Session Service", () => {
msg_total: 10,
};
(fetch as jest.Mock).mockImplementationOnce(() =>
(fetch as Mock).mockImplementationOnce(() =>
Promise.resolve({
status: 200,
json: () => Promise.resolve(expectedResult),
@ -45,7 +46,7 @@ describe("Session Service", () => {
it("throws an error if response status is not 200", async () => {
// NOTE: The current implementation ONLY handles 200 status;
// this means throwing even with a status of 201, 204, etc.
(fetch as jest.Mock).mockImplementationOnce(() =>
(fetch as Mock).mockImplementationOnce(() =>
Promise.resolve({ status: 401 }),
);
@ -67,7 +68,7 @@ describe("Session Service", () => {
],
};
(fetch as jest.Mock).mockImplementationOnce(() =>
(fetch as Mock).mockImplementationOnce(() =>
Promise.resolve({
status: 200,
json: () => Promise.resolve(expectedResult),
@ -84,7 +85,7 @@ describe("Session Service", () => {
});
it("throws an error if response status is not 200", async () => {
(fetch as jest.Mock).mockImplementationOnce(() =>
(fetch as Mock).mockImplementationOnce(() =>
Promise.resolve({ status: 401 }),
);
@ -98,7 +99,7 @@ describe("Session Service", () => {
ok: "true",
};
(fetch as jest.Mock).mockImplementationOnce(() =>
(fetch as Mock).mockImplementationOnce(() =>
Promise.resolve({
status: 200,
json: () => Promise.resolve(expectedResult),
@ -116,7 +117,7 @@ describe("Session Service", () => {
});
it("throws an error if response status is not 200", async () => {
(fetch as jest.Mock).mockImplementationOnce(() =>
(fetch as Mock).mockImplementationOnce(() =>
Promise.resolve({ status: 401 }),
);

View File

@ -1,11 +1,6 @@
import { mergeAndUpdateSettings } from "./settingsService";
import { ArgConfigType } from "../types/ConfigType";
// We need to mock this to avoid `SyntaxError` from using `Socket` in `settingsService` during testing
jest.mock("./socket", () => ({
send: jest.fn(),
}));
describe("mergeAndUpdateSettings", () => {
it("should return initial settings if newSettings is empty", () => {
const oldSettings = { key1: "value1" };

View File

@ -1,27 +1,28 @@
import type { Mock } from "vitest";
import { getCachedConfig } from "./storage";
describe("getCachedConfig", () => {
beforeEach(() => {
// Clear all instances and calls to constructor and all methods
Storage.prototype.getItem = jest.fn();
Storage.prototype.getItem = vi.fn();
});
it("should return an empty object when local storage is null or undefined", () => {
(Storage.prototype.getItem as jest.Mock).mockReturnValue(null);
(Storage.prototype.getItem as Mock).mockReturnValue(null);
expect(getCachedConfig()).toEqual({});
(Storage.prototype.getItem as jest.Mock).mockReturnValue(undefined);
(Storage.prototype.getItem as Mock).mockReturnValue(undefined);
expect(getCachedConfig()).toEqual({});
});
it("should return an empty object when local storage has invalid JSON", () => {
(Storage.prototype.getItem as jest.Mock).mockReturnValue("invalid JSON");
(Storage.prototype.getItem as Mock).mockReturnValue("invalid JSON");
expect(getCachedConfig()).toEqual({});
});
it("should return parsed object when local storage has valid JSON", () => {
const validJSON = '{"key":"value"}';
(Storage.prototype.getItem as jest.Mock).mockReturnValue(validJSON);
(Storage.prototype.getItem as Mock).mockReturnValue(validJSON);
expect(getCachedConfig()).toEqual({ key: "value" });
});
});

View File

@ -19,10 +19,10 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react",
"types": ["vite/client"]
"types": ["vite/client", "vitest/globals"]
},
"include": [
"src",
"src",
"vite-env.d.ts",
"vite.config.ts"
]

View File

@ -1,3 +1,4 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import viteTsconfigPaths from "vite-tsconfig-paths";
@ -31,4 +32,8 @@ export default defineConfig({
},
},
},
test: {
environment: "jsdom",
globals: true,
},
});