Migrating from Jest to Vitest
Published:

As our code bases have grown VS Code seemed to be having trouble keeping up. Typically we used a Jest test runner, which gave feedback and UI integrations. Those were becoming more and more slow, along with terminal tests. Looking for a solution, I discovered Vitest and spent a Saturday trying it on a whim. Bottom line?
Vitest is faster, and moving is easy. Do it
They have an excellent migration guide,
but a few surprises came up just the same. LLM's are also still a bit confused in their
attempts to do it for you due to the sheer volume of opinions and versions. This
code was written against the 3.1.2
release.
VS Code
You may not realize it, but your test running is an extension, and you'll need to replace it with Vitest's extension. VS code does not play well with multiple, so ensure you've disabled Jest for the workspace as well.
Replacing modules
Because vite
naturally handles more, we are able to drop an entire ecosystem. I was able to
remove:
npm rm @types/jest jest jest-environment-jsdom jest-transform-stub ts-jest
With them gone, you need only a single replacement:
npm i -D vitest
Related modules
Special attenion needs to be paid to some. They don't call out each library, make sure to read carefully through migration guide. This one is critical enough I'll repost:
If you decide to keep globals disabled, be aware that common libraries like testing-library will not run auto DOM cleanup.
Code changes
Vite config
Support for your custom aliases (@/
) is not automatic. This one feels like a miss from
the Vitest team, but I'm sure there's reasons I haven't considered. Regardless, the changes
need are minimal once you know to do them.
Your previous jest.config.js
may have looked a bit like:
/** @type {import('jest').Config} */ export default { moduleFileExtensions: ["js", "ts", "tsx", "json"], transform: { "^.+\\.(ts|tsx)$": "ts-jest", ".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$": "jest-transform-stub", }, transformIgnorePatterns: ["node_modules/(?!rehype-highlight)"], moduleNameMapper: { "^@/(.*)$": "<rootDir>/$1", }, testEnvironment: "jsdom", rootDir: "src", setupFiles: ["<rootDir>/../jest/setup/il8n.js"], };
Since this is vite
based, we can drop most of that. But we need to carry across some bits
into vite.config.ts
.
import path from "path"; //... export default defineConfig(({ mode }) => { //... test: { environment: "jsdom", setupFiles: ["./vitest/setup/il8n.tsx"], alias: { "@": path.resolve(__dirname, "./src"), // Add other aliases as needed }, }, });
Global mocking
Rather than constly remocking, you likely have something like react-i18next
as a global mock.
Using jest/setup/i18n.js
:
jest.mock("react-i18next", () => ({ // this mock makes sure any components using the translate hook can use it without a warning being shown useTranslation: () => { return { t: (str) => str, i18n: { changeLanguage: () => new Promise(() => {}), }, }; }, Trans: ({ i18nKey }) => i18nKey, }));
Using vitest/setup/i18n.ts
:
import React from "react"; import { vi } from "vitest"; vi.mock("react-i18next", () => ({ // this mock makes sure any components using the translate hook can use it without a warning being shown useTranslation: () => ({ t: (str: string) => str, i18n: { changeLanguage: vi.fn(() => new Promise(() => {})), language: "en", // You can set a default language if needed }, ready: true, // Indicate that resources are loaded }), Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey, withTranslation: () => (Component: React.ComponentType) => (props: any) => ( <Component t={(str: string) => str} i18n={{ changeLanguage: vi.fn(), language: "en", ready: true }} {...props} /> ), initReactI18next: { type: "3", // Or any other value to satisfy type checking }, }));
React router redirection hook
Here's an example of a hook test I use to ensure a Conversation
has been configured in Jest:
import { useConversationQuery, useTraineeQuery } from "@/api"; import { generateConversationSourcePath, generateConversationSourcesPath, generateConversationsPath, } from "@/paths"; import { generateConversation, generateTrainee } from "@/tests"; import { renderHook } from "@testing-library/react"; import { useConversationSourceConfigurationCompletedRedirect } from "./useConversationSourceConfigurationCompletedRedirect"; // Mock the useNavigate hook const mockNavigate = jest.fn(); // eslint-disable-next-line @typescript-eslint/no-unsafe-return jest.mock("react-router-dom", () => ({ ...jest.requireActual("react-router-dom"), useNavigate: () => mockNavigate, })); // Mock the API hooks (these are react-query hooks) const mockUseConversationQuery = jest.fn(); const mockUseTraineeQuery = jest.fn(); jest.mock("@/api", () => ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-return useConversationQuery: () => mockUseConversationQuery(), // eslint-disable-next-line @typescript-eslint/no-unsafe-return useTraineeQuery: () => mockUseTraineeQuery(), })); describe("useConversationSourceConfigurationCompletedRedirect", () => { // Some simple constants for ease of testing const conversationId = "conversation1"; const traineeId = "trainee1"; beforeEach(() => { // Reset the mock navigate function before each test mockNavigate.mockReset(); mockUseConversationQuery.mockReset(); mockUseTraineeQuery.mockReset(); }); describe("loading", () => { it("conversation should not navigate", () => { mockUseConversationQuery.mockReturnValue({ isLoading: true, data: undefined, error: undefined, }); mockUseTraineeQuery.mockReturnValue({ isLoading: false, data: generateTrainee(), error: undefined, }); renderHook(() => useConversationSourceConfigurationCompletedRedirect( mockUseConversationQuery() as ReturnType<typeof useConversationQuery>, mockUseTraineeQuery() as ReturnType<typeof useTraineeQuery> ) ); expect(mockNavigate).not.toHaveBeenCalled(); }); // ... }); // ... });
Much of the rewrite is one to one, with minor tweaks for typing:
const mockNavigate = jest.fn(); // eslint-disable-next-line @typescript-eslint/no-unsafe-return jest.mock("react-router-dom", () => ({ ...jest.requireActual("react-router-dom"), useNavigate: () => mockNavigate, }));
becomes:
vi.mock("react-router-dom", async () => { const actual = await vi.importActual<typeof import("react-router-dom")>( "react-router-dom" ); return { ...actual, useNavigate: () => mockNavigate, }; });
const mockUseConversationQuery = jest.fn(); const mockUseTraineeQuery = jest.fn(); jest.mock("@/api", () => ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-return useConversationQuery: () => mockUseConversationQuery(), // eslint-disable-next-line @typescript-eslint/no-unsafe-return useTraineeQuery: () => mockUseTraineeQuery(), }));
becomes:
vi.mock("@/api", () => ({ useConversationQuery: vi.fn(), useTraineeQuery: vi.fn(), }));
But using the mocks is not quite the same. Some of this was just refactoring, but their support for typing means the migrations can be more thoughtfully applied.
describe("useConversationSourceConfigurationCompletedRedirect", () => { const useNavigate = mockNavigate as Mock<typeof _useNavigate>; const useConversationQuery = vi.mocked(_useConversationQuery); const useTraineeQuery = vi.mocked(_useTraineeQuery); // Some simple constants for ease of testing const conversationId = "conversation1"; const traineeId = "trainee1"; beforeEach(() => { // Reset the mock navigate function before each test useNavigate.mockClear(); useConversationQuery.mockClear(); useTraineeQuery.mockClear(); }); describe("loading", () => { it("conversation should not navigate", () => { useConversationQuery.mockReturnValue({ isLoading: true, data: undefined, error: undefined, } as unknown as ReturnType<typeof _useConversationQuery>); useTraineeQuery.mockReturnValue({ isLoading: false, data: generateTrainee(), error: undefined, } as unknown as ReturnType<typeof _useTraineeQuery>); renderHook(() => useConversationSourceConfigurationCompletedRedirect( useConversationQuery("123"), useTraineeQuery("456") ) ); expect(useNavigate).not.toHaveBeenCalled(); }); // ... }); // ... });
React query refetching
This test ensures that a Trainee
associated with a Conversation
is being polled for
while it's in transitory state. We'll start with a similar Jest based testing setup:
import { useTraineeQuery } from "@/api"; import { generateTrainee } from "@/tests"; import { renderHook } from "@testing-library/react"; import { act } from "react"; import { useConversationSourcePendingRefetch, useConversationSourcePendingRefetchInterval, } from "./useConversationSourcePendingRefetch"; // Mock the isTraineeStatusTransient function jest.mock("@/helpers", () => ({ isTraineeStatusTransient: jest.fn(), })); // Mock the API hooks const mockUseTraineeQuery = jest.fn(); jest.mock("@/api", () => ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-return useTraineeQuery: () => mockUseTraineeQuery(), })); describe("useConversationSourcePendingRefetch", () => { let isTraineeStatusTransient: jest.MockedFunction< (status: string | undefined) => boolean >; beforeEach(() => { // Reset mocks // eslint-disable-next-line isTraineeStatusTransient = require("@/helpers").isTraineeStatusTransient; mockUseTraineeQuery.mockReset(); }); it("should not set up an interval if the trainee status is not transient", () => { isTraineeStatusTransient.mockReturnValue(false); const status = "ready"; const query = mockUseTraineeQuery.mockReturnValue({ isLoading: false, data: generateTrainee({ status }), error: undefined, refetch: jest.fn(), })() as ReturnType<typeof useTraineeQuery>; renderHook(() => useConversationSourcePendingRefetch(query)); expect(isTraineeStatusTransient).toHaveBeenCalledWith(status); expect(query.refetch).not.toHaveBeenCalled(); }); it("should set up an interval if the trainee status is transient", async () => { isTraineeStatusTransient.mockReturnValue(true); const status = "training"; const query = mockUseTraineeQuery.mockReturnValue({ isLoading: false, data: generateTrainee({ status }), error: undefined, refetch: jest.fn(), })() as ReturnType<typeof useTraineeQuery>; jest.useFakeTimers(); // Enable fake timers renderHook(() => useConversationSourcePendingRefetch(query)); expect(isTraineeStatusTransient).toHaveBeenCalledWith(status); expect(query.refetch).not.toHaveBeenCalled(); // Should not have been called immediately // Fast-forward time to trigger the interval act(() => { jest.advanceTimersByTime(useConversationSourcePendingRefetchInterval); }); await Promise.resolve(); // Allow any promises in refetch to resolve expect(query.refetch).toHaveBeenCalledTimes(1); // Fast-forward time again act(() => { jest.advanceTimersByTime(useConversationSourcePendingRefetchInterval); }); await Promise.resolve(); expect(query.refetch).toHaveBeenCalledTimes(2); jest.useRealTimers(); }); it("should clear the interval on unmount", async () => { isTraineeStatusTransient.mockReturnValue(true); const status = "ready"; const query = mockUseTraineeQuery.mockReturnValue({ isLoading: false, data: generateTrainee({ status }), error: undefined, refetch: jest.fn(), })() as ReturnType<typeof useTraineeQuery>; jest.useFakeTimers(); const { unmount } = renderHook(() => useConversationSourcePendingRefetch(query) ); act(() => { jest.advanceTimersByTime(useConversationSourcePendingRefetchInterval); }); await Promise.resolve(); expect(query.refetch).toHaveBeenCalledTimes(1); unmount(); // Unmount the component act(() => { jest.advanceTimersByTime(useConversationSourcePendingRefetchInterval); }); await Promise.resolve(); expect(query.refetch).toHaveBeenCalledTimes(1); // Should not have been called again jest.useRealTimers(); }); });
The APIs are nearly the same. Huge shout out to the Vitest team for making this so simple:
import { useTraineeQuery as _useTraineeQuery } from "@/api"; import { generateTrainee } from "@/tests"; import { renderHook } from "@testing-library/react"; import { act } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { isTraineeStatusTransient as _isTraineeStatusTransient } from "@/helpers"; import { useConversationSourcePendingRefetch, useConversationSourcePendingRefetchInterval, } from "./useConversationSourcePendingRefetch"; vi.mock("@/helpers", () => ({ isTraineeStatusTransient: vi.fn(), })); // Mock the API hooks vi.mock("@/api", () => ({ useTraineeQuery: vi.fn(), })); describe("useConversationSourcePendingRefetch", () => { const isTraineeStatusTransient = vi.mocked(_isTraineeStatusTransient); const useTraineeQuery = vi.mocked(_useTraineeQuery); beforeEach(() => { // Reset mocks isTraineeStatusTransient.mockClear(); useTraineeQuery.mockClear(); }); it("should not set up an interval if the trainee status is not transient", () => { isTraineeStatusTransient.mockReturnValue(false); const status = "ready"; useTraineeQuery.mockReturnValue({ isLoading: false, data: generateTrainee({ status }), error: undefined, refetch: vi.fn(), } as unknown as ReturnType<typeof _useTraineeQuery>); const query = useTraineeQuery("123"); renderHook(() => useConversationSourcePendingRefetch(query)); expect(isTraineeStatusTransient).toHaveBeenCalledWith(status); expect(query.refetch).not.toHaveBeenCalled(); }); it("should set up an interval if the trainee status is transient", async () => { isTraineeStatusTransient.mockReturnValue(true); const status = "training"; useTraineeQuery.mockReturnValue({ isLoading: false, data: generateTrainee({ status }), error: undefined, refetch: vi.fn(), } as unknown as ReturnType<typeof _useTraineeQuery>); const query = useTraineeQuery("123"); vi.useFakeTimers(); // Enable fake timers renderHook(() => useConversationSourcePendingRefetch(query)); expect(isTraineeStatusTransient).toHaveBeenCalledWith(status); expect(query.refetch).not.toHaveBeenCalled(); // Should not have been called immediately // Fast-forward time to trigger the interval act(() => { vi.advanceTimersByTime(useConversationSourcePendingRefetchInterval); }); await Promise.resolve(); // Allow any promises in refetch to resolve expect(query.refetch).toHaveBeenCalledTimes(1); // Fast-forward time again act(() => { vi.advanceTimersByTime(useConversationSourcePendingRefetchInterval); }); await Promise.resolve(); expect(query.refetch).toHaveBeenCalledTimes(2); vi.useRealTimers(); }); it("should clear the interval on unmount", async () => { isTraineeStatusTransient.mockReturnValue(true); const status = "ready"; useTraineeQuery.mockReturnValue({ isLoading: false, data: generateTrainee({ status }), error: undefined, refetch: vi.fn(), } as unknown as ReturnType<typeof _useTraineeQuery>); const query = useTraineeQuery("123"); vi.useFakeTimers(); const { unmount } = renderHook(() => useConversationSourcePendingRefetch(query) ); act(() => { vi.advanceTimersByTime(useConversationSourcePendingRefetchInterval); }); await Promise.resolve(); expect(query.refetch).toHaveBeenCalledTimes(1); unmount(); // Unmount the component act(() => { vi.advanceTimersByTime(useConversationSourcePendingRefetchInterval); }); await Promise.resolve(); expect(query.refetch).toHaveBeenCalledTimes(1); // Should not have been called again vi.useRealTimers(); }); });