🏆

翻訳「フロントエンドアプリケーションの静的、単体、結合、E2Eテスト」 by Kent C. Dodds

2023/02/20に公開

This is a translation of the original post Static vs Unit vs Integration vs E2E Testing for Frontend Apps by Kent C. Dodds.

banner

TestingJavaScript.com で公開されている私のインタビュー「Testing Practices with J.B. Rainsberger」の中で、彼は私がとても好きな比喩を述べています。彼はこう言いました。

"You can throw paint against the wall and eventually you might get most of the wall, but until you go up to the wall with a brush, you'll never get the corners. 🖌️"
「壁に絵の具を投げつけて、最終的には壁の大部分を塗ることができるかもしれませんが、ブラシで壁に近寄るまでは、隅々まで塗ることはできないのです。🖌️」

私はこの比喩がテストに通ずるという点で好きです。正しいテスト戦略を選択することは、壁を塗るためのブラシを選択するのと基本的に同じようなものだと言っているのです。細いブラシを壁全体に使うでしょうか?もちろん、そうではありません。時間がかかりすぎるし、仕上がりも均一にはならないでしょう。それでは、200年前に曾祖母が海を越えて運んできた家具の周りまで、ローラーで塗るでしょうか?ありえません。ユースケースによってブラシが異なりますが、テストにも同じことが言えます。

これが、私がテスティングトロフィーを作った理由です。それ以来、マギー・アップルトンegghead.io の見事なアート/デザインの首謀者)は TestingJavaScript.com のためにこれを作りました。

testing-trophy

テスティングトロフィーには、4種類のテストがあります。この文章は上に表示されていますが、アシスティブ・テクノロジーを使っている人のために(また、画像が読み込めない人のために)、ここに上から順に書きます。

  • End to End: ユーザーのように振る舞うヘルパーロボットが、アプリをクリックして回り、正しく機能するかどうかを検証すること。「機能テスト」「e2e」と呼ばれることもある。
  • 結合テスト(Integration): 複数のユニットが調和して動作することを検証する。
  • 単体テスト(Unit): 個々の独立した部品が期待通りに動作することを検証する。
  • 静的テスト(Static): コードを書きながら、タイプミスや型の間違いをキャッチする。

トロフィーが示すこれらテスト形式の大きさは、あなたのアプリケーションをテストする際に、どれだけ焦点を当てるべきかということと相対的なものです(一般的に)。これらの異なる形式のテストについて深く掘り下げ、それが実際的に何を意味するのか、そして、テストにかかる費用を最大限に活用するために何ができるのかを考えてみたいと思います。

テストの種類

このようなテストにはどのようなものがあるのか、上から順番に見ていきましょう。

End to End

通常、これらのテストはアプリケーション全体(フロントエンドとバックエンドの両方)を実行し、典型的なユーザーが行うのと同じように、テストはアプリケーションとやりとりします。これらのテストは cypress で書かれています。

import {generate} from 'todo-test-utils'

describe('todo app', () => {
  it('should work for a typical user', () => {
    const user = generate.user()
    const todo = generate.todo()
    // ここでは、登録のプロセスを行っています。
    // 通常、これを行うe2eテストは1つだけです。
    // 残りのテストは、アプリが行うのと同じエンドポイントを叩くので、その経験を通じてナビゲートするのをスキップできます。
    cy.visitApp()

    cy.findByText(/register/i).click()

    cy.findByLabelText(/username/i).type(user.username)

    cy.findByLabelText(/password/i).type(user.password)

    cy.findByText(/login/i).click()

    cy.findByLabelText(/add todo/i)
      .type(todo.description)
      .type('{enter}')

    cy.findByTestId('todo-0').should('have.value', todo.description)

    cy.findByLabelText('complete').click()

    cy.findByTestId('todo-0').should('have.class', 'complete')
    // etc...
    // 私のE2Eテストは、通常、ユーザが行うのと同じように動作します。
    // 時にはかなり長くなることがあります。
  })
})

