Yamada UIのテストスイートをJestからvitestに乗り換える
やること
↓のIssueの対応
まずは vitest
をworkspace-rootに入れる
pnpm add -w vitest
↓をよく読む
とりあえずvitest.config.ts
を作る
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {},
})
Globals as a Default
Jestはdescribe
, test
, beforeEach
などをいちいちimportしなくても良いようにGlobal API化されている一方で、Vitestはそうはなっていない。
Jestと同様にVitestでも有効化するためには明示する必要がある。
import { defineConfig } from "vitest/config"
export default defineConfig({
- test: {},
+ test: {
+ globals: true
+ },
})
Module Mocks
jest.mock()
とvi.mock()
では第2引数に書くcallbackが違うらしい。
Yamada UIではjest.mock()
は使っていないのでスキップする
Auto-Mocking Behaviour
<root>/__mocks__
に配置したモックはvi.mock()
を呼ばない限りロードされないが、Yamada UIではここもスキップ。
Accessing the Return Values of a Mocked Promise
ここもスキップ
Envs
Jestではワーカープロセスを識別するユニークIDとしてJEST_WORKER_ID
という環境変数が使われていた。
これがVitestではVITEST_POOL_ID
という名前に変わっている。
ただYamada UIでは環境変数JEST_WORKER_ID
を一切参照していないのでスキップする。
Replace property
JestのreplaceProperty
はYamada UIでは今の所使っていないのでスキップする。
Done Callback
Yamada UIはDone callback形式のテストを書いてないのでスキップ
Hooks
beforeAll/beforeEach
の書き方が変わるらしい。ただ確認したら問題なさそうだった。スキップ。
Types
型のimportに気をつけようという話。範囲が広いので見つけ次第直していく
Timers
Vitest doesn't support Jest's legacy timers.
ん〜〜〜直すところいっぱいありそう
問題はここから
vitest.config.ts
の足りないところを埋める
まずこのjest.config.ts
を書き換えよう
import type { Config } from "jest"
const config: Config = {
testEnvironment: "jsdom",
collectCoverageFrom: ["packages/**/*.{ts,tsx}"],
coveragePathIgnorePatterns: ["dist", "theme", "tests", "test"],
moduleFileExtensions: ["ts", "tsx", "js", "jsx"],
modulePathIgnorePatterns: [
"<rootDir>/examples",
"<rootDir>/stories",
"<rootDir>/scripts",
],
transform: {
"^.+\\.(ts|tsx|js|jsx)?$": ["@swc-node/jest", { module: "commonjs" }],
},
transformIgnorePatterns: ["[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$"],
setupFilesAfterEnv: ["@testing-library/jest-dom", "./scripts/setup-test.ts"],
globals: {},
watchPlugins: [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname",
],
}
export default config
coverageについて
jest.config.ts
ではこのように設定されている
collectCoverageFrom: ["packages/**/*.{ts,tsx}"],
coveragePathIgnorePatterns: ["dist", "theme", "tests", "test"],
これをvitest.config.ts
ではこのように書き換える
export default defineConfig({
test: {
globals: true,
+ coverage: {
+ include: ["packages/**/*.{ts,tsx}"],
+ exclude: ["dist", "theme", "tests", "test"],
+ },
},
})
testEnvironment
export default defineConfig({
test: {
+ environment: "jsdom",
globals: true,
coverage: {
include: ["packages/**/*.{ts,tsx}"],
exclude: ["dist", "theme", "tests", "test"],
},
},
})
moduleFileExtensions
Vitestでこれに相当するオプションを調査中
modulePathIgnorePatterns
Vitestではこれと全く同じオプションはないが、deps.web.transformGlobPattern
というオプションがある。
これはmodulePathIgnorePatterns
とは逆に対象とするモジュールが配置されたパスを正規表現で指定する。
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "jsdom",
globals: true,
coverage: {
include: ["packages/**/*.{ts,tsx}"],
exclude: ["dist", "theme", "tests", "test"],
},
+ deps: {
+ web: {
+ transformGlobPattern: [/[/\\\\]packages[/\\\\].+\\.(js|jsx|ts|tsx)$/],
+ },
+ },
},
})
watchPlugins
jest-watch-typeahead
を使ってファイル名やテスト名でフィルタリングできるようにしているが、Vitestはプラグインの追加なしで実現できるので設定は不要。
setupFilesAfterEnv
setupFiles
に書き直す
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "jsdom",
globals: true,
coverage: {
include: ["packages/**/*.{ts,tsx}"],
exclude: ["dist", "theme", "tests", "test"],
},
deps: {
web: {
transformGlobPattern: [/[/\\\\]packages[/\\\\].+\\.(js|jsx|ts|tsx)$/],
},
},
+ setupFiles: ["./scripts/setup-test.ts"],
},
})
あ、React用のPluginが必要だった
pnpm add -w @vitejs/plugin-react-swc
ひとまずできた?
import { defineConfig } from "vitest/config"
import react from "@vitejs/plugin-react-swc"
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
coverage: {
include: ["packages/**/*.{ts,tsx}"],
exclude: ["dist", "theme", "tests", "test"],
},
deps: {
web: {
transformGlobPattern: [/[/\\\\]packages[/\\\\].+\\.(js|jsx|ts|tsx)$/],
},
},
setupFiles: ["./scripts/setup-test.ts"],
},
})
tsconfig.json
にtypes:["vitest/globals"]
を追加してなかった
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"lib": ["dom", "esnext"],
"declaration": true,
"sourceMap": true,
"moduleResolution": "node",
"skipLibCheck": true,
"strict": true,
"isolatedModules": true,
"noFallthroughCasesInSwitch": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"downlevelIteration": true,
+ "types": ["vitest/globals"]
},
"include": ["@types", "**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"],
"exclude": [
"node_modules",
"examples",
"dist",
".changelog",
".changeset",
".turbo"
],
"ts-node": {
"transpileOnly": true,
"compilerOptions": {
"module": "CommonJS"
}
}
}
setupScriptを直す
import "@testing-library/jest-dom"
const { getComputedStyle } = window
window.getComputedStyle = (elt) => getComputedStyle(elt)
window.Element.prototype.scrollTo = () => {}
window.scrollTo = () => {}
if (typeof window.matchMedia !== "function") {
Object.defineProperty(window, "matchMedia", {
enumerable: true,
configurable: true,
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
})
}
global.TextEncoder = require("util").TextEncoder
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}))
import
@testing-library/jest-dom
のVitestへの導入方法を直す必要がある。
- import "@testing-library/jest-dom"
+ import "@testing-library/jest-dom/vitest"
jest.fn()
をvi.fn()
に置き換え
import "@testing-library/jest-dom/vitest"
+ import { vi } from "vitest"
const { getComputedStyle } = window
window.getComputedStyle = (elt) => getComputedStyle(elt)
window.Element.prototype.scrollTo = () => { }
window.scrollTo = () => { }
if (typeof window.matchMedia !== "function") {
Object.defineProperty(window, "matchMedia", {
enumerable: true,
configurable: true,
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
- addListener: jest.fn(),
- removeListener: jest.fn(),
- addEventListener: jest.fn(),
- removeEventListener: jest.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
}
global.TextEncoder = require("util").TextEncoder
global.ResizeObserver = vi.fn().mockImplementation(() => ({
- observe: jest.fn(),
- unobserve: jest.fn(),
- disconnect: jest.fn(),
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
}))
a11y
a11yのテストで使用するモジュールも直す必要がある
// packages/test/accessibility.tsx
import type { RenderOptions } from "@testing-library/react"
import type { JestAxeConfigureOptions } from "jest-axe"
import { axe, toHaveNoViolations } from "jest-axe"
import type { ReactElement } from "react"
import { isValidElement } from "react"
import { render } from "./render"
import "@testing-library/jest-dom"
expect.extend(toHaveNoViolations)
export type A11yProps = RenderOptions & { axeOptions?: JestAxeConfigureOptions }
export const a11y = async (
ui: ReactElement | HTMLElement,
{ axeOptions, ...rest }: A11yProps = {},
): Promise<void> => {
const container = isValidElement(ui) ? render(ui, rest).container : ui
const results = await axe(container as HTMLElement, axeOptions)
expect(results).toHaveNoViolations()
}
vitest-axe, axe-coreを入れる
cd packages/test
pnpm add vitest-axe axe-core
scripts/setup-test.ts
の方で対応しているから正直要らないんじゃないかと思っているが一応同じように直す
不要だったら後で消す
- import "@testing-library/jest-dom"
+ import "@testing-library/jest-dom/vitest"
- import { axe, toHaveNoViolations } from "jest-axe"
+ import { axe } from "vitest-axe"
+ import * as matchers from "vitest-axe/matchers"
// ...
- expect.extend(toHaveNoViolations)
+ expect.extend(matchers)
問題はここのJextAxeConfigureOptions
の型の置き換え
export type A11yProps = RenderOptions & { axeOptions?: JestAxeConfigureOptions }
@types/jest-axe
のindex.d.ts
を確認すると、JestAxeConfigureOptions
の型定義は以下のようになっている。
export interface JestAxeConfigureOptions extends RunOptions {
globalOptions?: Spec | undefined;
impactLevels?: ImpactValue[];
}
で、jest-axe
におけるaxe()
の型定義は同じファイルでこのようになっている。
/**
* Runs aXe on HTML.
*
* @param html Raw HTML string to verify with aXe.
* @param options Options to run aXe.
* @returns Promise for the results of running aXe.
*/
export type JestAxe = (html: Element | string, options?: RunOptions) => Promise<AxeResults>;
ではvitest-axe
ではどのようになっているのか確認する(インデントがTabだから読みづらい...)
// vitest-axe/src/axe.ts
function axe(
html: Element | string,
additionalOptions: AxeCore.RunOptions = {},
): Promise<AxeCore.AxeResults>
jest-axe
とvitest-axe
のどちらもaxe-core
のRunOptions
及びそのサブタイプを受け入れる様になっている。
一方で、axe()
をラップしたYamada UIのa11y()
関数はRunOptions
のサブタイプとして以下の型が必要。
RunOptions & {
globalOptions?: Spec | undefined
impactLevels?: ImpactValue[]
}
この型定義はvitest-axe
では名前がつけられてはいないが、jest-axe
ではJextAxeConfigureOptions
という名前で型定義されている。
なので、Yamada UIでもそれに倣ってA11yConfigureOptions
という型定義を作成する。
type A11yConfigureOptions = RunOptions & {
globalOptions?: Spec
impactLevels?: ImpactValue[]
}
そして、JestAxeConfigureOptions
をこのA11yConfigureOptions
で置き換える。
import type { RenderOptions } from "@testing-library/react"
- import type { JestAxeConfigureOptions } from "jest-axe"
import { axe } from "vitest-axe"
import * as matchers from "vitest-axe/matchers"
import type { ReactElement } from "react"
import { isValidElement } from "react"
import { render } from "./render"
import "@testing-library/jest-dom/vitest"
import { expect } from "vitest"
+ import { ImpactValue, RunOptions, Spec } from "axe-core"
expect.extend(matchers)
+ type A11yConfigureOptions = RunOptions & {
+ globalOptions?: Spec
+ impactLevels?: ImpactValue[]
+ }
- export type A11yProps = RenderOptions & { axeOptions?: JestAxeConfigureOptions }
+ export type A11yProps = RenderOptions & { axeOptions?: A11yConfigureOptions }
export const a11y = async (
ui: ReactElement | HTMLElement,
{ axeOptions, ...rest }: A11yProps = {},
): Promise<void> => {
const container = isValidElement(ui) ? render(ui, rest).container : ui
const results = await axe(container as HTMLElement, axeOptions)
expect(results).toHaveNoViolations()
}
Workspace対応
忘れてた
まず workspace-rootにvitest.workspace.ts
を作る
+ import { defineWorkspace } from "vitest/config"
+
+ export default defineWorkspace([
+ "packages/*",
+ {
+ extends: "./vitest.config.ts",
+ },
+ ])
テストの実行に時間がかかる...
Jestの設定を一通りVitestに置き換えた段階でのTest結果
Jestで実行すると23秒なので、Vitest導入後はこのタイムより短くなって欲しい
Render
これも直す
// packages/test/render.tsx
import type { RenderOptions } from "@testing-library/react"
import { render as reactRender } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { UIProvider } from "@yamada-ui/providers"
import theme from "@yamada-ui/theme"
import { toHaveNoViolations } from "jest-axe"
import type { ReactElement } from "react"
import "@testing-library/jest-dom"
expect.extend(toHaveNoViolations)
export type RenderProps = RenderOptions & {
withProvider?: boolean
}
export type RenderReturn = ReturnType<typeof reactRender> & {
user: ReturnType<typeof userEvent.setup>
}
export const render = (
ui: ReactElement,
{ withProvider, ...rest }: RenderProps = {
withProvider: true,
},
): RenderReturn => {
const user = userEvent.setup()
if (withProvider)
rest.wrapper = (props: any) => <UIProvider {...props} theme={theme} />
const result = reactRender(ui, rest)
return { user, ...result }
}
何故か存在するa11y用の記述を削る
- import { toHaveNoViolations } from "jest-axe"
import type { ReactElement } from "react"
import "@testing-library/jest-dom"
- expect.extend(toHaveNoViolations)
あとこれも削る。
@testing-library/jest-dom/vitest
に変えても動かないテストがあったのでいっそのこと削ってみたら動いた。
- import "@testing-library/jest-dom"
Coverage周り
package.json
のnpm script達を直す
{
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage --silent && npx http-server ./coverage/lcov-report",
"test:ci": "jest --coverage --silent --testLocationInResults --ci --json --outputFile=report.json",
}
{
"scripts": {
- "test": "jest",
+ "test": "vitest",
"test:coverage": "jest --coverage --silent && npx http-server ./coverage/lcov-report",
"test:ci": "jest --coverage --silent --testLocationInResults --ci --json --outputFile=report.json",
}