AHAテスト 💡
これは、Kent C. Dodds 氏のブログ記事であるAHA Testing 💡を日本語訳してみたものです。
誤訳などあればIssueや PR を頂けると幸いです。
AHA プログラミング原則は”Avoid Hasty Abstraction(性急な抽象化を避けよ)”の略です。私には、メンテナブルなテストを書く上でこれをどのように適用するかということの具体的な思いがあります。 私が実際に見てきたテストのほとんどが抽象の分布の片方に偏ったものでした:ANA(”Absolutely No Abstraction” - 全く抽象化しない)か完全に DRY(Don't Repeat Yourself - 繰り返しを避ける)か。(ANA はたった今作りました。)
抽象化の分布の中間のスイートスポットを見つけることがメンテナブルなテストを開発する鍵となります。
ANA テスト
私が見てきたテストにおける”全く抽象化しない”最も良い例はExpressJS のルートのハンドラーです。 ”ANA がテストに向いていない”と私が言うことが何を意味しているかを理解してもらうために、典型的なテストファイルを用意してそのコードベースとテストをメンテナンスするふりをしてみましょう。 このルートがどのように動作するかを理解していることが重要です。あなたは、何か壊してしまわないようにするためのテストがあることで安心します。それであなたはルートハンドラのニュアンスを理解するためにテストを利用することにします。
このテストを読んで、2 つの間の微妙な差異を理解してみましょう。
時間をかけ過ぎないように。。。
_125import * as blogPostController from '../blog-post'_125_125// データベース用のアプリケーション全体でのモックを読み込みます。_125// これはANNA(Almost Absolutely No Abstraction - ほぼ全く抽象化しない)を_125// 意味するかもしれませんが、データベースのモックの全てをこのブログ記事に_125// 書きたくありません 😅_125jest.mock('../../lib/db')_125_125test('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_125test('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 度も見たことがありませんか?あなたはとても幸運です! 私は幾度となく目にしておきました。その経緯はこのようなものです:
- エンジニアのジョーがチームにジョインします
- ジョーはテストを追加する必要に迫られます
- ジョーは必要としたものによく似た以前からあるテストをコピーして、ユースケースに合わせて修正します
- レビュアーはテストが通ることを確認して、ジョーが会話を理解していると考えます
- PR がマージされます
これがリトマス試験紙です:
2 つの類似したテストのアサーションの違いを判断するのはどれくらい簡単で、その違いが何から生じますか?
全く抽象化しないテストはこれを非常に難しくします。
DRY テスト
今はDRY
なテストの良い例を出す時間がありません。何にでもDRY
を適用するとこのようなプロセスのためにメンテナンスが難しくなっていくことがよくあるのを知ってください:
- エンジニアのジョーがチームにジョインします
- ジョーはテストを追加する必要に迫られます
- ジョーは基本的に必要としたものによく似た以前からあるテストをコピーして、状況に合わせてテストのユーティリティーに他の
if
文を追加します - レビュアーはテストが通ることを確認して、ジョーが会話を理解していると考えます
- PR がマージされます
私が DRY なテストでよく目にするもう 1 つのものは、describe
とit
のネスト + beforeEach
の濫用です。ネストしてテスト間で変数を共有すればするほど、ロジックを追いかけるのが困難になります。
Test Isolation with Reactにおいてこの問題について少し書いてあるので読んでみるといいかもしれません。
AHA テスト
最初のテストは確実に抽象化のための悲鳴を上げています(これは AHA プログラミングの指針となるものです)。そこで、思慮深く抽象化したものを書いてみましょう。これらのテストで何が違いを生んでいるかを考えてください:
_87import * as blogPostController from '../blog-post'_87_87// データベース用のアプリケーション全体でのモックを読み込みます。_87jest.mock('../../lib/db')_87_87function 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_87test('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_87test('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
関数を用意することが時々あります。以下が簡単な例です:
_33import * as React from 'react'_33import {render, screen} from '@testing-library/react'_33import userEvent from '@testing-library/user-event'_33import LoginForm from '../login-form'_33_33function 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_33test('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
純粋な関数に対してテストを書いている場合、大抵最もテストしやすいものなのでついています。非常に明確な形で入力と出力を呼び出す簡単な抽象化によってテストを簡潔にできます。
(作為的な)例:
_13import add from '../add'_13_13test('adds one and two to equal three', () => {_13 expect(add(1, 2)).toBe(3)_13})_13_13test('adds three and four to equal seven', () => {_13 expect(add(3, 4)).toBe(7)_13})_13_13test('adds one hundred and two to equal one hundred two', () => {_13 expect(add(100, 2)).toBe(102)_13})
非常にシンプルですがjest-in-case
で改善できます。
_14import cases from 'jest-in-case'_14import add from '../add'_14_14cases(_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”と呼ばれます)ではそれが必要ありません。
つまり私が伝えたいのは:注意深く抽象化を適用したテストはテストを書いてメンテナンスする手間が少なく済むということです。
お役に立てれば幸いです!幸運を祈ります!