Closed47

Yamada UIのテストスイートをJestからvitestに乗り換える

korallekoralle

まずは vitestをworkspace-rootに入れる

pnpm add -w vitest
korallekoralle
korallekoralle

とりあえずvitest.config.tsを作る

vitest.config.ts
import { defineConfig } from "vitest/config"

export default defineConfig({
  test: {},
})
korallekoralle

Globals as a Default

https://vitest.dev/guide/migration.html#module-mocks

Jestはdescribe, test, beforeEachなどをいちいちimportしなくても良いようにGlobal API化されている一方で、Vitestはそうはなっていない。

Jestと同様にVitestでも有効化するためには明示する必要がある。

vitest.config.ts
import { defineConfig } from "vitest/config"

export default defineConfig({
- test: {},
+ test: {
+  globals: true
+ },
})
korallekoralle

Envs

https://vitest.dev/guide/migration.html#module-mocks

Jestではワーカープロセスを識別するユニークIDとしてJEST_WORKER_IDという環境変数が使われていた。
これがVitestではVITEST_POOL_IDという名前に変わっている。

ただYamada UIでは環境変数JEST_WORKER_IDを一切参照していないのでスキップする。

korallekoralle

問題はここから

korallekoralle

まずvitest.config.tsの足りないところを埋める

このjest.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
korallekoralle

coverageについて

jest.config.tsではこのように設定されている

collectCoverageFrom: ["packages/**/*.{ts,tsx}"],
coveragePathIgnorePatterns: ["dist", "theme", "tests", "test"],

これをvitest.config.tsではこのように書き換える

vitest.config.ts
export default defineConfig({
  test: {
    globals: true,
+   coverage: {
+     include: ["packages/**/*.{ts,tsx}"],
+     exclude: ["dist", "theme", "tests", "test"],
+  },
  },
})
korallekoralle

testEnvironment

vitest.config.ts
export default defineConfig({
  test: {
+   environment: "jsdom",
    globals: true,
    coverage: {
      include: ["packages/**/*.{ts,tsx}"],
      exclude: ["dist", "theme", "tests", "test"],
    },
  },
})
korallekoralle

moduleFileExtensions

Vitestでこれに相当するオプションを調査中

korallekoralle

modulePathIgnorePatterns

Vitestではこれと全く同じオプションはないが、deps.web.transformGlobPatternというオプションがある。

これはmodulePathIgnorePatternsとは逆に対象とするモジュールが配置されたパスを正規表現で指定する。

vitest.config.ts
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)$/],
+     },
+   },
  },
})
korallekoralle

watchPlugins

jest-watch-typeaheadを使ってファイル名やテスト名でフィルタリングできるようにしているが、Vitestはプラグインの追加なしで実現できるので設定は不要。

korallekoralle

setupFilesAfterEnv

setupFilesに書き直す

vitest.config.ts
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"],
  },
})
korallekoralle

あ、React用のPluginが必要だった

pnpm add -w @vitejs/plugin-react-swc
korallekoralle

ひとまずできた?

vitest.config.ts
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"],
  },
})
korallekoralle

tsconfig.jsontypes:["vitest/globals"]を追加してなかった

tsconfig.json
  {
    "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"
      }
    }
  }
korallekoralle

setupScriptを直す

scripts/setup-test.ts
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(),
}))
korallekoralle

jest.fn()vi.fn()に置き換え

scripts/setup-test.ts
  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(),
  }))
korallekoralle

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()
}
korallekoralle

vitest-axe, axe-coreを入れる

cd packages/test
pnpm add vitest-axe axe-core
korallekoralle

scripts/setup-test.tsの方で対応しているから正直要らないんじゃないかと思っているが一応同じように直す
不要だったら後で消す

accessibility.tsx
- import "@testing-library/jest-dom"
+ import "@testing-library/jest-dom/vitest"
korallekoralle
accessibility.tsx
- import { axe, toHaveNoViolations } from "jest-axe"
+ import { axe } from "vitest-axe"
+ import * as matchers from "vitest-axe/matchers"

// ...
- expect.extend(toHaveNoViolations)
+ expect.extend(matchers)
korallekoralle

問題はここのJextAxeConfigureOptionsの型の置き換え

accessibility.tsx
export type A11yProps = RenderOptions & { axeOptions?: JestAxeConfigureOptions }
korallekoralle

@types/jest-axeindex.d.tsを確認すると、JestAxeConfigureOptionsの型定義は以下のようになっている。

index.d.ts
export interface JestAxeConfigureOptions extends RunOptions {
    globalOptions?: Spec | undefined;
    impactLevels?: ImpactValue[];
}

で、jest-axeにおけるaxe()の型定義は同じファイルでこのようになっている。

index.d.ts
/**
 * 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>;

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/jest-axe/index.d.ts

korallekoralle

jest-axevitest-axeのどちらもaxe-coreRunOptions及びそのサブタイプを受け入れる様になっている。

一方で、axe()をラップしたYamada UIのa11y()関数はRunOptionsのサブタイプとして以下の型が必要。

RunOptions & {
  globalOptions?: Spec | undefined
  impactLevels?: ImpactValue[]
}

この型定義はvitest-axeでは名前がつけられてはいないが、jest-axeではJextAxeConfigureOptionsという名前で型定義されている。

korallekoralle

なので、Yamada UIでもそれに倣ってA11yConfigureOptionsという型定義を作成する。

accessibility.ts
type A11yConfigureOptions = RunOptions & {
  globalOptions?: Spec
  impactLevels?: ImpactValue[]
}

そして、JestAxeConfigureOptionsをこのA11yConfigureOptionsで置き換える。

accessibility.ts
  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()
  }
korallekoralle

テストの実行に時間がかかる...

Jestの設定を一通りVitestに置き換えた段階でのTest結果

korallekoralle

Jestで実行すると23秒なので、Vitest導入後はこのタイムより短くなって欲しい

korallekoralle

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 }
}

korallekoralle

何故か存在するa11y用の記述を削る

render.tsx
- import { toHaveNoViolations } from "jest-axe"
  import type { ReactElement } from "react"
  import "@testing-library/jest-dom"
  
- expect.extend(toHaveNoViolations)
korallekoralle

あとこれも削る。
@testing-library/jest-dom/vitestに変えても動かないテストがあったのでいっそのこと削ってみたら動いた。

render.tsx
- import "@testing-library/jest-dom"
korallekoralle

Coverage周り

korallekoralle

package.jsonのnpm script達を直す

package.json
{
  "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",
}
korallekoralle
package.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",
  }
このスクラップは2024/03/12にクローズされました