7
3

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.

2023年Vitestことはじめ

Last updated at Posted at 2023-06-01

テストを書きましょう

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

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

対象

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

使うもの

  • Vite
  • TestingLibrary
  • Vitest

環境構築

プロジェクト作成

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

まずは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

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

設定追加

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

の順番で行います。

vite.config.tsの追記

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

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を作ります。

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を作ります。

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

テスト対象のコンポーネント

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

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

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

setup関数の用意

続いてdescribeの下で共通に利用するsetup関数を準備します。

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
userEvent.setup();

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

  1. 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();
  });

queryByRolegetByRoleでも利用可能です。この二つの違いは、実際に存在しないものを探してきた時に失敗するかどうかになります。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で包むといいそうです

参考: 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ならボタンがクリックできなくなる

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

参考資料

7
3
0

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?