Masayan tech blog.

  1. ブログ記事一覧>
  2. フロントエンドのテストツールJestに入門する

フロントエンドのテストツールJestに入門する

公開日

基本

主要メソッド

項目

概要

詳細

test()

テストを記述するためのメソッド

・it()というのもあるが、別名なだけなのでどちらかに統一して使用するとよい
・第1引数には、テストの説明を、第2引数テスト対象の関数を、第3引数にはタイムアウト(単位:ミリ秒)を指定 ※デフォルトタイムアウトは5秒

describe()

関連するテストをまとめるブロックを作成するためのメソッド

expect()

期待値を指定する

expectの引数に指定した関数にチェーンし、関数の実行結果とマッチャ(下記例ではtoBe())で指定した値が等しいかをもって評価する

recipe.test.ts

const recipeList: Recipe[] = [
  { id: 1, name: '牛丼' },
  { id: 2, name: 'ハンバーグ' },
]

describe('IDでレシピを検索できる', () => {
  test('存在するレシピ', () => {
    expect(findById(recipeList, 2)).toBe(recipeList.at(1))
  })

  test('存在しないレシピの場合はundefined', () => {
    expect(findById(recipeList, 99)).toBe(undefined)
  })
})

recipe.ts

export type Recipe = {
  id: number
  name: string
  image?: string
}
export const findById = (recipeList: Recipe[], id: number) => recipeList.find((recipe: Recipe) => recipe.id === id)

Parameterized test

関数の引数を複数パターンチェックしたい場合、test.eachが効果的です。テストの見やすさが格段に向上します。

テスト対象クラス

enum MenuType {
  Western = 1,
  Japanese = 2,
  Chinese = 9,
}

// eslint-disable-next-line @typescript-eslint/no-namespace
namespace MenuType {
  export function label(menuType: MenuType) {
  switch (menuType) {
    case MenuType.Western:
      return '洋食'
    case MenuType.Japanese:
      return '和食'
    case MenuType.Chinese:
      return '中華'
    }
  }
}

export default MenuType

テスト

src/classes/menu/MenuType.test.ts

test.each([
  [MenuType.Western, '洋食'],
  [MenuType.Japanese, '和食'],
  [MenuType.Chinese, '中華'],
])('メニュータイプの表示名', (type, expected) => {
  expect(MenuType.label(type)).toBe(expected)
})

マッチャー

  • 値が特定の条件に合致することを確認することが可能なメソッド
  • 以下は代表的な例で、その他にもたくさんあるので、詳細は以下参照

項目

概要

詳細

toBe

プリミティブ値の比較・オブジェクトの等価性の比較

・プリミティブ値(String, Number, Booleanなど)が等しいか
・オブジェクト(Object, Array, Mapなど)が同一のインスタンスの参照であるか

toEqual

オブジェクトの同値性の比較

・オブジェクトインスタンスのすべてのプロパティや、配列のすべての要素を再帰的に等しいか
(参照先が異なるオブジェクトでも値が同じであれば値が等しいとされる
・toStrictEqualとは異なり、undefinedの値を持つプロパティは無視される)

toStrictEqual

toEqualよりも厳格な同値性の比較

toEqualのように、undefinedの値を持つプロパティは無視されないのでその意味で厳格な比較

recipe.test.ts

const recipeList: Recipe[] = [
  { id: 1, name: '牛丼' },
  { id: 2, name: 'ハンバーグ' },
]

describe('diff between toEqual and toStrictEqual'', () => {
  // 等しい(undefinedが含まれるプロパティは無視されるので等しい)
  test('toEqual', () => {
    expect(recipeList).toEqual([
      { id: 1, name: '牛丼', image: undefined },
      { id: 2, name: 'ハンバーグ', image: undefined },
    ])
  })

  // 等しくない(undefinedが含まれるプロパティは無視されないので等しくない)
  test('toStrictEqual', () => {
    expect(recipeList).not.toStrictEqual([
      { id: 1, name: '牛丼', image: undefined },
      { id: 2, name: 'ハンバーグ', image: undefined },
    ])
  })
})