結合テスト(Integration)

以下のテストは、アプリ全体をレンダリングします。これは結合テストの要件ではありませんし、私の結合テストのほとんどはアプリ全体をレンダリングしません。しかし、私のアプリで使用されているすべてのプロバイダはレンダリングします(これは、架空のモジュール "test/app-test-utils" の render メソッドが行うことです)。結合テストの背後にある考え方は、できるだけモックを少なくすることです。私は以下のようなモックしかほどんどしていません。

  1. ネットワークリクエスト (MSW を使用)
  2. アニメーションを担当するコンポーネント (テスト中にアニメーションを待ちたい人はいないでしょうから)
import * as React from 'react'
import {render, screen, waitForElementToBeRemoved} from 'test/app-test-utils'
import userEvent from '@testing-library/user-event'
import {build, fake} from '@jackfranklin/test-data-bot'
import {rest} from 'msw'
import {setupServer} from 'msw/node'
import {handlers} from 'test/server-handlers'
import App from '../app'

const buildLoginForm = build({
  fields: {
    username: fake(f => f.internet.userName()),
    password: fake(f => f.internet.password()),
  },
})

// 結合テストは通常、MSW を介して HTTP リクエストをモックするだけです。
const server = setupServer(...handlers)

beforeAll(() => server.listen())
afterAll(() => server.close())
afterEach(() => server.resetHandlers())

test(`logging in displays the user's username`, async () => {
  // カスタムレンダリングは、アプリの読み込みが完了したときに解決するプロミスを返します。
  // (サーバーレンダリングの場合は、これは必要ないかもしれません)
  // カスタムレンダリングでは、初期ルートを指定することもできます。
  await render(<App />, {route: '/login'})
  const {username, password} = buildLoginForm()

  userEvent.type(screen.getByLabelText(/username/i), username)
  userEvent.type(screen.getByLabelText(/password/i), password)
  userEvent.click(screen.getByRole('button', {name: /submit/i}))

  await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i))

  // ユーザーがログインしていることを確認するために必要なことを実行します。
  expect(screen.getByText(username)).toBeInTheDocument()
})

これらのために、私は通常、テスト間ですべてのモックを自動的にリセットするように、いくつかのことをグローバルに設定します。

上記のような test-utils ファイルのセットアップ方法は、React Testing Library setup docs を参照してください。

単体テスト

import '@testing-library/jest-dom/extend-expect'
import * as React from 'react'
// 上記の結合テストの例のように、テスト用ユーティリティモジュールがある場合、
// @testing-library/react の代わりにそれを使います。
import {render, screen} from '@testing-library/react'
import ItemList from '../item-list'

// ReactでDOMにレンダリングしているので、これらを単体テストと呼ばない人もいます。
// 彼ら彼女らは代わりに shallow rendering を使えというかもしれません。
// そう言われたら、https://kcd.im/shallow を送りつけてください。
test('renders "no items" when the item list is empty', () => {
  render(<ItemList items={[]} />)
  expect(screen.getByText(/no items/i)).toBeInTheDocument()
})

test('renders the items in a list', () => {
  render(<ItemList items={['apple', 'orange', 'pear']} />)
  // 注:これほど単純なものであれば、代わりにスナップショットを使用することを考えるかもしれませんが、それは以下の場合のみです。
  // 1. スナップショットが小さい場合
  // 2. toMatchInlineSnapshot() を使用する。
  // 参照: https://kcd.im/snapshots
  expect(screen.getByText(/apple/i)).toBeInTheDocument()
  expect(screen.getByText(/orange/i)).toBeInTheDocument()
  expect(screen.getByText(/pear/i)).toBeInTheDocument()
  expect(screen.queryByText(/no items/i)).not.toBeInTheDocument()
})

誰もがこれを単体テストと呼びますが、その通りです。

