💯

[Typescript]Jest入門を進めてみる

33 min read

この記事について

Reactをtypescriptで始めたはいいものの、テストを書こうとすると、どういったテストを書けば良いのか、書いてもtypeエラーになることがままある。
その度にドキュメントを読みに行ったり、エラー文を検索したりするのだが、毎回解決に時間がかかっていた。(特に最初の頃はJestの構文を間違えているのが問題なのか、enzymeの構文を間違えているのが問題なのかもわからず)

そこで一度Jest側を体系的に勉強しなおそうと思い至った。

せっかくなので、ドキュメンのうち「はじめに」部分の内容について、若干コードを付け足しながら記事にしてみようと思う。

はじめに

対象読者は「typescriptでコードを書いているが、そろそろテストコードをつけないとな」という人や、私と同じく「日頃書いているけど、イマイチわかんないところがあるから勉強し直したいな」という人を想定している。そのため、基本的な言語の書き方については省略していく。

主な内容についてはJestドキュメントの「はじめに」の内容になぞらえて記載していこうと思う。

https://jestjs.io/ja/docs/26.x/getting-started

この記事は前半と後半に分かれている。
後半は来週あたりに出す予定だ。

準備

とりあえずコードを動かすための環境を用意する。

この記事の出発点は「Reactをtypescriptで始めた時のテストコードの書き方につまづいた」ので、create-react-appで簡単にReactプロジェクトを作成する。

ターミナル等を開いて
$ npx create-react-app jest-practice --template typescript
を叩き、ささっとReactプロジェクトを作成する。

上記のコードが何をしているのかわからない場合は、Reactのドキュメントへのリンクを置いておくので、ここでは省略する。

https://ja.reactjs.org/docs/create-a-new-react-app.html

この時の私の主要なバージョンを一応記載しておく。

react: 17.0.2
jest: 26.6.3
node: 16.7.0
npm: 7.21.0

Reactプロジェクトが作成できたら、
$ yarn test
を叩いて、すでにあるsrc/App.test.tsxが無事にパスすることを確認する。

テストの流れを確認する

幾つかのテストコードを記述し、Jestの基本的な動作を復習する。

足し算をする関数のテスト

2つの引数をとり、足し算をした結果を返す関数がある。この関数が期待通りに動作することをテストする。

テスト対象となるコードは以下のようになる。

sum.ts
const sum = (a: number, b: number) => {
  return a + b
}

テストコードは、ドキュメントを参考に以下のようになる。

sum.test.ts
test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3)
})

テストはtest()の中に記述する。第一引数にはテスト名を、第二引数にはテスト内容を記載する。上記のテストはadds 1 + 2 to equal 3という名前のテストを作成したことになる。

ここで一度実行してみる。
ターミナルで$ yarn testと叩くと、テストが走り、無事にパスすることが確認できる。

今回記述したテストはexpecttoBeを使って、2つの値が同じであることをテストした。

ファイルを指定すると、そのファイルだけ指定してテストすることができる。
$ yarn test ./src/utils/sum/sum.test.ts

マッチャー

Jestでは、マッチャー("matcher")を使用して様々な方法で値のテストをすることができる。

一般的なマッチャー

toBe

厳密に等価であることをテストする。先ほどのテストコードをもう一度見てみる。

sum.test.ts
test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3)
})

expect(sum(1, 2))は"expection"オブジェクトを返している。exceptionオブジェクトに対して、マッチャーであるtoBe()を使うことで、厳密に等価であるかをチェックすることできる。

等価ではない。という反対のテストをしたい場合がある。この時はnotを利用する。

sum.test.js
test('adds 1 + 2 to equal 3', () => {
-  expect(sum(1, 2)).toBe(3)
+  expect(sum(1, 2)).not.toBe(4)
})

toEqual

オブジェクトの値を確認するにはtoBeではなくtoEqualを利用する。

toBetoEqualの動作の違いを確かめるためにコードを書いてみる。

ある数字のリストを渡された時、その数字が偶数か奇数かの情報をつけたオブジェクトを返却する関数のテストをしてみる。

テスト対象となる関数は以下になる。

evenAndOdd.ts
const evenAndOdd = (numList: number[]) => {
  return numList.map(num => ({ num, isEven: !(num % 2) }))
}

次にテストコードを書いてみる。
まず初めに先ほど紹介した、toBeを使って、テストがどうなるかを確認する。

evenAndOdd.test.ts
test('numList width even or odd', () => {
  const numList = [0, 1, 2]
  expect(evenAndOdd(numList)).toBe([
    { num: 0, isEven: true },
    { num: 1, isEven: false },
    { num: 2, isEven: true },
  ])
})

テストを実行すると失敗することが確認できる。

ここで、toBetoEqualに変えて再度テストを実行する。