セットアップ

テストの前後で実行する処理。他の一般的なテストツールにも存在する

主なメソッド

項目

概要

詳細

beforeEach / afterEach

個々のテストの開始終了時に処理を実施したい場合(各テストの前後で毎回実行)

-

beforeAll / afterAll

テスト全体の開始終了時に処理を実施したい場合

-

実行順序(原則)

  • beforeAll
  • beforeEach
  • test(it)
  • afterEach
  • afterAll

モック

※以下に示す例では、説明の都合上、モック自体をテストしているが実際はモックを使用する関数、クラスのメソッドなどのテストの中でモックを使用することになる

メソッドのモック

  • jest.spyOn(object, methodName)を使用してモック化する
  • 第一引数のobjectにはモックの対象となるメソッドを保有するオブジェクトを、第二引数には対象のメソッド名を指定
  • spyOn()の返り値はSpyInstanceであり、ここからモック用のメソッドをチェーンして、モック関数を希望する実装に差し替えることが可

something.ts

class Hoge {
  // somethingFuncをテストしたいが、内部でDate.now()を使用しているため、テストを実行するたびに値が異なり、テストが困難
  somethingFunc() {
    return Date.now();
  }
}

something.test.ts

test("SomethingFunc test", () => {
  // Dateオブジェクトのnowメソッドをモックし、固定の値を返すようにすることで、somethingFuncのテストが可能になる
  const spy = jest.spyOn(Date, "now").mockReturnValue(1662275723971); // 2020/09/04;
  expect(somethingFunc()).toBe(1662275723971);
  spy.mockRestore();
});
  • spy.mockRestore
    • モックをオリジナルに戻す(spyOnでのみ可能)。これをしないと他のテストで意図しない挙動(オリジナルの関数を使いたいのに、モックを使ってしまう)になる可能性がある
    • モックの初期化については下部参照

getterのモック

  • 基本的にはメソッドのモックと同じで、jest.spyOnを使用するが、記載方法が少し異なる

Product.ts

export default class Product {
  constructor(
    private _name: string,
    private _price: number
  ) {}
  get name(): string {
    return this._name
  }
  
  get price(): number {
    return this._price
  }
}

Product.test.ts

  • jest.spyOn(Product.prototype, 'name', 'get')のように、指定する(第三引数はgetterなのかsetterなのかを指定)
test('getter', () => {
  jest.spyOn(Product.prototype, 'name', 'get').mockImplementation(() => '色鉛筆セット')
  jest.spyOn(Product.prototype, 'price', 'get').mockImplementation(() => 1000)
 
  const productGetterMock = new Product('', 0)
  expect(productGetterMock.name).toBe('色鉛筆セット')
  expect(productGetterMock.price).toBe(1000)
})

関数のモック

  • メソッドの場合とほぼ同様だが、jest.spyOn(moduleName, methodName)の形で指定する
// 対象のモジュールを名前付きimportで変数を割り当てる
import * as somethingModule from './something';

const spy = jest.spyOn(somethingModule, "somethingFunc").mockReturnValue("something return value");
expect(somethingFunc()).toBe("something return value");

spy.mockRestore();

クラスのモック

  • jest.mockと型アサーションを組み合わせてAxiosをモックする
  • インスタンス生成が難しいクラスを使用しているクラスや関数はモックを使うとテストがしやすい
  • Product.tsとProductList.tsがあり、ProductクラスがまだできていないもしくはProductのオブジェクトの生成が複雑または困難であると前提に立つ
  • ProductListの生成には、Productのオブジェクトが入った配列が必要になるため、Productクラスをモックにしてテストを可能にする

Product.ts

export default class Product {
  constructor(
    private _name: string,
    private _price: number
  ) {}