// 純粋な関数は単体テストに最適で、私はこの関数のために jest-in-case を使うのが大好きです!
import cases from 'jest-in-case'
import fizzbuzz from '../fizzbuzz'

cases(
  'fizzbuzz',
  ({input, output}) => expect(fizzbuzz(input)).toBe(output),
  [
    [1, '1'],
    [2, '2'],
    [3, 'Fizz'],
    [5, 'Buzz'],
    [9, 'Fizz'],
    [15, 'FizzBuzz'],
    [16, '16'],
  ].map(([input, output]) => ({title: `${input} => ${output}`, input, output})),
)

静的チェック

// バグを発見できますか?
// ESLint の for-direction ルールならあなたがコードレビューより早く発見するできるはずです 😉
for (var i = 0; i < 10; i--) {
  console.log(i)
}

const two = '2'
// これはちょっと作為的ですが、
// TypeScriptは、これは悪いことだと言うでしょう。
const result = add(1, two)

なぜ、テストをするのか?

そもそもなぜテストを書くのか、その理由を思い出すことが大切だと思うのです。なぜテストを書くのでしょうか?私がそうしろと言ったからですか?テストを含まないとPRが却下されるから?テストがワークフローを向上させるからでしょうか?

私がテストを書く最大かつ最も重要な理由は信頼性です。将来のために書いているコードが、今日、本番で動かしているアプリを壊さないことに自信を持ちたいのです。だから、何をするにしても、私が書くテストが私に最大限の自信をもたらすことを確認したいし、テストするときのトレードオフを認識する必要があるのです。

トレードオフについて話そう

この写真(私のこのスライドから抜粋)で示したい、テスティングトロフィーに重要な要素がいくつかあります。

confidence-coefficient
kcd.im/confident-react

画像の矢印は、自動テストを書くときに行う3つのトレードオフを意味しています。

コスト: ¢ heap(安い) ➡ 💰🤑💰

テスティングトロフィーを上がるにつれて、テストはよりコストがかかるようになります。これは、継続的インテグレーション環境でテストを実行するための実際の費用という形で現れますが、エンジニアが個々のテストを書き、維持するために要する時間という形でも現れます。

トロフィーの上に上がれば上がるほど、失敗するポイントが増えるので、テストが壊れる可能性が高くなり、テストの解析と修正に多くの時間がかかるようになります。大事なことなので覚えておいてください #伏線...

スピード: 🏎💨 ➡ 🐢

テスティングトロフィーを上がるにつれて、テストの実行速度は通常遅くなります。これは、テスティングトロフィーの上に上がれば上がるほど、テストにて実行されるコードが多くなるためです。単体テストは通常、依存関係のない小さなものをテストするか、 あるいは依存関係のあるものをモックします (何千行にもなるコードを、たった数行で置き換えます)。これは重要なことなので、覚えておいてください #伏線...

自信: 簡単な問題👌 ➡ 大きな問題 😖

人々がテストピラミッド🔺について話すとき、コストとスピードのトレードオフが一般的に言及されます。しかし、もしそれが唯一のトレードオフだとしたら、私はテストピラミッドについて話すとき、単体テストに 100% の力を注ぎ、他のテスト形式を完全に無視するでしょう。もちろん、そうするべきではありません。なぜなら、私が以前に言ったことを聞いたことがあるかもしれませんが、ある超重要な原則があるからです。

"The more your tests resemble the way your software is used, the more confidence they can give you."
「テストがソフトウェアの使用方法に似ていればいるほど、より高い信頼性を得ることができます。」

これはどういう意味でしょうか。つまり、あなたのマリー叔母さんがあなたの税務ソフトを使って確定申告できるようにするためには、実際にマリー叔母さんにやってもらうのが一番だということです。でも、マリー叔母さんがバグを見つけてくれるのを待つのは嫌ですよね?時間がかかりすぎるし、本来テストすべき機能を見逃してしまうかもしれません。さらに、私たちは定期的にソフトウェアのアップデートをリリースしていますが、人間がそれに追いつくのは不可能です。

