57
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

1人フロントエンドAdvent Calendar 2022

Day 24

Viteを利用したテストツールVitestの利用を始める

Last updated at Posted at 2022-12-24

はじめに

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-domjsdomを使ったDOMのモック
  • c8istanbulによる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を出力することが出来ます。その他の設定はこちらを参照してください。
スクリーンショット 2022-12-24 10.11.06.png
また、VSCodeではプラグインを導入することで下のように便利にテストを実行できます(画像は公式より引用)。
202203292020.gif
ここまでの設定でテストが実行できることを確かめるために、引数を全て足す関数とそのテストを作成して実行してみます。追加する前ではテストを実行しても件数が0なのでエラーが出て実行できないことを確認してください。
関数は以下のように定義します。

src/utils/add.ts
export const add = (...args: number[]) => args.reduce((a, b) => a + b, 0)

テストは下のように書けます(記法がjestと同じですね)。

src/utils/add.test.ts
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 testpnpm coverageが実行できたら基本の設定完了です。

インポートなしで利用する

Vitestが提供するAPIをJestのようにグローバルで利用するには設定を変更する必要があります。Vitestの設定ファイルに以下のように追加します。

test: {
  global: true
}

TypeScriptを利用の場合はグローバルで利用するAPIの型を与えるために以下のように設定を追加します。

tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

これによって先ほど記述したテスト

src/utils/add.test.ts
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)
})

は以下のように書くことが出来ます。

src/utils/add.test.ts
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とすることで出来ます。このコマンドはhuskylint-stagedを用いてコミット前に検査させるときなどで役に立ちます。試しにhuskyとlint-stagedを使ってセットアップしてみます。
まずhuskyとlint-stagedの基本的な用意を行ないます。

pnpm dlx husky-init && pnpm install
pnpm add -D lint-staged

次にlintstagedの設定ファイルを用意します。

.lintstagedrc.cjs
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ファイルを以下のように変更します。

.husky/precommit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

pnpm lint-staged

このように設定することでテストファイルをコミットした時にそのファイルのテストが実行される仕組みを作ることが出来ます。jsに類するファイルすべてを検査するようにしたのはVitestではソース内コードでテストが可能だからです。
横道にそれましたが、これらがVitestの持つコマンドです。

オプション

これらのコマンドに渡せるオプションも様々なものがあります。量が多いので抜粋して消化します。
よく使うコマンドとして紹介するのが--coverageです。このコマンドは導入でも利用しましたが、@vitest/coverage-c8を使ってcoverageを表示してくれるようなコマンドです。
スクリーンショット 2022-12-24 10.11.06.png
興味深いコマンドとして--uiを紹介します。@vitest/uiと合わせて使うオプションです。
ローカルポートを立ててブラウザ上にテストの状況やモジュールのグラフを表示してくれます。pnpm vitest --uiのように実行することが出来ます。
スクリーンショット 2022-12-24 16.21.21.png
わかりやすいUIなのでテストを書くモチベーションが上がりますね。

この記事で紹介するオプションは以上です。他のオプションを知りたい場合はこちらを参照するか、pnpm vitest --helpで参照してください。

テストの書き方

Vitestのテストの書き方を紹介します。ここで紹介するサンプルコードではimportを省いて書きます。

テストの定義

テストスイートはdescribeによって定義することができ、テスト自体はitで定義することが出来ます。

describe('suite', () => {
  it('test', () => {
    // ここにテスト内容を書く
  }, 1000)
})

itの代わりにtestを使うことが出来ます。ittestの第三引数にはでテストにかける最大の時間timeoutを与えることが出来ます。
さらにdescribeittestには.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、テストが失敗した時だけ通過させる.failsittestで利用できます。それぞれ以下のように使えます。

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でもbeforeEachbeforeAllafterEachafterAllを利用できます。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が提供されています。これを用いてさまざまなことが出来ます。代表的なのはモックに関わる機能です。
viimport vi from 'vitest'で利用することが出来ます。グローバルなAPIなので設定によってはインポートなして利用できます。
viから利用できるAPIはこちらに書かれています。

モック

Vitestには二種類のモックが存在します。vi.spyOnvi.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を行っています。この処理を行わないとバグに繋がりますので必ず行うようにしてください。
これらの機能が使われる実際のケースに近いものとしては以下のようなものが考えられます。

calc.ts
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)
calc.test.ts
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.spyvi.fnはカテゴリの異なる機能ですが、mockReturnValuemockImplementationOnceなど呼び出すことのできるメソッドは共通しています。呼び出せるメソッドは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では型を利用したテストを実行できます。型テストはexpectTypeOfassertTypeの二つの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です。これによって実行されるテストは構文解析されるだけなのでより高速に結果を得ることが出来ます。構文の解析しか行われないのでrunIfskipIfなどコードの実行が必要となる機能は利用できないことに注意してください。

ソース内でテスト実行

VitestではRustのように実装コード内でテストを書くことが出来ます。まずは設定を変更する必要があります。

test: {
  includeSource: ['src/**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
}

さらにTypeScriptを利用している場合は型情報vitest/importMetaを追加します(import.meta.vitestの型)。

{
  "compilerOptions": {
    "types": [
      "vitest/importMeta"
    ]
  }
}

下ように設定後は以下のようにソースコード内にテストを埋め込んでVitestのテストを行うことが可能となります。

add.ts
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の採用を検討してみてはいかがでしょうか。

57
36
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
57
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?