フロントエンドのテストツール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の値を持つプロパティは無視される)
toStrictEqualtoEqualよりも厳格な同値性の比較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)
})

モックの初期化

masayan
masayan

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

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

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

項目概要詳細
mockClear/ jest.clearAllMocksmockFn.mock.calls,
mockFn.mock.instances を初期化※instancesはモック関数からインスタンス化されたすべてのオブジェクトインスタンスを含む配列
呼び出し回数やモックインスタンスの配列などが初期化される。

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

mockReset / jest.resetAllMocksmockClearの内容に加え、mockImplementation, mockReturnValue等で設定内容も初期化
mockRestore / jest.restoreAllMocksjest.spyOn によって作成されたモックをオリジナルの実装に戻すjest.fnやjest.mockでのモックに対しては使用できないので注意

カバレッジ

masayan
masayan

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

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

以下を実行する

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

インポートエイリアス

  • 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',
    },
}

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

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

参考

TypeScript学習におすすめの書籍

タイトルとURLをコピーしました