AHAテスト 💡

Published

これは、Kent C. Dodds 氏のブログ記事であるAHA Testing 💡を日本語訳してみたものです。

誤訳などあればIssueや PR を頂けると幸いです。


AHA プログラミング原則は”Avoid Hasty Abstraction(性急な抽象化を避けよ)”の略です。私には、メンテナブルなテストを書く上でこれをどのように適用するかということの具体的な思いがあります。 私が実際に見てきたテストのほとんどが抽象の分布の片方に偏ったものでした:ANA(”Absolutely No Abstraction” - 全く抽象化しない)か完全に DRY(Don't Repeat Yourself - 繰り返しを避ける)か。(ANA はたった今作りました。)

ANA (Absolutely No Abstraction)
AHA (Avoid Hasty Abstraction)
DRY (Don't Repeat Yourself)
T h eS p e c t r u mo fA b s t r a c t i o n

抽象化の分布の中間のスイートスポットを見つけることがメンテナブルなテストを開発する鍵となります。

ANA テスト

私が見てきたテストにおける”全く抽象化しない”最も良い例はExpressJS のルートのハンドラーです。 ”ANA がテストに向いていない”と私が言うことが何を意味しているかを理解してもらうために、典型的なテストファイルを用意してそのコードベースとテストをメンテナンスするふりをしてみましょう。 このルートがどのように動作するかを理解していることが重要です。あなたは、何か壊してしまわないようにするためのテストがあることで安心します。それであなたはルートハンドラのニュアンスを理解するためにテストを利用することにします。

このテストを読んで、2 つの間の微妙な差異を理解してみましょう。

時間をかけ過ぎないように。。。


_125
import * as blogPostController from '../blog-post'
_125
_125
// データベース用のアプリケーション全体でのモックを読み込みます。
_125
// これはANNA(Almost Absolutely No Abstraction - ほぼ全く抽象化しない)を
_125
// 意味するかもしれませんが、データベースのモックの全てをこのブログ記事に
_125
// 書きたくありません 😅
_125
jest.mock('../../lib/db')
_125
_125
test('lists blog posts for the logged in user', async () => {
_125
const req = {
_125
locale: {
_125
source: 'default',
_125
language: 'en',
_125
region: 'GB',
_125
},
_125
user: {
_125
guid: '0336397b-e29d-4b63-b94d-7e68a6fa3747',
_125
isActive: false,
_125
picture: 'http://placehold.it/32x32',
_125
age: 30,
_125
name: {
_125
first: 'Francine',
_125
last: 'Oconnor',
_125
},
_125
company: 'ACME',
_125
email: 'francine.oconnor@ac.me',
_125
latitude: 51.507351,
_125
longitude: -0.127758,
_125
favoriteFruit: 'banana',
_125
},
_125
body: {},
_125
cookies: {},
_125
query: {},
_125
params: {
_125
bucket: 'photography',
_125
},
_125
header(name) {
_125
return {
_125
Authorization: 'Bearer TEST_TOKEN',
_125
}[name]
_125
},
_125
}
_125
const res = {
_125
clearCookie: jest.fn(),
_125
cookie: jest.fn(),
_125
end: jest.fn(),
_125
locals: {
_125
content: {},
_125
},
_125
json: jest.fn(),
_125
send: jest.fn(),
_125
sendStatus: jest.fn(),
_125
set: jest.fn(),
_125
}
_125
const next = jest.fn()
_125
_125
await blogPostController.loadBlogPosts(req, res, next)
_125
_125
expect(res.json).toHaveBeenCalledTimes(1)
_125
expect(res.json).toHaveBeenCalledWith({
_125
posts: expect.arrayContaining([
_125
expect.objectContaining({
_125
title: 'Test Post 1',
_125
subtitle: 'This is the subtitle of Test Post 1',
_125
body: 'The is the body of Test Post 1',
_125
}),
_125
]),
_125
})
_125
})
_125
_125
test('returns an empty list when there are no blog posts', async () => {
_125
const req = {
_125
locale: {
_125
source: 'default',
_125
language: 'en',
_125
region: 'GB',
_125
},
_125
user: {
_125
guid: '0336397b-e29d-4b63-b94d-7e68a6fa3747',
_125
isActive: false,
_125
picture: 'http://placehold.it/32x32',
_125
age: 30,
_125
name: {
_125
first: 'Francine',
_125
last: 'Oconnor',
_125
},
_125
company: 'ACME',
_125
email: 'francine.oconnor@ac.me',
_125
latitude: 31.230416,
_125
longitude: 121.473701,
_125
favoriteFruit: 'banana',
_125
},
_125
body: {},
_125
cookies: {},
_125
query: {},
_125
params: {
_125
bucket: 'photography',
_125
},
_125
header(name) {
_125
return {
_125
Authorization: 'Bearer TEST_TOKEN',
_125
}[name]
_125
},
_125
}
_125
const res = {
_125
clearCookie: jest.fn(),
_125
cookie: jest.fn(),
_125
end: jest.fn(),
_125
locals: {
_125
content: {},
_125
},
_125
json: jest.fn(),
_125
send: jest.fn(),
_125
sendStatus: jest.fn(),
_125
set: jest.fn(),
_125
}
_125
const next = jest.fn()
_125
_125
await blogPostController.loadBlogPosts(req, res, next)
_125
_125
expect(res.json).toHaveBeenCalledTimes(1)
_125
expect(res.json).toHaveBeenCalledWith({
_125
posts: [],
_125
})
_125
})

違いを見つけられましたか?そう!最初の方では記事を見つけることを想定して、2 つ目の方ではそうではないことを想定しています。いいですね!よくできました。でも、、、何が原因でしょうか? なぜblogPostController.loadBlogPosts(req, res, next)がres.jsonで最初の方はブログ記事を返して 2 つ目ではそうならないのでしょうか?

もし分からなくても後でお伝えするので、気を悪くしたり心配したりしないでも大丈夫です。もし分かるのだとしたらあなたは恐らく”ウォーリーを探せ”が得意で、 そしてそれが私の主張したいことです。このようなテストはテストを理解してメンテナンスすることを必要以上に難しくします。

では 1 つのファイルにこのようなテストが 20 個あることを想像してください。恐ろしくありませんか?そうです、とても酷いです。そのようなテストを 1 度も見たことがありませんか?あなたはとても幸運です! 私は幾度となく目にしておきました。その経緯はこのようなものです:

  1. エンジニアのジョーがチームにジョインします
  2. ジョーはテストを追加する必要に迫られます
  3. ジョーは必要としたものによく似た以前からあるテストをコピーして、ユースケースに合わせて修正します
  4. レビュアーはテストが通ることを確認して、ジョーが会話を理解していると考えます
  5. PR がマージされます

これがリトマス試験紙です:

2 つの類似したテストのアサーションの違いを判断するのはどれくらい簡単で、その違いが何から生じますか?

全く抽象化しないテストはこれを非常に難しくします。

DRY テスト

今はDRYなテストの良い例を出す時間がありません。何にでもDRYを適用するとこのようなプロセスのためにメンテナンスが難しくなっていくことがよくあるのを知ってください:

  1. エンジニアのジョーがチームにジョインします
  2. ジョーはテストを追加する必要に迫られます
  3. ジョーは基本的に必要としたものによく似た以前からあるテストをコピーして、状況に合わせてテストのユーティリティーに他のif文を追加します
  4. レビュアーはテストが通ることを確認して、ジョーが会話を理解していると考えます
  5. PR がマージされます

私が DRY なテストでよく目にするもう 1 つのものは、describeとitのネスト + beforeEachの濫用です。ネストしてテスト間で変数を共有すればするほど、ロジックを追いかけるのが困難になります。 Test Isolation with Reactにおいてこの問題について少し書いてあるので読んでみるといいかもしれません。

AHA テスト

最初のテストは確実に抽象化のための悲鳴を上げています(これは AHA プログラミングの指針となるものです)。そこで、思慮深く抽象化したものを書いてみましょう。これらのテストで何が違いを生んでいるかを考えてください:


_87
import * as blogPostController from '../blog-post'
_87
_87
// データベース用のアプリケーション全体でのモックを読み込みます。
_87
jest.mock('../../lib/db')
_87
_87
function setup(overrides = {}) {
_87
const req = {
_87
locale: {
_87
source: 'default',
_87
language: 'en',
_87
region: 'GB',
_87
},
_87
user: {
_87
guid: '0336397b-e29d-4b63-b94d-7e68a6fa3747',
_87
isActive: false,
_87
picture: 'http://placehold.it/32x32',
_87
age: 30,
_87
name: {
_87
first: 'Francine',
_87
last: 'Oconnor',
_87
},
_87
company: 'ACME',
_87
email: 'francine.oconnor@ac.me',
_87
latitude: 51.507351,
_87
longitude: -0.127758,
_87
favoriteFruit: 'banana',
_87
},
_87
body: {},
_87
cookies: {},
_87
query: {},
_87
params: {
_87
bucket: 'photography',
_87
},
_87
header(name) {
_87
return {
_87
Authorization: 'Bearer TEST_TOKEN',
_87
}[name]
_87
},
_87
...overrides,
_87
}
_87
_87
const res = {
_87
clearCookie: jest.fn(),
_87
cookie: jest.fn(),
_87
end: jest.fn(),
_87
locals: {
_87
content: {},
_87
},
_87
json: jest.fn(),
_87
send: jest.fn(),
_87
sendStatus: jest.fn(),
_87
set: jest.fn(),
_87
}
_87
const next = jest.fn()
_87
_87
return {req, res, next}
_87
}
_87
_87
test('lists blog posts for the logged in user', async () => {
_87
const {req, res, next} = setup()
_87
_87
await blogPostController.loadBlogPosts(req, res, next)
_87
_87
expect(res.json).toHaveBeenCalledTimes(1)
_87
expect(res.json).toHaveBeenCalledWith({
_87
posts: expect.arrayContaining([
_87
expect.objectContaining({
_87
title: 'Test Post 1',
_87
subtitle: 'This is the subtitle of Test Post 1',
_87
body: 'The is the body of Test Post 1',
_87
}),
_87
]),
_87
})
_87
})
_87
_87
test('returns an empty list when there are no blog posts', async () => {
_87
const {req, res, next} = setup()
_87
req.user.latitude = 31.230416
_87
req.user.longitude = 121.473701
_87
_87
await blogPostController.loadBlogPosts(req, res, next)
_87
_87
expect(res.json).toHaveBeenCalledTimes(1)
_87
expect(res.json).toHaveBeenCalledWith({
_87
posts: [],
_87
})
_87
})

お分かり頂けたでしょうか?最初のテストと 2 つ目のテストとの違いは何でしょうか?最初のテストではユーザーがロンドンにいて、2 つ目のテストではユーザーが上海にいます! うーん、同僚が私たちは位置情報に基づくブログプラットフォームに取り組んでいると言ってくれたら良かったのに(ちょっと、これは面白いプロダクトのアイディアですよね 🤔)。

少しの心がけで抽象化することで、入力と出力の違いで何が重要かより明確になって、より意味のあるメンテナンスしやすいテストとなりました。

React の AHA テスト

React では、私はここでのsetup関数のように振る舞うrenderFoo関数を用意することが時々あります。以下が簡単な例です:


_33
import * as React from 'react'
_33
import {render, screen} from '@testing-library/react'
_33
import userEvent from '@testing-library/user-event'
_33
import LoginForm from '../login-form'
_33
_33
function renderLoginForm(props) {
_33
render(<LoginForm {...props} />)
_33
const usernameInput = screen.getByLabelText(/username/i)
_33
const passwordInput = screen.getByLabelText(/password/i)
_33
const submitButton = screen.getByText(/submit/i)
_33
return {
_33
usernameInput,
_33
passwordInput,
_33
submitButton,
_33
changeUsername: value => userEvent.type(usernameInput, value),
_33
changePassword: value => userEvent.type(passwordInput, value),
_33
submitForm: () => userEvent.click(submitButton),
_33
}
_33
}
_33
_33
test('submit calls the submit handler', () => {
_33
const handleSubmit = jest.fn()
_33
const {changeUsername, changePassword, submitForm} = renderLoginForm({
_33
onSubmit: handleSubmit,
_33
})
_33
const username = 'chucknorris'
_33
const password = 'ineednopassword'
_33
changeUsername(username)
_33
changePassword(password)
_33
submitForm()
_33
expect(handleSubmit).toHaveBeenCalledTimes(1)
_33
expect(handleSubmit).toHaveBeenCalledWith({username, password})
_33
})

注意:これを使用するテストが 1 つのファイルにおいて 2 つか 3 つしかなく短いものである場合、私はこれを時期早々の抽象化だと考えるでしょう。ただし、テストしているのがいくつかの微妙な差異を持つ場合(例えばエラーの状態のようなもの)、 このような抽象化は素晴らしいものとなります。

ネスト

Avoid Nesting in Testsを一度読んでみてください。

jest-in-case と test.each

純粋な関数に対してテストを書いている場合、大抵最もテストしやすいものなのでついています。非常に明確な形で入力と出力を呼び出す簡単な抽象化によってテストを簡潔にできます。

(作為的な)例:


_13
import add from '../add'
_13
_13
test('adds one and two to equal three', () => {
_13
expect(add(1, 2)).toBe(3)
_13
})
_13
_13
test('adds three and four to equal seven', () => {
_13
expect(add(3, 4)).toBe(7)
_13
})
_13
_13
test('adds one hundred and two to equal one hundred two', () => {
_13
expect(add(100, 2)).toBe(102)
_13
})

非常にシンプルですがjest-in-caseで改善できます。


_14
import cases from 'jest-in-case'
_14
import add from '../add'
_14
_14
cases(
_14
'add',
_14
({first, second, result}) => {
_14
expect(add(first, second)).toBe(result)
_14
},
_14
[
_14
{first: 1, second: 2, result: 3},
_14
{first: 3, second: 4, result: 7},
_14
{first: 100, second: 2, result: 102},
_14
],
_14
)

このような簡単な例でわざわざこのようなことをしないかもしれませんが、配列にただ要素を追加するだけでとても簡単にテストケースを追加できることが魅力です。 このコンセプトの好例(jest-in-case を使っていないもの)はrtl-css-jsのテストです。 このコードベースへのコントリビューターはこの構造で新しいテストケースを追加するのが非常に簡単であることがわかります。

これは純粋ではない関数やモジュールにも適用できますが、もう少し手間がかかります。 (全く誇れるものではないですがそんなに悪くもないテストがこちらです)。

私は個人的にjest-in-caseが好みですが、Jest にはビルトインのtest.eachの機能があり便利かもしれません。

結論

確かにテストはより良い名前やコメントによって改善できていましたが、単純なsetupの抽象化(ちなみにこれは”Test Object Factory”と呼ばれます)ではそれが必要ありません。 つまり私が伝えたいのは:注意深く抽象化を適用したテストはテストを書いてメンテナンスする手間が少なく済むということです。

お役に立てれば幸いです!幸運を祈ります!