[Typescript]Jest入門を進めてみる
この記事について
Reactをtypescriptで始めたはいいものの、テストを書こうとすると、どういったテストを書けば良いのか、書いてもtypeエラーになることがままある。
その度にドキュメントを読みに行ったり、エラー文を検索したりするのだが、毎回解決に時間がかかっていた。(特に最初の頃はJestの構文を間違えているのが問題なのか、enzymeの構文を間違えているのが問題なのかもわからず)
そこで一度Jest側を体系的に勉強しなおそうと思い至った。
せっかくなので、ドキュメンのうち「はじめに」部分の内容について、若干コードを付け足しながら記事にしてみようと思う。
はじめに
対象読者は「typescriptでコードを書いているが、そろそろテストコードをつけないとな」という人や、私と同じく「日頃書いているけど、イマイチわかんないところがあるから勉強し直したいな」という人を想定している。そのため、基本的な言語の書き方については省略していく。
主な内容についてはJestドキュメントの「はじめに」の内容になぞらえて記載していこうと思う。
この記事は前半と後半に分かれている。
後半は来週あたりに出す予定だ。
準備
とりあえずコードを動かすための環境を用意する。
この記事の出発点は「Reactをtypescriptで始めた時のテストコードの書き方につまづいた」ので、create-react-app
で簡単にReactプロジェクトを作成する。
ターミナル等を開いて
$ npx create-react-app jest-practice --template typescript
を叩き、ささっとReactプロジェクトを作成する。
上記のコードが何をしているのかわからない場合は、Reactのドキュメントへのリンクを置いておくので、ここでは省略する。
この時の私の主要なバージョンを一応記載しておく。
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つの引数をとり、足し算をした結果を返す関数がある。この関数が期待通りに動作することをテストする。
テスト対象となるコードは以下のようになる。
const sum = (a: number, b: number) => {
return a + b
}
テストコードは、ドキュメントを参考に以下のようになる。
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})
テストはtest()
の中に記述する。第一引数にはテスト名を、第二引数にはテスト内容を記載する。上記のテストはadds 1 + 2 to equal 3
という名前のテストを作成したことになる。
ここで一度実行してみる。
ターミナルで$ yarn test
と叩くと、テストが走り、無事にパスすることが確認できる。
今回記述したテストはexpect
とtoBe
を使って、2つの値が同じであることをテストした。
マッチャー
Jestでは、マッチャー("matcher")を使用して様々な方法で値のテストをすることができる。
一般的なマッチャー
toBe
厳密に等価であることをテストする。先ほどのテストコードをもう一度見てみる。
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})
expect(sum(1, 2))
は"expection"オブジェクトを返している。exceptionオブジェクトに対して、マッチャーであるtoBe()
を使うことで、厳密に等価であるかをチェックすることできる。
等価ではない。という反対のテストをしたい場合がある。この時はnot
を利用する。
test('adds 1 + 2 to equal 3', () => {
- expect(sum(1, 2)).toBe(3)
+ expect(sum(1, 2)).not.toBe(4)
})
toEqual
オブジェクトの値を確認するにはtoBe
ではなくtoEqual
を利用する。
toBe
とtoEqual
の動作の違いを確かめるためにコードを書いてみる。
ある数字のリストを渡された時、その数字が偶数か奇数かの情報をつけたオブジェクトを返却する関数のテストをしてみる。
テスト対象となる関数は以下になる。
const evenAndOdd = (numList: number[]) => {
return numList.map(num => ({ num, isEven: !(num % 2) }))
}
次にテストコードを書いてみる。
まず初めに先ほど紹介した、toBe
を使って、テストがどうなるかを確認する。
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 },
])
})
テストを実行すると失敗することが確認できる。
ここで、toBe
をtoEqual
に変えて再度テストを実行する。
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
に書き換えてみる。
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
との違いはなんなのか、どういった時に使い分けるべきなのかが気になる。
ドキュメントにそのまま答えが載っているため内容を少し補足しつつ記載する。
.toEqual との違い:
toStrictEqual
を使うとundefined
プロパティを持つキーがチェックされる。例えば、{ a: undefined, b: 2 }
と{ b: 2 }
は一致しない。- 配列が'まばら'であるかをチェックする。 例えば.toStrictEqual を使用している場合には、
[, 1]
は[undefined, 1]
と一致しない。- オブジェクトタイプの一致をテストする。例えば、フィールド
a
とb
を持つクラスインスタンスは、フィールドa
とb
をもつリテラルオブジェクトと等しくならない。これについて、参考となるコードを以下に提示する。
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
テストにおいて、null
、undefined
、true
、false
のうちどれかであることをチェックしたいことがある。これらに対応するマッチャーを紹介する。
-
toBeNull
はnull
にのみ一致する -
toBeUndefined
はundefined
にのみ一致する -
toBeDefined
はnot.toBeUndefined
と等価 -
toBeTruthy
はif
ステートメントが真であると見なすものに一致する -
toBeFalsey
はif
ステートメントが偽であると見なすものに一致する
それぞれの出力を確認してみる。
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
})
数値
数値の比較をするほとんどの方法について対応するマッチャーがある。
toBeGreaterThan, toBeGreaterThanOrEqual
ある値より大きい、ある値以上ということをチェックしたい時にtoBeGreaterThan
、toBeGreaterThanOrEqual
を利用する。
実は実際に使う場面がいまいち想定できていない。
とりあえずドキュメントのコードを提示する。
test('two plus two', () => {
const value = 2 + 2
expect(value).toBeGreaterThan(3)
expect(value).toBeGreaterThanOrEqual(3.5)
})
toBeLessThan, toBeLessThanEqual
ある値より小さい、ある値以下ということをチェックしたい時にtoBeLessThan
、toBeLessThanEqual
を利用する。
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桁の数値を文字列として返却する関数を考える。
const eightNumber = () => {
return `${Math.floor(Math.random() * Math.pow(10, 8))}`
}
このテストコードを正規表現を使ってテストする。
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/)
})
非同期コードのテスト
非同期のコードをテストするためには、Jestにテストコードがいつ完了するのかを教える必要がある。幾つか方法があるので1つずつ紹介していく。
コールバック
最も一般的な非同期の処理パターンはコールバックである。
例えばデータを取得してcallback(data)
を呼び出すfeatchData(callback)
関数があるとする。テスト動作を確認するために、とりあえずそれっぽいものを作成する。
今回作成するfeatchData
関数は、500ms経過後、callback関数にpeanut butter
という文字列を引数に渡して発火させる。
const fetchData = (callback: (str: string) => void) => {
setTimeout(() => callback('peanut butter'), 500)
}
これに対するテストを今までの知識によって書いてみる。
test('the data is peanut butter', () => {
const callback = (str: string) => {
expect(str).toBe('peanut butter')
}
fetchData(callback)
})
これを実行してみると、テストをパスすることを確認する。本当にテストできているのか確認するために、わざと失敗するはずのテストを書いて、失敗を確認してみる。
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
コールバックが呼ばれるまで待つようになる。
-test('the data is peanut butter', () => {
+test('the data is peanut butter', done => {
const callback = (str: string) => {
expect(str).toBe('')
done()
}
fetchData(callback)
})
このコードを実行することで、“正しく”テストに失敗する。
もう一度テストに成功するはずのコードに直して、動作を確認しておく。
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
で囲み、テスト失敗時のメッセージが表示されるようにする必要がある。
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を返すことにする。
const fetchData = () => {
- return setTimeout(() => callback('peanut butter'), 500)
+ return Promise.resolve('peanut butter')
}
次のようなコードでテストをすることができる。
test('the data is peanut butter', () => {
return fetchData().then(str => {
expect(str).toBe('peanut butter')
})
})
必ずpromiseを返すようにする。もしreturn文を省略した場合、テストはfetchData
がresolveされpromiseが帰ってくる前に実行され、then()内のコールバックが実行される前に完了してしまう。
実際に失敗するテストに書き換えて実行してみる。
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
メソッドを使用する。
テスト対象となるコードは以下のようになる。
const fetchData = () => {
return Promise.reject('error')
}
テストコードは以下のようになる。
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
追加の必要性を知るために、テスト対象のコードを次のように書き換えてみる。
const fetchData = () => {
- return Promise.reject('error')
+ return Promise.resolve('error')
}
これは、fetchData
がreject
ではなく、なんらかのコード改変を行なった結果resolve
が返却されてしまった場合を想定している。
この後、テストでexpect.assertions
を抜いてから実行してみる。
test('the data is peanut butter', () => {
- expect.assertions(1)
return fetchData().catch(str => {
expect(str).toBe('error')
})
})
この時、テストが失敗することを期待するが、テストはパスしてしまう。
ここで、expect.assertions
を追加してから再度実行してみる。
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
マッチャーで書き直してみる。
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した場合はテストは自動的に失敗する。
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
async
とawait
をテストで利用できる。非同期テストを書くには、test
に渡す関数の前にasync
キーワードを記述する。fetchData
のテストを書き換えてみる。
test('the data is peanut butter', async () => {
- return expect(fetchData()).resolves.toBe('peanut butter')
+ const str = await fetchData()
+ expect(str).toBe('peanut butter')
})
テストが失敗することを期待するテストも以下のように書き換えられる。
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')
+ }
})
async
とawait
を.resolves
または、.reject
と組み合わせることができる。
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はこれらを処理するヘルパー機能を提供する。
テストごとにセットアップ作業を繰り返す
多くのテストで繰り返し行う必要がある場合は、beforeEach
とafterEach
を使用する。
テスト対象として、あるポイントを管理するクラスを用意する。
このクラスは自信が管理しているポイントを足したり引いたりするadd
とsub
関数を持つ。
また、静的プロパティのtotal
を内部にもつ。total
はインスタンスに関係なく値を保持し続ける。
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.point
とPointManagement
に加算する。
it('add 1', () => {
const pointManagement = new PointManagement(100)
expect(pointManagement.add(1)).toBe(101)
expect(pointManagement.total).toBe(101)
})
次に、sub
関数が正常に動作するかをテストする。sub
は第2引数に渡されたポイント数をthis.point
とPointManagement.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
に処理をまとめる。
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 1
のexpect(pointManagement.total).toBe(99)
が失敗し、sub 1
が先にテストしている場合、add 1
のexpect(pointManagement.total).toBe(101)
が失敗する。
原因は、静的プロパティのtotal
にある。この2つのテストはそれぞれ実行順序に関係なく、テストに成功してほしいため、PointManagement.total
は毎回リセットされることが望ましい。
そこで、テストの後に毎回実行されるafterEach
を利用する。
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)
})
これでテストがパスするようになる。
ちなみにbeforEach
とafterEach
は非同期コードを扱える。話がややこしくなるので割愛。
ワンタイムセットアップ
セットアップがファイルの先頭で1回だけ実行されることが必要なケースがある。このセットアップが非同期で行われる場合は特に面倒になるので、インラインでは実施できない。Jestはこの状況に対応するためにbeforeAll
とafterAll
を提供している。
例えば、先ほど出していたテストのうち、it('add 1', () => { ... })
とit('sub 1', () => { ... })
のテストは独立させたくない場合、beforeAll
とafterAll
を使って以下のように書き換えられる。
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)
})
スコープ
デフォルトでは、before
とafter
ブロックはファイルの中の各テストに適用される。ファイルの中であっても、特定のテストのみ適用させたい場合は、describe
ブロックを使って複数のテストをグループ化することで対応できる。describe
ブロック内にあるbefore
とafter
ブロックはdescribe
ブロックの中のテストにだけ適用される。
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)
})
})
before
とafter
ブロックの実行順序に注意すること。例えば以下のようなコードを実行してみることで、その実行順序がわかる。
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度だけテストを実行するには、test
をtest.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
関数の実装をテストすることを考えてみる。この関数は与えられた各配列に対して、コールバック関数を呼び出す。
const forEach = (items: number[], callback: (item: number) => number) => {
return items.map(item => {
return callback(item)
})
}
この関数をテストするために、モック関数を利用して、コールバックが期待通りに呼び出されるかを確認する。
ひとまず、モック関数を使ったテストコードを以下に示す。
詳細は次から。
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)
としてみた。
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
を利用する。
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からユーザー情報を取得するクラスをテスト対象として考えてみる。
import axios from '../axios'
class Users {
static all() {
return axios.get('/users.json').then(resp => resp.data)
}
}
Usersクラスはaxios
を使用してAPIをよび、全てのユーザーが持っているdata属性を返す。
動作させるために、axios
モジュールを仮に作成しておく。
export default ({
get: (path: string) => Promise.resolve({ data: [{ name: 'axios user data' }] })
})
Usersのテストコードを書いてみる。まず初めにjest.mock(...)
関数を使わない状態で書いてみる。
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
をモックしてしまい、依存をなくしておく。コードは以下のようになる。
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')
に偽のレスポンスを返すようにさせている。
部分的なモック
モジュールの一部分だけをモックすることができる。
export const foo = 'foo'
export const bar = () => 'bar'
export default () => 'baz'
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
を受け取っている)
テストを成功させるためには、以下のように記述する。(ただ、なぜ失敗するのか根本原因がわからない)
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
が使われている)
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