evenAndOdd.test.ts
test('numList width even or odd', () => {
  const numList = [0, 1, 2]
-  expect(evenAndOdd(numList)).toBe([
+  expect(evenAndOdd(numList)).toEqual([
    { num: 0, isEven: true },
    { num: 1, isEven: false },
    { num: 2, isEven: true },
  ])
})

次はテストをパスした。

toBe===を使用して厳密な等価性をテストしている。今回の場合は、evenAndOddによって返却されるオブジェクトの中身と、toEqualに渡されているオブジェクトの中身(プロパティの値)は同一ではあるが、それぞれ違うところから生成されているため、等価ではないと判断される。これに対して、toEqualは、オブジェクトまたは配列の全てのフィールドを再帰的にチェックする。このため、今回のテストではtoEqualでパスする。

toStrictEqual

先ほど失敗したテストの出力結果をよく見てみると、If it should pass with deep equality, replace "toBe" with "toStrictEqual"と出力されている。

では実際にtoStrictEqualに書き換えてみる。

evenAndOdd.test.ts
test('numList width even or odd', () => {
  const numList = [0, 1, 2]
-  expect(evenAndOdd(numList)).toBe([
+  expect(evenAndOdd(numList)).toStrictEqual([
    { num: 0, isEven: true },
    { num: 1, isEven: false },
    { num: 2, isEven: true },
  ])
})

これを実行してみるとテストにパスすることが確認できる。

ではtoEqualとの違いはなんなのか、どういった時に使い分けるべきなのかが気になる。
ドキュメントにそのまま答えが載っているため内容を少し補足しつつ記載する。

https://jestjs.io/ja/docs/26.x/expect#tostrictequalvalue

.toEqual との違い:

  • toStrictEqualを使うとundefinedプロパティを持つキーがチェックされる。例えば、{ a: undefined, b: 2 }{ b: 2 }は一致しない。
  • 配列が'まばら'であるかをチェックする。 例えば.toStrictEqual を使用している場合には、 [, 1][undefined, 1] と一致しない。
  • オブジェクトタイプの一致をテストする。例えば、フィールドabを持つクラスインスタンスは、フィールドabをもつリテラルオブジェクトと等しくならない。これについて、参考となるコードを以下に提示する。
class LaCroix {
  private flavor: string
  constructor(flavor: string) {
    this.flavor = flavor
  }
}

test('are note semantically the same', () => {
  // こちらはパスする
  expect(new LaCroix('lemon')).toEqual({ flavor: 'lemon' })
  // こちらは失敗する
  expect(new LaCroix('lemon')).toStrictEqual({ flavor: 'lemon'})
})

真偽値(とそれっぽいもの)

toBeNull, toBeUndefined, toBeDefined, toBeTruthy, toBeFalsy

テストにおいて、nullundefinedtruefalseのうちどれかであることをチェックしたいことがある。これらに対応するマッチャーを紹介する。

  • toBeNullnullにのみ一致する
  • toBeUndefinedundefinedにのみ一致する
  • toBeDefinednot.toBeUndefinedと等価
  • toBeTruthyifステートメントが真であると見なすものに一致する
  • toBeFalseyifステートメントが偽であると見なすものに一致する

それぞれの出力を確認してみる。

test('null', () => {
  const n = null
  expect(n).toBeNull() // PASS
  expect(n).toBeDefined() // PASS
  expect(n).toBeUndefined() // FAILED
  expect(n).toBeTruthy() // FAILED
  expect(n).toBeFalsy() // PASS
})

test('zero', () => {
  const z = 0
  expect(z).toBeNull() // FAILED
  expect(z).toBeDefined() // PASS
  expect(z).toBeUndefined() // FAILED
  expect(z).toBeTruthy() // FAILED
  expect(z).toBeFalsy() // PASS
})

上記のコードを実際にテスト実行してみると、テストが失敗したところで処理が終了する。つまり、test('null'() => ...)のうち、expect(n).toBeUndefined()でテスト失敗すると、expect(n).toBeTruthy()以降のテストは実行されない。

ここで紹介したtoBeNulltoBeUndefinedtoBe(null)toBe(undefined)でも同じように動作する。

数値

数値の比較をするほとんどの方法について対応するマッチャーがある。

toBeGreaterThan, toBeGreaterThanOrEqual

ある値より大きい、ある値以上ということをチェックしたい時にtoBeGreaterThantoBeGreaterThanOrEqualを利用する。

実は実際に使う場面がいまいち想定できていない。
とりあえずドキュメントのコードを提示する。

test('two plus two', () => {
  const value = 2 + 2
  expect(value).toBeGreaterThan(3)
  expect(value).toBeGreaterThanOrEqual(3.5)
})

toBeLessThan, toBeLessThanEqual

ある値より小さい、ある値以下ということをチェックしたい時にtoBeLessThantoBeLessThanEqualを利用する。

test('two plus two', () => {
  const value = 2 + 2
  expect(value).toBeLessThan(5)
  expect(value).toBeLessThanOrEqual(4.5)
})

toBeCloseTo

浮動小数点の値が同一であるかどうかをチェックするにはtoEqualの代わりにtoBeCloseToを利用する。これは丸め誤差を考慮してくれる。

toBeCloseToについて挙動を確認するために、一旦toEqualで実行してみる。

test('adding floating point numbers', () => {
  const value = 0.1 + 0.2
  expect(value).toEqual(0.3)
})

すると次のようにテスト失敗する

次にtoBeCloseToを利用してみる。

test('adding floating point numbers', () => {
  const value = 0.1 + 0.2
-  expect(value).toEqual(0.3)
+  expect(value).toBeCloseTo(0.3)
})

すると次はテストをパスする。

文字列

toMatch

toMatchで、文字列に対して正規表現でマッチするか確認できる。

テスト対象として8桁の数値を文字列として返却する関数を考える。

eightNumber.ts
const eightNumber = () => {
  return `${Math.floor(Math.random() * Math.pow(10, 8))}`
}

このテストコードを正規表現を使ってテストする。

eightNumber.test.ts
test('eightNumber', () => {
  expect(eightNumber()).toMatch(/[0-9]{8}/)
})

配列と反復可能なオブジェクト

toContain

toContainを使用して、配列や反復可能なオブジェクトに特定のアイテムが含まれているかどうかをチェックできる。

const shoppingList = [
  'diapers',
  'kleenex',
  'trash bags',
  'paper towels',
  'milk',
]

test('the shopping list has milk on it', () => {
  expect(shoppingList).toContain('milk');
  expect(new Set(shoppingList)).toContain('milk');
})

例外

toThrow

ある関数が呼び出し時に例外を投げることをテストするには、toThrowを使用する。

const throwError () => {
  throw new Error('you are using the wrong JDK')
}

test('throwError goes as expected', () => {
  expect(() => compileAndroidCode()).toThrow()
  expect(() => compileAndroidCode()).toThrow(Error)

  // 例外発生時のメッセージをチェックするには以下のようにします。
  expect(() => compileAndroidCode()).toThrow('you are using the wrong JDK')
  expect(() => compileAndroidCode()).toThrow(/JDK/)
})

例外をスローする関数は、ラッピング関数内で呼び出される必要がある。
そうしない場合、toThrowのチェックが実施されることなく、テストに失敗する。

非同期コードのテスト

非同期のコードをテストするためには、Jestにテストコードがいつ完了するのかを教える必要がある。幾つか方法があるので1つずつ紹介していく。

コールバック

最も一般的な非同期の処理パターンはコールバックである。

例えばデータを取得してcallback(data)を呼び出すfeatchData(callback)関数があるとする。テスト動作を確認するために、とりあえずそれっぽいものを作成する。

今回作成するfeatchData関数は、500ms経過後、callback関数にpeanut butterという文字列を引数に渡して発火させる。

fetchData.ts
const fetchData = (callback: (str: string) => void) => {
  setTimeout(() => callback('peanut butter'), 500)
}

これに対するテストを今までの知識によって書いてみる。

fetchData.test.ts
test('the data is peanut butter', () => {
  const callback = (str: string) => {
    expect(str).toBe('peanut butter')
  }
  fetchData(callback)
})

これを実行してみると、テストをパスすることを確認する。本当にテストできているのか確認するために、わざと失敗するはずのテストを書いて、失敗を確認してみる。

fetchData.test.ts
test('the data is peanut butter', () => {
  const callback = (str: string) => {
-    expect(str).toBe('peanut butter')
+    expect(str).toBe('')
  }
  fetchData(callback)
})

失敗することを期待するが、実際に実行してみるとテストをパスしてしまう。

Jestは一度最後まで実行したらテスト完了としてしまう。つまり上記のテストはfetchData関数を呼び出した時点でテストが終了し、パスしたことになっている。

正しく動作させるためには、test('...',() => { ... }からtest('...',done => { ... }にして実行する。こうすることで、Jestはテストを終了する前に、doneコールバックが呼ばれるまで待つようになる。

fetchData.test.ts
-test('the data is peanut butter', () => {
+test('the data is peanut butter', done => {
  const callback = (str: string) => {
    expect(str).toBe('')
    done()
  }
  fetchData(callback)
})

このコードを実行することで、“正しく”テストに失敗する。
もう一度テストに成功するはずのコードに直して、動作を確認しておく。

fetchData.test.ts
test('the data is peanut butter', done => {
  const callback = (str: string) => {
-    expect(str).toBe('')
+    expect(str).toBe('peanut butter')
    done()
  }
  fetchData(callback)
})

もし、done()が呼ばれない場合は、テストが「タイムアウトにより」失敗する。
また、expect文が失敗した場合には、エラーがスローされるため、done()が呼び出されない。このとき、テスト失敗のエラーメッセージには「タイムアウトエラー」が出力される。

もし、エラーとして適切に表示されて欲しい場合、以下のようにtry-catchで囲み、テスト失敗時のメッセージが表示されるようにする必要がある。

fetchData.test.ts
test('the data is peanut butter', done => {
  const callback = (str: string) => {
+    try {
-      expect(str).toBe('peanut butter')
+      expect(str).toBe('')
      done()
+    } catch (error) {
+      done(error)
+    }
  }
  fetchData(callback)
})

Promises

promiseを使用するコードであれば、非同期テストをもっと簡単に処理する方法がある。テストからpromiseを返すと、Jestはそのpromiseがresolveされるまで待機するようになる。もし、promiseがrejectされた場合は、テストは自動的に失敗する。

例えば先程のfetchDataにおいて、コールバックを使用する代わりにpromiseを返すことにする。

fetchData.ts
const fetchData = () => {
-  return setTimeout(() => callback('peanut butter'), 500)
+  return Promise.resolve('peanut butter')
}

次のようなコードでテストをすることができる。

fetchData.test.ts
test('the data is peanut butter', () => {
  return fetchData().then(str => {
    expect(str).toBe('peanut butter')
  })
})

必ずpromiseを返すようにする。もしreturn文を省略した場合、テストはfetchDataがresolveされpromiseが帰ってくる前に実行され、then()内のコールバックが実行される前に完了してしまう。

実際に失敗するテストに書き換えて実行してみる。

fetchData.test.ts
test('the data is peanut butter', () => {
-  return fetchData().then(str => {
+  fetchData().then(str => {
-    expect(str).toBe('peanut butter')
+    expect(str).toBe('')
  })
})

triggerUncaughtExceptionが発生し、テストが失敗した。

次にpromiseがrejectされることを期待するケースでは.catchメソッドを使用する。

テスト対象となるコードは以下のようになる。

fetchData.ts
const fetchData = () => {
  return Promise.reject('error')
}

テストコードは以下のようになる。

fetchData.test.ts
test('the data is peanut butter', () => {
  expect.assertions(1)
  return fetchData().catch(str => {
    expect(str).toBe('error')
  })
})

想定した数のアサーションが呼ばれたことを確認するため、promiseがrejectされることを期待するケースではexpect.assertionsを必ず追加する。
c.f.https://jestjs.io/ja/docs/26.x/asynchronous#promises

追加の必要性を知るために、テスト対象のコードを次のように書き換えてみる。

fetchData.ts
const fetchData = () => {
-  return Promise.reject('error')
+  return Promise.resolve('error')
}

これは、fetchDatarejectではなく、なんらかのコード改変を行なった結果resolveが返却されてしまった場合を想定している。

この後、テストでexpect.assertionsを抜いてから実行してみる。

fetchData.test.ts
test('the data is peanut butter', () => {
-  expect.assertions(1)
  return fetchData().catch(str => {
    expect(str).toBe('error')
  })
})

この時、テストが失敗することを期待するが、テストはパスしてしまう。

ここで、expect.assertionsを追加してから再度実行してみる。

fetchData.test.ts
test('the data is peanut butter', () => {
+  expect.assertions(1)
  return fetchData().catch(str => {
    expect(str).toBe('error')
  })
})

すると、Expected one assertion to be calledというエラーメッセージと共に、“正しく”テストに失敗することができた。

.resolves/.rejects

expect宣言で.resolvesマッチャーを使うことができる。Jestはそのpromiseが解決するまで待機する。promiseがrejectされた場合はテストは自動的に失敗する。

先程のfetchData関数のテストを.resolvesマッチャーで書き直してみる。

fetchData.test.ts
test('the data is peanut butter', () => {
-  return fetchData().then(str => {
-    expect(str).toBe('peanut butter')
-  })
+  return expect(fetchData()).resolves.toBe('peanut butter')
})

こちらでもreturnを忘れないように気をつける。もし忘れてしまった場合、fetchDataがresolveされpromiseが返ってくる前にテストが終了してしまう。

同じくpromiseがrejectされることを期待するケースでは.rejectsマッチャーを使用する。promseがresolveした場合はテストは自動的に失敗する。

fetchData.test.ts
test('the data is peanut butter', () => {
-  expect.assertions(1)
-  return fetchData().catch(str => {
-    expect(str).toBe('error')
-  })
+  return expect(fetchData()).rejects.toBe('error')
})

Async/Await

asyncawaitをテストで利用できる。非同期テストを書くには、testに渡す関数の前にasyncキーワードを記述する。fetchDataのテストを書き換えてみる。

fetchData.test.ts
test('the data is peanut butter', async () => {
-  return expect(fetchData()).resolves.toBe('peanut butter')
+  const str = await fetchData()
+  expect(str).toBe('peanut butter')
})

テストが失敗することを期待するテストも以下のように書き換えられる。

fetchData.test.ts
test('the data is peanut butter', async () => {
-  return expect(fetchData()).rejects.toBe('error')
+  expect.assertions(1)
+  try {
+    await fetchData()
+  } catch(error) {
+    expect(error).toBe('error')
+  }
})

asyncawait.resolvesまたは、.rejectと組み合わせることができる。

fetchData.test.ts
test('the data is peanut butter', async () => {
  await expect(fetchData()).resolves.toBe('peanut butter')
})

test('the fetch fails with an error', async () => {
  await expect(fetchData()).rejects.toMatch('error')
})

これらのケースは、事実上promiseを使用した例と同じロジックの糖衣構文である。

これらの形式のどれかが他よりも優れているということはなく、コードベースや場合によっては同じファイル内でも混在して合わせて使うことができる。

セットアップと破棄

テストを書いている際にしばしば、テストを実行する前に幾つかのセットアップ作業をしたり、テストが終了した後に幾つかの仕上げ作業をしたい場合がある。Jestはこれらを処理するヘルパー機能を提供する。

テストごとにセットアップ作業を繰り返す

多くのテストで繰り返し行う必要がある場合は、beforeEachafterEachを使用する。

テスト対象として、あるポイントを管理するクラスを用意する。
このクラスは自信が管理しているポイントを足したり引いたりするaddsub関数を持つ。
また、静的プロパティのtotalを内部にもつ。totalはインスタンスに関係なく値を保持し続ける。

PointManagement.ts
class PointManagement {
  static total: number = 0
  private point: number = 0

  constructor(point: number) {
    this.point = point
    PointManagement.total += point
  }

  get total() {
    return PointManagement.total
  }

  add = (point: number) => {
    this.point += point
    PointManagement.total += point
    return this.point
  }

  sub = (point: number) => {
    this.point -= point
    PointManagement.total -= point
    return this.point
  }

  clear = () => {
    this.point = 0
    PointManagement.total = 0
  }
}

このクラスに対するテストを書いていく。まず初めにadd関数が正常に動作するかをテストする。addは第1引数に渡されたポイント数をthis.pointPointManagementに加算する。

it('add 1', () => {
  const pointManagement = new PointManagement(100)
  expect(pointManagement.add(1)).toBe(101)
  expect(pointManagement.total).toBe(101)
})

次に、sub関数が正常に動作するかをテストする。subは第2引数に渡されたポイント数をthis.pointPointManagement.totalから減算する。

it('sub 1', () => {
  const pointManagement = new PointManagement(100)
  expect(pointManagement.sub(1)).toBe(99)
  expect(pointManagement.total).toBe(99)
})

さて2つのテストが書けたところで、一度実行してみて、テストがパスすることを確認しておく。書けたテストコードを見比べてみると、
const pointMangement = new PointManagement(100)
の行が共通している。このようにテストを実施する前のセットアップに当たる部分は大抵繰り返し書くことになるため、beforEachでまとめる候補になる。

実際にbeforeEachに処理をまとめる。

PointManagement.test.ts
let pointManagement: PointManagement

beforeEach(() => {
  pointManagement = new PointManagement(100)
})

it('add 1', () => {
  expect(pointManagement.add(1)).toBe(101)
  expect(pointManagement.total).toBe(101)
})

it('sub 1', () => {
  expect(pointManagement.sub(1)).toBe(99)
  expect(pointManagement.total).toBe(99)
})

さて、このテストコードを実行すると、次は失敗する。この時、add 1が先にテストしている場合、sub 1expect(pointManagement.total).toBe(99)が失敗し、sub 1が先にテストしている場合、add 1expect(pointManagement.total).toBe(101)が失敗する。

原因は、静的プロパティのtotalにある。この2つのテストはそれぞれ実行順序に関係なく、テストに成功してほしいため、PointManagement.totalは毎回リセットされることが望ましい。
そこで、テストの後に毎回実行されるafterEachを利用する。

PointManagement.test.ts
let pointManagement: PointManagement

beforeEach(() => {
  pointManagement = new PointManagement(100)
})

afterEach(() => {
  pointManagement.clear()
})

it('add 1', () => {
  expect(pointManagement.add(1)).toBe(101)
  expect(pointManagement.total).toBe(101)
})

it('sub 1', () => {
  expect(pointManagement.sub(1)).toBe(99)
  expect(pointManagement.total).toBe(99)
})

これでテストがパスするようになる。
ちなみにbeforEachafterEachは非同期コードを扱える。話がややこしくなるので割愛。

ワンタイムセットアップ

セットアップがファイルの先頭で1回だけ実行されることが必要なケースがある。このセットアップが非同期で行われる場合は特に面倒になるので、インラインでは実施できない。Jestはこの状況に対応するためにbeforeAllafterAllを提供している。

例えば、先ほど出していたテストのうち、it('add 1', () => { ... })it('sub 1', () => { ... })のテストは独立させたくない場合、beforeAllafterAllを使って以下のように書き換えられる。

PointManagement.test.ts
let pointManagement: PointManagement

beforeAll(() => {
  pointManagement = new PointManagement(100)
})

afterAll(() => {
  pointManagement.clear()
})

it('add 1', () => {
  expect(pointManagement.add(1)).toBe(101)
  expect(pointManagement.total).toBe(101)
})

it('sub 1', () => {
  expect(pointManagement.sub(1)).toBe(100)
  expect(pointManagement.total).toBe(100)
})

スコープ

デフォルトでは、beforeafterブロックはファイルの中の各テストに適用される。ファイルの中であっても、特定のテストのみ適用させたい場合は、describeブロックを使って複数のテストをグループ化することで対応できる。describeブロック内にあるbeforeafterブロックはdescribeブロックの中のテストにだけ適用される。

PointManagement.test.ts
describe('add', () => {
  let pointManagement: PointManagement
  let actual: number

  beforeEach(() => {
    pointManagement = new PointManagement(100)
    actual = pointManagement.add(1)
  })

  afterEach(() => {
    pointManagement.clear()
  })

  it('add 1', () => {
    expect(actual).toBe(101)
  })

  it('total', () => {
    expect(pointManagement.total).toBe(101)
  })
})

describe('sub', () => {
  let pointManagement: PointManagement
  let actual: number

  beforeAll(() => {
    pointManagement = new PointManagement(100)
    actual = pointManagement.sub(1)
  })

  afterAll(() => {
    pointManagement.clear()
  })

  it('sub 1', () => {
    expect(actual).toBe(99)
  })

  it('total', () => {
    expect(pointManagement.total).toBe(99)
  })
})

beforeafterブロックの実行順序に注意すること。例えば以下のようなコードを実行してみることで、その実行順序がわかる。

beforeAll(() => console.log('1 - beforeAll'))
afterAll(() => console.log('1 - afterAll'))
beforeEach(() => console.log('1 - beforeEach'))
afterEach(() => console.log('1 - afterEach'))
test('', () => console.log('1 - test'))
describe('Scoped / Nested block', () => {
  beforeAll(() => console.log('2 - beforeAll'))
  afterAll(() => console.log('2 - afterAll'))
  beforeEach(() => console.log('2 - beforeEach'))
  afterEach(() => console.log('2 - afterEach'))
  test('', () => console.log('2 - test'))
});

// 1 - beforeAll
// 1 - beforeEach
// 1 - test
// 1 - afterEach
// 2 - beforeAll
// 1 - beforeEach
// 2 - beforeEach
// 2 - test
// 2 - afterEach
// 1 - afterEach
// 2 - afterAll
// 1 - afterAll

describeブロックとtestブロックの実行順序

Jestは、テストファイル内の全てのdescribeハンドラを、実際の全てのテストを実行する前に実行する。これが、セットアップとティアダウンをdescribeブロックではなく、before及びafterハンドラの中で実行するもう一つの理由になる。describeブロックの実行完了後に、デフォルトでは、Jestはコレクションフェーズで発見したテストを順番に直列に実行する。次のテストに移動する前に、それぞのれテストが完了して片付けが終わるまで待つ。

describe('outer', () => {
  console.log('describe outer-a')

  describe('describe inner 1', () => {
    console.log('describe inner 1')
    test('test 1', () => {
      console.log('test for describe inner 1')
      expect(true).toBe(true)
    })
  })

  console.log('describe outer-b')

  test('test1', () => {
    console.log('test for describe outer')
    expect(true).toBe(true)
  })

  describe('describe inner 2', () => {
    console.log('describe inner 2')
    test('test for describe inner 2', () => {
      console.log('test for describe inner 2')
      expect(false).toBe(false)
    })
  })
  console.log('describe outer-c')
})

// describe outer-a
// describe inner 1
// describe outer-b
// describe inner 2
// describe outer-c
// test for describe inner 1
// test for describe outer
// test for describe inner 2

一般的なアドバイス

もしテストが失敗して、まず最初に調べるべきことの1つは、そのテストが単体で実行された場合にも失敗するかを確認することである。Jestで1度だけテストを実行するには、testtest.onlyに一時的に変更する。

test.only('this will be the only test that runs', () => {
  expect(true).toBe(false);
})

test('this test will not run', () => {
  expect('A').toBe('A');
})

モック関数

モック関数によりコード間のつながりをテストすることができる。関数が持つ実際の実装を除去したり、関数の呼び出しをキャプチャしたり、newによるコンストラクタ関数のインスタンス化をキャンプチャできる。そうすることでテスト時のみの返り値を設定することが可能となる。

関数をモックするには2つの方法がある。

  • テストコードの中でモック関数を作成する
  • manual mockを作成してもじゅ=流に依存性を上書きするという方法

モック関数を利用する

foreEach関数の実装をテストすることを考えてみる。この関数は与えられた各配列に対して、コールバック関数を呼び出す。

forEach.ts
const forEach = (items: number[], callback: (item: number) => number) => {
  return items.map(item => {
    return callback(item)
  })
}

この関数をテストするために、モック関数を利用して、コールバックが期待通りに呼び出されるかを確認する。

ひとまず、モック関数を使ったテストコードを以下に示す。
詳細は次から。

forEach.test.ts
describe('forEach', () => {
  let mockCallback: jest.Mock<any, any>

  beforeEach(() => {
    mockCallback = jest.fn(x => 42 + x)
    forEach([0, 1], mockCallback)
  })

  test('call length', () => {
    expect(mockCallback.mock.calls.length).toBe(2)
  })

  test('first argument of the first call', () => {
    expect(mockCallback.mock.calls[0][0]).toBe(0)
  })

  test('first argument of the second call', () => {
    expect(mockCallback.mock.calls[1][0]).toBe(1)
  })

  test('return value of the first call', () => {
    expect(mockCallback.mock.results[0].value).toBe(42)
  })
})

.mockプロパティ

全てのモック関数には、この特別な.mockプロパティがある。このプロパティにはモック関数呼び出し時のデータと、関数の返り値が記録されている。.mockプロパティには、各呼び出し時のthisの値も記録されているため、thisの値チェックも可能になっている。

const myMock = jest.fn()

const a = new myMock()
const b = {}
const bound = myMock.bind(b)
bound()

console.log(myMock.mock.instances)
// > [ <a>, <b> ]

以下のモックのプロパティを使用すると、関数がどのように呼び出され、どのようにインスタンス化され、返り値が何であったのかを確認することができる。

先ほど出てきたテストに説明を加えて以下に示す。

test('call length', () => {
  // mockCallbackはちょうど2回だけ呼ばれたことをチェックする
  expect(mockCallback.mock.calls.length).toBe(2)
})

test('first argument of the first call', () => {
  // mockCallback関数の1回目の呼び出しの1番目の引数は'0'であることをチェックする
  expect(mockCallback.mock.calls[0][0]).toBe(0)
})

test('first argument of the second call', () => {
  // mockCallback関数の2回目の呼び出しの1番目の引数は'1'であることをチェックする
  expect(mockCallback.mock.calls[1][0]).toBe(1)
})

test('return value of the first call', () => {
  // mockCallback関数の1回目の呼び出しの返り値は'42'であることをチェックする
  expect(mockCallback.mock.results[0].value).toBe(42)
})

モックの戻り値

モック関数は、テスト中のコードにテスト用の値を注入することができる。mockReturnValueを利用することで、そのモック関数が次に呼び出された時に返却する値を設定できる。

例えば、先ほどのforEachのテストを少し書き換えてみる。
jest.fn(x => 42 + x)というところを、jest.fn().mockReturnValue(200)としてみた。

forEach.test.ts
describe('forEach', () => {
  let mockCallback: jest.Mock<any, any>

  beforeEach(() => {
    mockCallback = jest.fn().mockReturnValue(200)
    forEach([0, 1], mockCallback)
  })

  test('return value of the first call', () => {
    expect(mockCallback.mock.results[0].value).toBe(200)
  })

  test('return value of the second call', () => {
    expect(mockCallback.mock.results[1].value).toBe(200)
  })
})

この時、mockCallbackは引数が何であろうと200を返却するモック関数になる。もし、最初に呼び出される返却値と次に呼び出される返却値を異なるものにしたい場合は、mockReturnValueOnceを利用する。

forEach.test.ts
describe('forEach', () => {
  let mockCallback: jest.Mock<any, any>

  beforeEach(() => {
-    mockCallback = jest.fn().mockReturnValue(200)
+    mockCallback = jest.fn().mockReturnValueOnce(200).mockReturnValueOnce(300)


    forEach([0, 1], mockCallback)
  })

  test('return value of the first call', () => {
    expect(mockCallback.mock.results[0].value).toBe(200)
  })

  test('return value of the second call', () => {
-    expect(mockCallback.mock.results[1].value).toBe(200)
+    expect(mockCallback.mock.results[1].value).toBe(300)
  })
})

モジュールのモック

テスト対象のコードがモジュールをインポートしている場合、そのモジュールをモックしてしまいたい場合がある。その時はjest.mock(...)関数を使う。

利用例を示すために、APIからユーザー情報を取得するクラスをテスト対象として考えてみる。

Users.ts
import axios from '../axios'

class Users {
  static all() {
    return axios.get('/users.json').then(resp => resp.data)
  }
}

Usersクラスはaxiosを使用してAPIをよび、全てのユーザーが持っているdata属性を返す。
動作させるために、axiosモジュールを仮に作成しておく。

axios.ts
export default ({
  get: (path: string) => Promise.resolve({ data: [{ name: 'axios user data' }] })
})

Usersのテストコードを書いてみる。まず初めにjest.mock(...)関数を使わない状態で書いてみる。

Users.test.ts
import Users from '.'

test('should fetch users', () => {
  const users = [{ name: 'axios user data' }]

  return Users.all().then(data => expect(data).toEqual(users))
})

このテストには2つ問題がある。1つ目は返却されるユーザー情報が変わった時('axios user data')テストが失敗してしまうこと。2つ目はaxiosの仕様変更によってUsersのテストが失敗してしまうことにある。テストすべきなのはUsersに書かれているコードであって、ユーザー情報が変わったり、利用しているモジュールの変更によって失敗することは本意ではない。

そこで、axiosをモックしてしまい、依存をなくしておく。コードは以下のようになる。

User.test.ts
import Users from '.'
import axios from '../axios'

jest.mock('../axios')
const mockAxios = axios as jest.Mocked<typeof axios>

test('should fetch users', () => {
  const users = [{ name: 'Bob' }]
  const resp = { data: users }
  mockAxios.get.mockResolvedValue(resp)

  return Users.all().then(data => expect(data).toEqual(users))
})

一度モジュールをモックすれば.getに対してmockResolvedValueメソッドを使えるようになり、テストで検証したいデータを返却させるようにできる。実装上は、axios.get('/users/json')に偽のレスポンスを返すようにさせている。

部分的なモック

モジュールの一部分だけをモックすることができる。

foo-bar-baz.ts
export const foo = 'foo'
export const bar = () => 'bar'
export default () => 'baz'
foo-bar-baz.test.ts
import defaultExport, { bar, foo } from '.'

jest.mock('../foo-bar-baz', () => {
  const originalModule = jest.requireActual('../foo-bar-baz')
  return {
    __esModule: true,
    ...originalModule,
    default: jest.fn(() => 'mocked baz'),
    foo: 'mocked foo'
  }
})

test('should do a partial mock', () => {
  const defaultExportResult = defaultExport()
  expect(defaultExportResult).toEqual('mocked baz')
  expect(foo).toBe('mocked foo')
  expect(bar()).toBe('bar')
})

上記のコードはドキュメントから引用してきたものだが、私の環境ではexpect(defaultExportResult).toEqual('mocked baz')で失敗した。(undefinedを受け取っている)

テストを成功させるためには、以下のように記述する。(ただ、なぜ失敗するのか根本原因がわからない)

foo-bar-baz.test.ts
import defaultExport, { bar, foo } from '.'

jest.mock('../foo-bar-baz', () => {
  const originalModule = jest.requireActual('../foo-bar-baz')
  return {
    __esModule: true,
    ...originalModule,
-    default: jest.fn(() => 'mocked baz'),
+    default: () => 'mocked baz',
    foo: 'mocked foo'
  }
})

test('should do a partial mock', () => {
  const defaultExportResult = defaultExport()
  expect(defaultExportResult).toEqual('mocked baz')
  expect(foo).toBe('mocked foo')
  expect(bar()).toBe('bar')
})

上記のコードでも今回は動作するが、直接関数を書き換えず、jestの機能を使って書き換えたい場合は、以下のように書き換えられる。(次で説明する予定のmockImplementationが使われている)

foo-bar-baz.test.ts
jest.mock('../foo-bar-baz', () => {
  const originalModule = jest.requireActual('../foo-bar-baz')
  return {
    __esModule: true,
    ...originalModule,
-    default: () => 'mocked baz',
+    default: jest.fn(),
    foo: 'mocked foo'
  }
})

+const mockFooBarBaz = defaultExport as jest.MockedFunction<typeof defaultExport>

test('should do a partial mock', () => {
+  mockFooBarBaz.mockImplementation(() => 'mocked baz')
-  const defaultExportResult = defaultExport()
+  const defaultExportResult = mockFooBarBaz()
  expect(defaultExportResult).toEqual('mocked baz')
  expect(foo).toBe('mocked foo')
  expect(bar()).toBe('bar')
})

モックの実装

指定された値を返すという能力を超えて完全に実装をモック化することが便利なケースがある。これはjest.fnまたはモック関数のmockImplementationを利用することで実現できる。

const myMockFn = jest.fn(cb => cb(null, true))

myMockFn((err, val) => console.log(val))
// > true

mockImplementationメソッドは他のモジュールによって作成されたモック関数のデフォルトの実装を定義したい時に便利である。

import defaultExport from '.'

jest.mock('../foo-bar-baz')

const mockFooBarBaz = defaultExport as jest.MockedFunction<typeof defaultExport>

test('mock', () => {
  mockFooBarBaz.mockImplementation(() => 'mocked')
  expect(mockFooBarBaz()).toEqual('mocked')
})

関数への複数回への呼び出しで異なる結果を得るように複雑な挙動をするモック関数を再作成する必要がある場合はmockImplementationOnceメソッドを使用する。

import defaultExport from '.'

jest.mock('../foo-bar-baz')

const mockFooBarBaz = defaultExport as jest.MockedFunction<typeof defaultExport>

test('mock', () => {
  mockFooBarBaz.mockImplementationOnce(() => 'mocked')
    .mockImplementationOnce(() => 'mocked2')
  expect(mockFooBarBaz()).toEqual('mocked')
  expect(mockFooBarBaz()).toEqual('mocked2')
})

モック関数がmockImplementationOnceによって定義された実装が全て使い切った場合は、jest.fnのデフォルトの実装を実行する。

const myMockFn = jest
  .fn(() => 'default')
  .mockImplementationOnce(() => 'first call')
  .mockImplementationOnce(() => 'second call')

console.log(myMockFn(), myMockFn(), myMockFn(), myMockFn())
// > 'first call', 'second call', 'default', 'default'

終わりに

ざっくりコードを書きながら記事にすることで、Jestの基礎について勉強しなおせた。特に非同期のコードテストと、モック関数についてはなんとなくで書いていたこともあり、良い機会だったと思う。

また時間があれば次はenzymeの基礎を記事にしようかと思う。

Discussion

ログインするとコメントできます