では、どうすればいいのか? トレードオフをするのです。 その方法は?私たちはソフトウェアをテストするためのソフトウェアを書くのです。その結果、マリー叔母さんがテストしていたときほどには、ソフトウェアの使われ方とテストが一致しなくなるというトレードオフが生じます。でも、そのアプローチで抱えていた本当の問題を解決するために、そうしているのです。そしてそれは、テスティングトロフィーのすべてのレベルで行われていることなのです。

テスティングトロフィーを上がるにつれて、私が"信頼係数 "と呼ぶものが増えていきます。 これは、各テストがそのレベルで得ることができる相対的な信頼度です。トロフィーより上にあるのは手動テストだと想像してください。その場合、テストから得られる信頼度は非常に高いのですが、テストは非常に高価で時間がかかります。

先ほど私は、2つのことを覚えてくださいと言いました。

  • トロフィーを上がれば上がるほど、失敗するポイントが増えるので、テストが壊れる可能性が高くなります。
  • 単体テストは通常、依存関係のない小さなものをテストするか、あるいは依存関係のあるものをモックします (何千行ものコードを数行のコードに置き換える)。

つまり、トロフィーの下にいけばいくほど、テストしているコードの量は少なくなるということです。低い階層のテストでは、アプリケーションのコード行数をカバーするために、より多くのテストが必要になります。実際、テスト階層が下がれば下がるほど、テストすることが不可能になるものもあります。

特に、静的解析ツールは、ビジネスロジックに信頼性を与えることができません。単体テストは、依存関係を呼び出すときに、それが適切に呼び出されていることを確認することができません(どのように呼び出されているかについての表明はできますが、単体テストでは、それが適切に呼び出されていることを確認することはできません)。UI 結合テストは、バックエンドに正しいデータを渡しているか、エラーに正しく対応し解析しているかを確認することができません。End to End テストは非常に有能ですが、通常、その信頼性と実用性をトレードオフするために、本番環境ではない環境(本番に近い、でも本番ではない)にてこれらを実行することになります。

今度は逆のことをやってみましょう。テスティングトロフィーの頂点で、フォームと URL 生成におけるエッジケースに対して、あるフィールドに入力して送信ボタンをクリックすることを確認するために E2E テストを使おうとすると、アプリケーション全体(バックエンドも含む)を動かすことによって多くの設定作業をすることになります。これは結合テストに適しているかもしれません。クーポンコード計算のエッジケースに結合テストを使おうとすると、クーポンコード計算を使用するコンポーネントをレンダリングできることを確認するために、セットアップ関数でかなりの量の作業をしている可能性があり、単体テストでそのエッジケースをよりよくカバーすることができるかもしれません。もし単体テストで add 関数を数値ではなく文字列で呼び出したときに何が起こるかを検証しようとするならば、TypeScriptのような静的型チェックツールを使った方がはるかに良い結果を得られるかもしれません。

まとめ

すべてのレベルには、それぞれトレードオフがあります。E2E テストは、より多くの失敗のポイントを持つので、どのコードが原因で壊れたのかを追跡するのがしばしば難しくなります。しかし、それはまた、テストが自信につながるということでもあります。これは、テストを書く時間があまりない場合に特に有効です。私は、テストによって問題を発見できなかった場合よりも、自信を持ちつつも、なぜ失敗したのかを追跡することに直面した場合の方が良いと思います。

結局のところ、私はその区別を気にしていません。 もしあなたが私の単体テストを結合テストと呼びたいなら、あるいはE2Eテスト(何人かがしたように 🤷‍♂️)とでも呼びたいなら、そうすればいいと思います。私が興味があるのは、変更を加えて出荷するときに、私のコードがビジネス要件を満たしていると確信できるかどうかということであり、その目標を達成するために、さまざまなテスト戦略を組み合わせて使用することです。

Good luck!

GitHubで編集を提案

Discussion