  get name(): string {
    return this._name
  }

  get price(): number {
    return this._price
  }

  public priceWithTax(): number {
    return this._price * 1.1
  }
}

ProductList.ts(Productオブジェクトの配列を持つクラス)

import Product from './Product'

export default class ProductList {
  constructor(private products: Product[]) {}

  public totalPrice(): number {
    return this.products
    .map((product: Product) => product.priceWithTax())
    .reduce((prev, current) => prev + current, 0)
  }
}
  • mockImplementationOnceを使うと、複数回(今回の例では2回)呼び出し(new Product())で異なる実装を返すといったことが可能

ProductList.test.ts

import Product from './Product'
import ProductList from './ProductList'

// jest.mock()でクラス全体をモック
jest.mock('./Product')
const ProductMock = Product as jest.Mock

describe('ProductListのテスト', () => {
  test('totalPrice', () => {
    ProductMock.mockImplementationOnce(() => {
      return {
        _name: 'ホッチキス',
        _price: 300,
        priceWithTax: (): number => {
          return 330
        },
      }
    }).mockImplementationOnce(() => {
      return {
        _name: '色鉛筆セット',
        _price: 1000,
        priceWithTax: (): number => {
          return 1300
        },
      }
    })

    const product1 = new Product('', 0)
    const product2 = new Product('', 0)
    const productList = new ProductList([product1, product2])

    expect(ProductMock).toHaveBeenCalledTimes(2)
    expect(productList.totalPrice()).toBe(1630)
  })
})

Axiosを使用した非同期関数のモック

functions/recipe.ts

import axios, { AxiosResponse } from 'axios'
import { Recipe } from '@/types/recipe'

// 返り値は、登録したレシピの件数の前提とする
export const registerRecipe= async (recipe: Recipe): Promise<number> => {
  const endpoint = '/register-recipe'

  const res: AxiosResponse = await axios.post(endpoint, recipe)
  return res.data.count
}

recipe.test.ts

  • toHaveBeenCalledTimes(calledCount)でスパイ関数が指定回数実行されたかを確認可能
import axios from 'axios'
import { registerRecipe } from './recipe'

jest.mock('axios')
const axiosMock = axios as jest.Mocked<typeof axios> // 型アサーションがないとコンパイルエラーになる

test('レシピを保存できる', async () => {
  const resp = { data: { count: 1 } }
  axiosMock.mockResolvedValue(resp)

  // new Recipeでレシピオブジェクト生成などの過程は割愛
  const count = await registerRecipe(recipe)
  expect(count).toBe(1)
  expect(axiosMock.post).toHaveBeenCalledTimes(1)
})

モックの初期化

メソッドによって、リセットできる内容が異なります。通常は最低でもafterEach()でテストごとにmockClearする必要があります。

理由は、各テスト後にmockが保持しているデータを初期化してあげないと、意図した結果にならないようになってしまうためです。(例えばモックが保持しているmockFn.mock.callsが初期化されないと、呼び出し回数が各テストで引き継がれて意図した内容でなくなってしまいます)

※mock.mockClearとjest.clearAllMocks(複数形)の違いは、個別のmockに対して処理するか、全てのモックに対して処理するかの違いだけなので、内容としては同じです(他のメソッドも同様)

項目

概要

詳細

mockClear/ jest.clearAllMocks

mockFn.mock.calls,
mockFn.mock.instances を初期化※instancesはモック関数からインスタンス化されたすべてのオブジェクトインスタンスを含む配列

呼び出し回数やモックインスタンスの配列などが初期化される。

mockReturnValueなどの設定した内容は初期化されない。

mockReset / jest.resetAllMocks

mockClearの内容に加え、mockImplementation, mockReturnValue等で設定内容も初期化

-

mockRestore / jest.restoreAllMocks

jest.spyOn によって作成されたモックをオリジナルの実装に戻す

