はじめに
Viteのバージョン4.0の公開をアナウンスするブログでVitestについて言及されていました。
Vitest adoption is exploding, it will soon represent half of Vite's npm downloads. Nx is also investing in the ecosystem, and officially supports Vite.
Vitestの採用は爆発的に増えており、まもなくViteのnpmダウンロードの半分を占めるようになるでしょう。Nxもエコシステムに投資しており、Viteを公式にサポートしています。(DeepLによる翻訳)
これまではJavaScript、TypeScriptにおけるテストツールとしてはJestという成熟したツールがあるので、Vitestを利用するのは趣味だったり少し先の未来だろうと考えていました。しかし、リリースブログを読んでVite環境においてはVitestの方が使い勝手が良く積極的に利用を始めても良いのではないかと考えを改めVitestを学びました。
この記事ではそんな中で学んだVitestについて紹介していきます。
Vitestとは
Vitestは公式でBlazing Fast Unit Test Framework
と謳われています。とんでもなく早いユニットテストフレームワークということです。
Vitestが持つ特徴はいくつもあります。
- Viteのconfig、transformers、resolversとplugins
- アプリと同じ設定
- ViteのHMRのようなwatchモード
- Vue、React、Svelte、Litなどのコンポーネントテストをサポート
- TypeScriptやJSXのサポート
- ESMファースト, top level await
- Tinypoolを搭載したWorkerのマルチスレッド
- Tinybenchによるベンチマークをサポート
- テストスイートやテスト自身のフィルタリング、タイムアウト、並列実行
- Jest互換のスナップショット
- アサーションのためのChai搭載、Jestのexpectと互換性のあるAPI
- モックのためにTinyspyを搭載
- happy-domやjsdomを使ったDOMのモック
- c8かistanbulによるCoverage
- Rustのような実装コード内におけるテスト
- expect-typeを用いた型のテスト
特にViteと同じ構成を利用してテストを実行できること、Jestと互換があること、watchモードが高いパフォーマンスを持つこと、ESMとTypeScriptとJSXがサポートされていることが大きな特徴を持ちます。
Vitestの導入
VitestをVite環境に導入していきます。Vite環境は以下のコマンドで作れます。
# npm
npm create vite@latest
# yarn
yarn create vite@latest
# pnpm
pnpm create vite@latest
それに対してVitestは
npm i -D vitest
yarn add -D vitest
pnpm add -D vitest
でインストールします。今後はpnpm
を使うとして進めていくので好みのパッケージマネージャーに読み変えてください。
次にVitestの設定を行います。最初に特徴として紹介した通りViteの設定を共有して行うことが出来ます。
Viteの設定ファイルvite.config.ts
はデフォルトで以下のようになっています。
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})
ここに加えていきます。設定を共有するにはまず、冒頭に/// <reference types="vitest" />
を追加してファイルに型情報を与えます。これによってtest
をキーとしたVitestの設定を記述することができるようになります。例えばテストを実行するファイルを絞るような設定をする場合は以下のように書きます。
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}']
}
})
これで基本機能を共有したVitestの設定が完了します。vite.config.ts
の他にもvitest.config.ts
で設定を行うことも出来ます(vite.config.ts
より優先度が高く反映されます)。
最後に実行コマンドの登録です。Vitestの実行はpnpm vitest
で行うことができます(npmのnpx vitest
と同義です)。Vitestがおすすめするpackage.jsonにおけるスクリプトの設定は以下の通りです。
{
"scripts": {
"test": "vitest",
"coverage": "vitest run --coverage"
}
}
これによってpnpm test
でwatchモードのテストを実行することが出来ます。また、@vitest/coverage-c8
をインストールすることで、pnpm coverage
と実行して下のようなcoverageを出力することが出来ます。その他の設定はこちらを参照してください。
また、VSCodeではプラグインを導入することで下のように便利にテストを実行できます(画像は公式より引用)。
ここまでの設定でテストが実行できることを確かめるために、引数を全て足す関数とそのテストを作成して実行してみます。追加する前ではテストを実行しても件数が0なのでエラーが出て実行できないことを確認してください。
関数は以下のように定義します。
export const add = (...args: number[]) => args.reduce((a, b) => a + b, 0)
テストは下のように書けます(記法がjestと同じですね)。
import { expect, it } from 'vitest';
import { add } from "../add"
it('add', () => {
expect(add()).toBe(0)
expect(add(1)).toBe(1)
expect(add(1, 2, 3)).toBe(6)
})
この状況でpnmp test
とpnpm coverage
が実行できたら基本の設定完了です。
インポートなしで利用する
Vitestが提供するAPIをJestのようにグローバルで利用するには設定を変更する必要があります。Vitestの設定ファイルに以下のように追加します。
test: {
global: true
}
TypeScriptを利用の場合はグローバルで利用するAPIの型を与えるために以下のように設定を追加します。
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}
これによって先ほど記述したテスト
import { expect, it } from 'vitest';
import { add } from "../add"
it('add', () => {
expect(add()).toBe(0)
expect(add(1)).toBe(1)
expect(add(1, 2, 3)).toBe(6)
})
は以下のように書くことが出来ます。
import { add } from "../add"
it('add', () => {
expect(add()).toBe(0)
expect(add(1)).toBe(1)
expect(add(1, 2, 3)).toBe(6)
})
コマンド
pnmp vitest
のように実行できるコマンドを紹介します。
vitest
pnpm vitest
は置かれたディレクトリでテストを行うコマンドです。基本的にはwatchモードですが、CI環境では自動で一度きりの実行に切り替わります。また、pnpm vitest add
のようにして、ファイル名に追加した文字列を含むテストのみを実行することも可能です。この例だとadd
を含むファイルのテストが実行されます。
vitest watch
pnpm vitest watch
はwatchモードで実行します。全てのテストを実行し、実行後はテストファイルの監視を行います。テストファイルの変更を検知すると、そのテストだけが再実行されます。pnpm vitest dev
でも全く同じ挙動で実行することが出来ます。
vitest run
pnpm vitest run
は全てのテストを一度だけ実行するコマンドです。CI環境におけるpnpm vitest
と同じ挙動をします。わかりやすさのためにCIを書くときはこちらのコマンドで記述した方が良いと考えています。
vitest related
vitest related
は引数に取ったテストファイルのみを実行するコマンドです。先ほどの例だとpnpm vitest related ./src/utils/add.test.ts
のように行うことが出来ます。このコマンドは他のコマンドと合わせて使うことが出来ます。watchモードにしたい場合はpnpm vitest watch related ./src/utils/add.test.ts
のようにすることでwatch
モードになります(コマンドラインからは元々watchモードで実行されるので動作は変わりません)。1度だけ実行したい場合はpnpm vitest run related ./src/utils/add.test.ts
とすることで出来ます。このコマンドはhuskyとlint-stagedを用いてコミット前に検査させるときなどで役に立ちます。試しにhuskyとlint-stagedを使ってセットアップしてみます。
まずhuskyとlint-stagedの基本的な用意を行ないます。
pnpm dlx husky-init && pnpm install
pnpm add -D lint-staged
次にlintstagedの設定ファイルを用意します。
const path = require('path');
const buildCommand = (filenames) => {
const files = filenames
.map((f) => path.relative(process.cwd(), f))
.join(' ');
return [
`prettier --write ${files}`,
`eslint --max-warnings=0 --fix ${files}`,
`vitest run related ${files}`
];
}
module.exports = {
'src/**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}': buildCommand,
};
prettierとeslintの設定も書きましたがこれはおまけなのでVitestしか行わない場合は消してください。最後にpnpm dlx husky-init
によって生成されたprecommitファイルを以下のように変更します。
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
このように設定することでテストファイルをコミットした時にそのファイルのテストが実行される仕組みを作ることが出来ます。jsに類するファイルすべてを検査するようにしたのはVitestではソース内コードでテストが可能だからです。
横道にそれましたが、これらがVitestの持つコマンドです。
オプション
これらのコマンドに渡せるオプションも様々なものがあります。量が多いので抜粋して消化します。
よく使うコマンドとして紹介するのが--coverage
です。このコマンドは導入でも利用しましたが、@vitest/coverage-c8
を使ってcoverageを表示してくれるようなコマンドです。
興味深いコマンドとして--ui
を紹介します。@vitest/ui
と合わせて使うオプションです。
ローカルポートを立ててブラウザ上にテストの状況やモジュールのグラフを表示してくれます。pnpm vitest --ui
のように実行することが出来ます。
わかりやすいUIなのでテストを書くモチベーションが上がりますね。
この記事で紹介するオプションは以上です。他のオプションを知りたい場合はこちらを参照するか、pnpm vitest --help
で参照してください。
テストの書き方
Vitestのテストの書き方を紹介します。ここで紹介するサンプルコードではimportを省いて書きます。
テストの定義
テストスイートはdescribe
によって定義することができ、テスト自体はit
で定義することが出来ます。
describe('suite', () => {
it('test', () => {
// ここにテスト内容を書く
}, 1000)
})
it
の代わりにtest
を使うことが出来ます。it
やtest
の第三引数にはでテストにかける最大の時間timeout
を与えることが出来ます。
さらにdescribe
とit
やtest
には.skip
や.only
、.todo
を使うことで特定のスイートやテストのスキップ、実行、todo化を行うことが出来ます。
describe.todo.only('todo and only suite', () => {
it.skip('skip test', () => {
//
})
it('normal test', () => {
//
})
it.only('only test', () => {
//
})
})
他にも.each
を使うことで簡単に異なる変数で同じテストを実行させることが出来ます。
it.each([
[1, 2, 3],
[1, 3, 2],
[2, 1, 3],
[2, 3, 1],
[3, 1, 2],
[3, 2, 1],
])('it pattern(%i, %i, %i)' (a, b, c) => {
//
})
ここまでの機能はJestにも同等のものがありましたが、Vitestでは他に条件に当てはまった場合のみスキップする.skipIf
、条件に当てはまった時のみ実行する.runIf
、並列してテストを実行させる.concurrent
、テストが失敗した時だけ通過させる.fails
がit
、test
で利用できます。それぞれ以下のように使えます。
it.skipIf(import.meta.env.DEV).('Dev環境ではスキップされるテスト', () => {
//
})
it.runIf(import.meta.env.DEV).('Dev環境でだけ実行されるテスト', () => {
//
})
test.concurrent('test', () => {
//
})
test.concurrent('testと並列して実行されるテスト', () => {
//
})
test.fails('失敗すると通過する', () => {
// 失敗するテストを書く
})
環境によって実行させるテストを選択できたり、並行でテストができるのは大変便利なので積極的に利用していきたいです。
describe
にもVitest固有の機能があります。it
などにもあった.concurrent
と中身をランダムな順番で実行させる.shuffle
です。.convurrent
はそのスイートのテスト全てが並列実行されます。.shuffle
で行われるランダムな選択はコマンドオプションや設定で指定させることも出来ます。
expect
expectはJestの書き方とChaiの書き方の両方を使えます。等しいことを表すコードは以下の二種類を書くことが出来ます。
// Chai
expect(1).to.eqaul(1);
// Jest
expect(1).toBe(1);
expectに関するAPIはたくさん用意されてことに加えて多くがJestやChaiに共通するのでこの記事ではこれ以上紹介しません。気になる場合はAPIリファレンスを参照してください。
テスト前後の処理
JestにもあったようにVitestでもbeforeEach
、beforeAll
、afterEach
、afterAll
を利用できます。beforeEach
には各テストが実行される前に発火させる関数を、beforeAll
にはスイート内の最初のテストが実行される前に発火させる関数を、afterEach
には各テストが実行された後に発火させる関数を、afterAll
にはスイート内の最後のテストが実行された後に発火させる関数を配置します。
describe('suit', () => {
beforeEach(() => {
console.log(1);
})
beforeAll(() => {
console.log(2);
})
afterEach(() => {
console.log(3);
})
afterAll(() => {
console.log(4);
})
it('test1', () => {
//
});
it('test1', () => {
//
});
})
このようなテストがあった場合は2 => 1 => 3 => 1 => 3 => 4の順番で出力されます。
vi
Vitestにはユーティリティ関数としてvi
が提供されています。これを用いてさまざまなことが出来ます。代表的なのはモックに関わる機能です。
vi
はimport vi from 'vitest'
で利用することが出来ます。グローバルなAPIなので設定によってはインポートなして利用できます。
vi
から利用できるAPIはこちらに書かれています。
モック
Vitestには二種類のモックが存在します。vi.spyOn
とvi.fn
です。vi.spyOn
は関数の振る舞いは変えずに、呼び出された回数の計測などを行うのに対してvi.fn
はテストのための関数を新しく作成します。
import * as calc from './add';
describe('add', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('spyは特定の関数をテスト用の関数に変更するイメージ', () => {
const spy = vi.spyOn(calc, 'add')
expect(spy.getMockName()).toEqual('add')
expect(calc.add()).toEqual(0)
expect(spy).toHaveBeenCalledTimes(1)
spy.mockReturnValue(10)
expect(calc.add()).toEqual(10)
expect(spy).toHaveBeenCalledTimes(2)
})
it('fnはテスト用の関数を新たに作成するイメージ', () => {
const mock = vi.fn(() => 5);
expect(mock()).toEqual(5)
expect(mock).toHaveBeenCalledTimes(1)
mock.mockReturnValue(10)
expect(mock()).toEqual(10)
expect(mock).toHaveBeenCalledTimes(2)
})
})
実際のテストの挙動を見るとvi.spy
の方はあくまで元の関数があってそれに対する振る舞いの変更や監視を行っているのに対して、vi.fn
では監視を行うテスト用の関数を新たに作成していることがわかります。afterEach
でテストが一つ終わるたびにモックを全て元に戻すための機能vi.restoreAllMocks
を行っています。この処理を行わないとバグに繋がりますので必ず行うようにしてください。
これらの機能が使われる実際のケースに近いものとしては以下のようなものが考えられます。
import { add } from "./add";
export const calcTwoNumbers = (
calc: (a: number, b: number) => number,
a: number,
b: number,
) => calc(a, b)
export const addTwoNumbers = (
a: number,
b: number,
) => add(a, b)
import * as calc from './add';
import { addTwoNumbers, calcTwoNumbers } from './calc';
describe('spyとfnそれぞれでcalcのテストを書く', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('spy', () => {
const spy = vi.spyOn(calc, 'add')
spy.mockReturnValue(3)
expect(calc.addTwoNumbers(calc.add, 1, 1)).toBe(3)
expect(spy).toHaveBeenCalledTimes(1);
})
it('fn', () => {
const mock = vi.fn(() => 3)
expect(calc.calcTwoNumbers(mock, 1, 1)).toBe(3)
expect(mock).toHaveBeenCalledTimes(1);
})
})
vi.spy
は内部で使われている関数に対しても監視させることが可能である点が有利です。一方のvi.fn
は引数に渡す関数をブラックボックス化して他の関数に依存しないテストを作れる点で優れています。
このようにvi.spy
とvi.fn
はカテゴリの異なる機能ですが、mockReturnValue
やmockImplementationOnce
など呼び出すことのできるメソッドは共通しています。呼び出せるメソッドはMockInstanceメソッドと呼ばれていてここに書かれています。
スナップショット
過去の結果と同じであることを確かめるためのテストであるスナップショットテストがVitestには用意されています。
it('snapshot', () => {
expect(1).toMatchSnapshot()
});
このファイルを実行すると同じディレクトリに__snapshots__
ディレクトリが生成され、そこにadd.test.ts.snap
というファイルが生成されます(実行したファイルはadd.test.ts
です)。実行したときに過去のテストの記録が存在したときは参照して値の変化がないかを確認するテストに変化します。値に変更があった場合はエラーが出るので間違いではなく更新したい場合は実行したコマンドラインでu
を入力してください。
他のスナップショットテストとしてtoMatchInlineShapshot
があります。このAPIはtoMatchSnapshot
のように別ファイルに書くのではなくテストファイルに直接書き込みます。
it('inline snapshot', () => {
expect(1).toMatchInlineSnapshot()
});
上のテストを実行するとスナップショットが取られ、
it('inline snapshot', () => {
expect(1).toMatchInlineSnapshot("1")
});
のようになります。toMatchInlineSnapshot
の引数に過去の値が書き込まれています。引数がある場合はexpect
に渡された引数と比較するテストに変化します。
これらのAPIはコマンドラインでu
を入力してリセットすることも可能ですが、pnpm vitest --update
または、pnpm vitest -u
でもリセットすることが出来ます。
この機能におけるJestとの差分は2点あります。
1点目はtoMatchSnapshot
を行った時に出力されるファイルのコメントが// Jest Snapshot v1
から// Vitest Snapshot v1
となっている点です。
もう一点はVitestのデフォルトではpretty-formatの記法を利用できない点です。
以下のようなコードは失敗します。
it('snapshot', () => {
const bar = [1, 2, 3]
expect(bar).toMatchInlineSnapshot(`Array [1, 2, 3]`)
})
pretty-formatを有効にしたい場合は設定ファイルのtestに下のような設定を追加してください。
snapshotFormat: {
printBasicPrototype: true
}
型テスト
Vitestでは型を利用したテストを実行できます。型テストはexpectTypeOf
とassertType
の二つのAPIを利用します。
テストは以下のように書けます。
it('type test', () => {
expectTypeOf([1, 2, 3]).toEqualTypeOf<number[]>();
assertType<[1, 2, 3]>([1, 2, 3]);
})
toEquakTypeOf
は型引数の型になっていることを確認します。型に対する検証メソッドは他にもたくさんあります。例えばtoMatchTypeOf
ではexpectTypeOf
に渡した型が型引数の型に含まれていることを検査します。他のメソッドについてはこちらを参照してください。
型テストは他のテストと区別して実行させることが出来ます。型テストだけを行うテストは*.test-d.ts
のような形式のファイルに記述します。他の形式でも行いたい場合は設定ファイルのtypecheck.include
を編集してください。
型テストを実行するコマンドは先ほど紹介できませんでしたがpnpm vitest typecheck
です。これによって実行されるテストは構文解析されるだけなのでより高速に結果を得ることが出来ます。構文の解析しか行われないのでrunIf
やskipIf
などコードの実行が必要となる機能は利用できないことに注意してください。
ソース内でテスト実行
VitestではRustのように実装コード内でテストを書くことが出来ます。まずは設定を変更する必要があります。
test: {
includeSource: ['src/**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
}
さらにTypeScriptを利用している場合は型情報vitest/importMeta
を追加します(import.meta.vitest
の型)。
{
"compilerOptions": {
"types": [
"vitest/importMeta"
]
}
}
下ように設定後は以下のようにソースコード内にテストを埋め込んでVitestのテストを行うことが可能となります。
export const add = (...args: number[]) => args.reduce((a, b) => a + b, 0)
if (import.meta.vitest) {
const { it, expect } = import.meta.vitest
it('add', () => {
expect(add()).toBe(0)
expect(add(1)).toBe(1)
expect(add(1, 2, 3)).toBe(6)
})
}
import.meta.vitest
で分岐させていることで本番ではこの中身は実行されませんが、バンドルファイルには含まれてしまいます。これはパフォーマンスの低下につながるのでvite.config.ts
のViteの設定で以下のようにすることで含まれないようにしましょう(testの配下ではないので注意してください)。
define: {
'import.meta.vitest': 'undefined',
},
環境
Vitestはデフォルトではnode環境で実行できるテストのみが対象となっています。ブラウザで実行できるようなテストを書きたい場合はjsdomまたはhappy-domを利用します。
happy-domはjsdomよりも高速であるのに対して、APIがjsdomほど豊富でないという特徴があります。
利用するには利用したいパッケージをインストールしてvitestの設定に追加します。
test: {
environment: 'happy-dom',
}
この例ではhappy-domを利用すると仮定して'happy-dom'
と設定しましたが、'jsdom'
と設定することでjsdomを利用することが出来ます。
この他にもデフォルトのnode
やvecelが開発する'edge-runtime'
を設定することが可能です。
おわりに
提供されるAPIの数が多いので全てを説明することはできませんでしたが、テストを書く上で不便ない程度で紹介できたのではないでしょうか。Jestを使っていた人も移行しやすく、高速ですのでViteを利用していて他のテストツールをご利用の場合はVitestの採用を検討してみてはいかがでしょうか。