テストを書きましょう
みなさん、自動テストしてますか?
今回はVite採用したReactフロントエンドでテストを書きたい場合どうしたらいいのかについて書いていきます。
冗長かもしれませんがテスト用の環境構築から書きます。
説明があまり細かくないので後から追記する気がします。
対象
今までテストを書いたことがなかった方
使うもの
- Vite
- TestingLibrary
- Vitest
環境構築
プロジェクト作成
めんどくさい人は
git clone https://github.com/na2na-p/training-vitest.git -b create-env
で3分クッキングのごとく環境構築が済んだ状態のものが上がってきます
まずはViteを使って環境構築をしてください。みんなお馴染み
yarn create vite
でいきます。作成後はプロジェクトのディレクトリの中に移動して作業開始です。
依存関係の追加
yarn
yarn add -D vitest @testing-library/jest-dom @testing-library/react @testing-library/user-event @testing-library/react-hooks jsdom
でテスト関係の依存を追加します。
設定追加
- vite.config.tsの追記
- vitest用のセットアップファイルの追加
- tsconfigの設定追加
の順番で行います。
vite.config.tsの追記
緑になってる行が追加箇所です。
+ /// <reference types="vitest" />
+
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: 'setup-vitest.ts',
+ /**
+ * for in-source testing.
+ * {@link https://vitest.dev/guide/in-source.html}
+ */
+ includeSource: ['src/**/*.{ts,tsx}'],
},
})
vitest用のセットアップファイルの追加
正直名前は何でもいいですが、vite.config.ts
のsetupFilesで指定したファイル名をつける必要があります。
今回はsetup-vitest.ts
で進めていきます。
参考: Vitestでtesting-library/jest-domを使えるようにする
import matchers from '@testing-library/jest-dom/matchers';
import { expect } from 'vitest';
import '@testing-library/jest-dom';
expect.extend(matchers);
tsconfig.jsonの設定追加
記事書いてるうちにこの項目は不要な気がしてきましたが一応
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
+ "types": ["vitest/importMeta"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
- "include": ["src"],
+ "include": [
+ "src",
+ "node_modules/vitest/globals.d.ts",
+ "vite.config.ts",
+ "setup-vitest.ts"
+ ],
"references": [{ "path": "./tsconfig.node.json" }]
}
今回の実践のためのファイルを置いていく場所を作る
src直下にtests
ディレクトリを作りましょう。
この記事で新しくファイルを作る、といった場合このディレクトリの中で作ると間違いないです。
ここまでで環境構築は完了です。
今すでにあるファイル群はいったん無視することにしましょう。
本記事の間で触れることはありません。
テストを書く
ここまできて本題です。
非Reactなところ
1=1テスト
まずは入門ということで1=1テストを書きましょう。
srcディレクトリの直下にhello.spec.ts
を作ります。
it('Hello vitest', () => {
const hello = 'Hello vitest';
expect(hello).toBe('Hello vitest');
});
it('Not jest', () => {
const hello = 'Hello vitest';
expect(hello).not.toBe('Hello jest');
});
こんな感じになります。
expect
の引数にテストしたい対象の値を渡します。
toBe
の引数に、期待されている対象の値を渡します。
I expect (the variable of) hello to be “Hello vitest”.
とするとわかりやすいかと思います。
また、.not
をつけると否定になります。
次からはよく使いそうなシチュエーションで書いていきます。
関数の結果が期待された値であることを確認する
func.spec.ts
を作ります。
function isString(arg?: string): boolean {
if (arg === undefined) {
return false;
}
if (typeof arg === 'string') {
return true;
}
// NOTE: ここに来ることはない
return false;
}
describe('isString', () => {
it('文字列が渡されてtrueが返る', () => {
expect(isString('test')).toBe(true);
});
it('undefinedが渡されてfalseが返る', () => {
expect(isString(undefined)).toBe(false);
});
});
話を単純にするためにツッコミどころありそうなコード書いてますが、こんな感じで使います。
モック関数を利用する
モック関数を利用します。ここでjestと微妙に違ってきます。
describe('funcMock', () => {
it('モック関数が呼ばれる', () => {
const mockFn = vi.fn(); // jest.fnではない
mockFn();
expect(mockFn).toHaveBeenCalled();
});
it('モック関数がtestという文字列を返す', () => {
const mockFn = vi.fn().mockReturnValue('test');
expect(mockFn()).toBe('test');
});
it('モック関数が1回だけ呼ばれる', () => {
const mockFn = vi.fn();
mockFn();
expect(mockFn).toBeCalledTimes(1);
});
});
オブジェクトの構造が一致してるか確認する
matchObject.spec.ts
を作ります。
const obj = {
name: 'na2na',
age: 22,
};
describe('matchObject', () => {
it('オブジェクトのプロパティが一致する', () => {
expect(obj).toMatchObject({
name: 'na2na',
age: 22,
});
});
it('オブジェクトのプロパティ名のうち、nameが一致する', () => {
expect(obj).toMatchObject({
name: 'na2na',
});
});
});
配列の構造が一致しているか確認する
matchArray.spec.ts
を作成します
const arr = [
{ id: 1, name: 'Hoge' },
{ id: 2, name: 'Fuga' },
];
describe('matchArray', () => {
it('配列の要素が一致する', () => {
expect(arr).toMatchObject([
{ id: 1, name: 'Hoge' },
{ id: 2, name: 'Fuga' },
]);
});
it('配列の要素のうち、idが一致する', () => {
expect(arr).toMatchObject([{ id: 1 }, { id: 2 }]);
});
});
In source testing
*.spec.ts
を作るのではなく、ソースコードと同じファイル中にテストを書くこともできます。
inSource.ts
を作ります。
関数やテストケース自体は先のfunc.spec.tsから拝借してきます。
function isString(arg?: string): boolean {
if (arg === undefined) {
return false;
}
if (typeof arg === 'string') {
return true;
}
// NOTE: ここに来ることはない
return false;
}
if (import.meta.vitest) {
describe('in source testing', () => {
it('文字列が渡されてtrueが返る', () => {
expect(isString('test')).toBe(true);
});
it('undefinedが渡されてfalseが返る', () => {
expect(isString(undefined)).toBe(false);
});
});
}
このようにすると、テストのためだけにexportする必要がなくなります。
Reactが関係してくるところ
Custom hooksの単体テスト
import { renderHook } from '@testing-library/react-hooks';
const useHooks = ({
argNumber,
handler,
}: {
argNumber: number;
handler: (name: string) => void;
}) => {
handler('handler args');
return {
argNumber,
};
};
describe('useHooks', () => {
it('idが渡されてidが返る', () => {
const id = 1;
const { result } = renderHook(() =>
useHooks({ argNumber: id, handler: vi.fn() })
);
expect(result.current.argNumber).toBe(id);
});
it('handlerが実行される', () => {
const mockFn = vi.fn();
renderHook(() => useHooks({ argNumber: 1, handler: mockFn }));
expect(mockFn).toHaveBeenCalled();
});
});
注意点としては、そのまま実行することができない点です。
renderHookを利用して、(内部的に)仮のコンポーネントをレンダリングして使う必要があります。
Reactコンポーネントの単体テスト
ちょっと細かめに書きます。
まずは今まで作業していたtests
ディレクトリにButton
ディレクトリを作ります。
最終的にはこのような2ファイルになります。
tests
├── Button
│ ├── Button.component.spec.tsx
│ └── Button.component.tsx
テスト対象のコンポーネント
まずこちらがテスト対象のコンポーネントです。
export type ButtonProps = {
disabled?: boolean;
dataTestid?: string;
} & JSX.IntrinsicElements['button'];
const Button = ({
type = 'button',
disabled = false,
dataTestid,
...restProps
}: ButtonProps) => {
return (
<button
{...restProps}
data-testid={dataTestid}
type={type}
disabled={disabled}
/>
);
};
export default Button;
data-testid
を受け渡しできるようにしておくと、後々のテスト実装が楽になります。
テストケースの用意
続いてテストの用意をします。
いきなりテストを書き始めることはせず、テストケースの書き出しをします。
今回は下記の3つにすることにしました。
describe('Button', () => {
it.todo('ボタンコンポーネントが存在する', () => {});
it.todo('ボタンクリックしたらonClickで渡したハンドラが動く', () => {});
it.todo('disabledがtrueならボタンがクリックできなくなる', async () => {});
});
setup関数の用意
続いてdescribeの下で共通に利用するsetup関数を準備します。
import { render, waitFor, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button.component';
describe('Button', () => {
const setup = ({
disabled = false,
onClick,
dataTestid,
}: {
disabled?: boolean;
onClick?: () => void;
dataTestid?: string;
}) => {
const user = userEvent.setup();
const utils = render(
<Button onClick={onClick} disabled={disabled} dataTestid={dataTestid} />
);
return { utils, user };
};
it.todo('ボタンコンポーネントが存在する', () => {});
it.todo('ボタンクリックしたらonClickで渡したハンドラが動く', () => {});
it.todo('disabledがtrueならボタンがクリックできなくなる', async () => {});
});
いくつか説明します。
- userEvent
userEvent.setup();
テスト上で、ユーザー操作によるシステム動作をシミュレートするライブラリとして、userEvent
を利用しています。
例えばクリックなど。
- renderResult
const utils = render(<Button onClick={onClick} disabled={disabled} />);
レンダリングした結果をとりあえずまとめて返してます。
テストの追加
表示されているか確認する
it('ボタンコンポーネントが存在する', () => {
const dataTestid = 'button';
setup({ dataTestid });
const button = screen.getByTestId(dataTestid);
expect(button).toBeInTheDocument();
});
dataTestidは、Testing Libraryでも利用できる、テスト対象の特定のために利用できる識別子です。
参照: https://testing-library.com/docs/queries/bytestid/
テストでは、button
というidを持つ要素が画面上に存在するかを確認(toBeInTheDocument)しています。
ちなみに、expect().toBeInTheDocument()
は、jestのカスタムマッチャをvitestから使えるようにしてます。
また、以下のようにしても確認ができます。
it('ボタンコンポーネントが存在する2', () => {
setup({});
const button = screen.queryByRole('button');
expect(button).toBeInTheDocument();
});
queryByRole
はgetByRole
でも利用可能です。この二つの違いは、実際に存在しないものを探してきた時に失敗するかどうかになります。queryByRole
は失敗せず続行、getByRole
は失敗しテストにもパスしなくなります。
同様の役割を持つものに、queryAllByRole
や、getAllByRole
が存在します。
個人的な意見としてはdata-testid
で特定する方が好みです。
今回は単一のコンポーネントなので混ざりようがないですが、少し複雑なコンポーネントになると選択対象が明確に選べなくなることを危惧しています。
クリックされた時にハンドラ関数が実行される
it('ボタンクリックしたらonClickで渡したハンドラが動く', async () => {
const dataTestid = 'button';
const onClick = vi.fn();
setup({ onClick, dataTestid });
const button = screen.getByTestId(dataTestid);
expect(button).toBeInTheDocument();
await waitFor(() => userEvent.click(screen.getByRole('button')));
expect(onClick).toBeCalled();
expect(onClick).toBeCalledTimes(1);
});
ここでようやくユーザー操作が登場します。
await waitFor(() => userEvent.click(screen.getByRole('button')));
waitForの中でユーザ操作を行います。非同期で実行するのがミソです。
「act
を使え」という記事を見るかと思いますが、React Testing Libraryの内部的に利用されていて、こちらに乗っかるにはwaitFor
で包むといいそうです
disabledがtrueならボタンがクリックできなくなる
it('disabledがtrueならボタンがクリックできなくなる', async () => {
const dataTestid = 'button';
const onClick = vi.fn();
setup({ disabled: true, dataTestid });
const button = screen.getByTestId(dataTestid);
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
await waitFor(() => userEvent.click(screen.getByRole('button')));
expect(onClick).not.toBeCalled();
});
今回はdisabledであることを確認(.toBeDisabled()
)し、かつクリックしてもonClickハンドラが呼ばれないこと(.not.toBeCalled()
)で以って要件を満たしていることを確認しています。