jest.fnやjest.mockでのモックに対しては使用できないので注意

カバレッジ

テストカバレッジとは、テスト対象のソースコードのうち、どの程度の割合のコードがテストされたかを表すものです。(網羅率)

テストカバレッジの確認方法

以下を実行する

npm test -- --coverage

実行後、以下のいずれかの方法でカバレッジを確認できます

  • コンソールに出力される内容を確認する
  • 実行後に生成されるcoverage/というディレクトリの中のindex.htmlをブラウザで開いて確認する

マスク処理が多くて少し見づらいかもしれませんが、網羅できていないコードが記載されている行を赤く表示してくれます

4つの指標

4つの指標(観点)から網羅率を確認していることがわかる

例えば以下のような条件分岐を含む関数をテストすると想定する場合のそれぞれの違いは以下の通り

const func = (num1, num2) => {
  if (num1 === 1) { 
    処理1 
  } 

  if (num2 === 1) { 
    処理2 
  }
}

名称

区分

内容

具体例

Stmts
ステートメントカバレッジ

C0 命令網羅

コード内のすべての実行可能ステートメントを1回以上実行されていることをテストする

num1が1でnum2が1のテストケース1種類を実施すれば100%になる

Branch
デシジョン(ブランチ)テスト

C1 分岐網羅

if文の条件自体には着目せず、すべての個別の条件がT/Fの両方をカバーしていることを確認する

テストケース2種類を実施すれば100%になる

  • num1, num2が1
  • num1, num2が2

Funcs
関数カバレッジ

-

プログラム内の各関数がテストで最低1回呼び出されたかの網羅率

-

Lines
行カバレッジ

-

各実行可能行がテストで実行されたかの網羅率

Linesは、行数しかみないのに対して、Stmtは宣言数をみる

※なお、Googleでは、60%を「許容可能」、75%を「推奨」、90%を「例示的」という一般的なガイドラインがある

その他のTIPS

Majestic

Jestでターミナルでテスト結果を見るのが嫌な人向けのツール npx majestic を打つだけでOK

ターミナルの100万倍見やすいし、 カバレッジも容易に確認できる

インポートエイリアス

  • Webpackやtsconfig、Vscodeなどにエイリアス(@など)を設定して、モジュールをimportできるようにしている場合、jestでも以下のようにmoduleNameMapperを指定する必要がある
  • 指定せずにtestファイルでエイリアスを使用してimportすると、Cannot find module・・・となってしまう
module.exports = {
  roots: ['<rootDir>/resources'],
  testMatch: ['**/__tests__/**/*.+(ts|tsx|js)', '**/?(*.)+(spec|test).+(ts|tsx|js)'],
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest',
  },

  // 追加する
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/resources/ts/$1',
  },
}

テストファイルのディレクトリ構成

  • 意見が分かれる様子
    • テストディレクトリを作成してそこに配置する
    • テスト対象のファイルと同階層に配置する

環境変数

複数のテストファイルで共通的に使うテスト用の環境変数を設定する方法です

src/tests/setupEnv.tsに環境変数を設定する関数を定義してexportし、

tests\setupEnv.ts

export default (): void => {
  process.env.COMMON_VALUE = 'common value';
  return;
};

jest.configに設定追加

jest.config.js

module.exports = {
  ・・・
  globalSetup: '<rootDir>/tests/setupEnv.ts',
};

tests\core\setEnv.test.ts

テスト内で使用できる

describe('common env test', () => {
  it('環境変数', () => {
    expect(process.env.COMMON_VALUE).toBe('common value');
  });
});

まとめ

いかがでしたでしょうか。本記事では、フロントエンドのテストツールJestに入門するために必要な最低限の記法やポイントについて紹介しています。具体的には、テストを記載するための基本的なメソッドや、効率よくテストを書くためのセットアップやモックについて、また、知っておいた方がいいTipsなどにも説明しています