2023年Vitestことはじめ

# テストを書きましょう

みなさん、自動テストしてますか？

今回はVite採用したReactフロントエンドでテストを書きたい場合どうしたらいいのかについて書いていきます。
冗長かもしれませんがテスト用の環境構築から書きます。
説明があまり細かくないので後から追記する気がします。

# 対象

今までテストを書いたことがなかった方

# 使うもの

- Vite
- TestingLibrary
- Vitest

# 環境構築

## プロジェクト作成

めんどくさい人は
`git clone https://github.com/na2na-p/training-vitest.git -b create-env`で~~3分クッキングのごとく~~環境構築が済んだ状態のものが上がってきます

まずはViteを使って環境構築をしてください。みんなお馴染み
```sh
yarn create vite
```
でいきます。作成後はプロジェクトのディレクトリの中に移動して作業開始です。

## 依存関係の追加

```sh
yarn
yarn add -D vitest @testing-library/jest-dom @testing-library/react @testing-library/user-event @testing-library/react-hooks jsdom
```

でテスト関係の依存を追加します。

## 設定追加

1. vite.config.tsの追記
1. vitest用のセットアップファイルの追加
1. tsconfigの設定追加

の順番で行います。

### vite.config.tsの追記

緑になってる行が追加箇所です。

```diff: 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を使えるようにする](https://zenn.dev/link/comments/93116549b684c4)

```ts
import matchers from '@testing-library/jest-dom/matchers';
import { expect } from 'vitest';
import '@testing-library/jest-dom';
expect.extend(matchers);
```

### tsconfig.jsonの設定追加

記事書いてるうちにこの項目は不要な気がしてきましたが一応

```diff
{
  "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`を作ります。

```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`を作ります。
```ts: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と微妙に違ってきます。

```funcMock.spec.ts
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`を作ります。
```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`を作成します
```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から拝借してきます。

```inSource.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の単体テスト

```useHooks.spec.ts
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
```

#### テスト対象のコンポーネント
まずこちらがテスト対象のコンポーネントです。

```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つにすることにしました。

```tsx:Button.component.spec.tsx
describe('Button', () => {
  it.todo('ボタンコンポーネントが存在する', () => {});
  it.todo('ボタンクリックしたらonClickで渡したハンドラが動く', () => {});
  it.todo('disabledがtrueならボタンがクリックできなくなる', async () => {});
});
```

#### setup関数の用意

続いてdescribeの下で共通に利用するsetup関数を準備します。
```tsx:Button.component.spec.tsx
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 () => {});
});
```

いくつか説明します。

1. userEvent
```ts
userEvent.setup();
```
テスト上で、ユーザー操作によるシステム動作をシミュレートするライブラリとして、`userEvent`を利用しています。
例えばクリックなど。

2. renderResult
```ts
const utils = render(<Button onClick={onClick} disabled={disabled} />);
```
レンダリングした結果をとりあえずまとめて返してます。

#### テストの追加

##### 表示されているか確認する

```ts
  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から使えるようにしてます。

また、以下のようにしても確認ができます。
```ts
  it('ボタンコンポーネントが存在する2', () => {
    setup({});
    const button = screen.queryByRole('button');
    expect(button).toBeInTheDocument();
  });
```
`queryByRole`は`getByRole`でも利用可能です。この二つの違いは、実際に存在しないものを探してきた時に失敗するかどうかになります。`queryByRole`は失敗せず続行、`getByRole`は失敗しテストにもパスしなくなります。
同様の役割を持つものに、`queryAllByRole`や、`getAllByRole`が存在します。
個人的な意見としては`data-testid`で特定する方が好みです。
今回は単一のコンポーネントなので混ざりようがないですが、少し複雑なコンポーネントになると選択対象が明確に選べなくなることを危惧しています。

#### クリックされた時にハンドラ関数が実行される

```ts
  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);
  });
```

ここでようやくユーザー操作が登場します。
```ts
await waitFor(() => userEvent.click(screen.getByRole('button')));
```
waitForの中でユーザ操作を行います。非同期で実行するのがミソです。

「`act`を使え」という記事を見るかと思いますが、React Testing Libraryの内部的に利用されていて、こちらに乗っかるには`waitFor`で包むといいそうです

参考: https://zenn.dev/crsc1206/articles/8bf487be129eed#%F0%9F%91%89act%E9%96%A2%E6%95%B0%E3%81%A8rtl%E3%81%AEwaitfor%E9%96%A2%E6%95%B0%E3%81%AE%E8%A9%B1

#### disabledがtrueならボタンがクリックできなくなる

```ts
  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()`)で以って要件を満たしていることを確認しています。

# 参考資料

- https://zenn.dev/link/comments/93116549b684c4
- https://testing-library.com/docs/queries/bytestid/
- https://zenn.dev/crsc1206/articles/8bf487be129eed#%F0%9F%91%89act%E9%96%A2%E6%95%B0%E3%81%A8rtl%E3%81%AEwaitfor%E9%96%A2%E6%95%B0%E3%81%AE%E8%A9%B1