PR

ユニットテストをしよう

2018年6月11日(月)
佐々木 俊介
【最新JavaScript開発~ES2017対応モダンプログラミング】 株式会社インプレスR&Dより発行された「最新JavaScript開発~ES2017対応モダンプログラミング」の立ち読みコーナー第5回です。

第5章 ユニットテストをしよう

 ユニットテストは、メソッドや関数など小さなユニット単位に絞り込んだ自動テストです。現代的なプログラミングに欠かせないものです1

 効能としてはコードの信頼性が向上するというのはもちろんですが、何より重要なことはテストのコストが下がることです。手動でテストを毎回行っていたらとても手間がかかりますが、ユニットテストならば勝手にテストをしてくれるので、コードを書き換えるたびにテストをするというような開発スタイルで効率が大幅に向上します。

 副次的な効果としてより、良い設計をもたらすという点もあります。これはユニットテストをきちんと行おうとすると、設計もそれに合わせたものになるためです。自然とシンプルでパワフルな構造に設計が変わる、という設計力養成ギプスとしての働きがあるのです。

 ユニットテストにはテストランナーを使うのが一般的です。有名なものにmocha2がありますが、最近はAVA3が人気です。本書ではAVAについて説明します。

5.1 AVA

 AVAは複数のテストを並列で走らせることができるため、とても高速なテストランナーです。test.jsという名前でリスト5.1を用意してavaコマンドを叩いてみてください。テストランナーは大体どれもAVA同様に専用のコマンドを叩いて実行します4

リスト5.1: テスト用のファイル

 1: const test = require('ava')
 2: 
 3: test(t => {
 4:   const a = 1
 5:   const b = 2
 6:   const c = 4
 7:   t.true(a + b === c)
 8: }) 

 testメソッドが一つのユニットテストです。複数のテストをする場合はこれを複数ならべることになります。コールバックの引数tはテストに使うためのオブジェクトで、t.true()は引数を評価してtrueならテスト成功というものです。falseならテストが失敗して、変数の中身やa + bが3になるという演算結果も詳しく表示してくれます。これはavaに組み込まれているpower-assert5によるものです。

 図5.1: 実行結果

 さて一般的にユニットテストでは、テスト対象になるファイルとテストをするファイルに分かれます。ここではsrc/hello.jsというファイルに定義されたhello関数をテストしてみましょう。

リスト5.2: src/hello.js

 1: const hello = s => {
 2:   return `Hello, ${s} world.`
 3: }
 4: module.exports = hello

 テストをする方のファイルはsrc/hello.test.jsというファイルを作っておきます6。test関数は第一引数に文字列を指定しておくと、テストが失敗した時にそれを表示してくれるので、わかりやすい説明を付けておくと良いでしょう。

リスト5.3: src/hello.test.js

 1: const test = require('ava')
 2: 
 3: const hello = require('./hello.js')
 4: 
 5: test('hello returns hello text', t => {
 6:   t.true(hello('Japari') === 'Hello, Japari world.')
 7: })

良いユニットテストとは

 良いユニットテストはどういうものでしょうか?ユニットテストの書き方の良し悪しもありますが、テストの対象となるオブジェクト・クラスがテスタブル(テストしやすいもの)な設計かどうかが大きな要素となります。

 では設計をテスタブルにするための条件は何でしょうか?一言でいえばシンプルな構造です。やることの意味合い(責務と言います)が多いクラスはそれだけテストしづらいものになりますし、コードの通るフローが複雑な場合(循環的複雑度が高いなどと呼ばれます)も同様にテストしづらいものになります。

 プログラマが覚えるべき概念のひとつに分割統治というものがあります。大きな問題は小さい問題に切り分けましょうという考え方です。オブジェクト指向設計においても分割統治はとても重要です。クラスを作ったときに責務が多いものは複雑ということです。これを分割統治していきましょう。そうすればテスタブルにしやすいのです。

5.2 TDD

 TDDはTest Driven Development(テスト駆動開発)のことですが、TDDはテスト技法ではなく設計及び開発の為の技法です。どういうことかというと、先にユニットテストを書きながらクラスの責務について考えを巡らせて実装をするというもので、TDDにおけるユニットテストは正しいクラス設計をするためのエンジンに過ぎないのです。TDDは特にこれからクラスを設計するという段階や、きれいなコードを書きたいという時に向いています。

 TDDは人間心理に基づいています。いきなりきれいで完璧な物を作ることは大変ですが、雑な物を作ってからきれいにするのであればその障壁は低くなるはずです。これは設計に関しても実装に関しても同様です。

5.2.1 黄金のサイクル

 TDDではRed/Green/Refactorサイクルというのが重要になります。このサイクルをよく黄金のサイクルと呼びますが、これをどれだけリズミカルに回せるかが重要になります。

 1.Red: テストだけ書く(動作対象がないのでテストは失敗する)2.Green: テストを通る最小限のコードを書く3.Refactor: リファクタリングして、良いコードに書き直す

 まずはRedの状態をリスト5.4で作りました。最低限のクラスとメソッドのひな形だけを作ってからテストを書きます。

リスト5.4: Red

 1: // consumption_tax.js
 2: class ConsumptionTax {
 3:   calc() {
 4:   }
 5: }
 6: 
 7: module.exports = ConsumptionTax
 8: 
 9: // consumption_tax.test.js
10: const ConsumptionTax = require('./consumption_tax')
11: const test = require('ava')
12: 
13: test(t => {
14:   const cunsumptionTax = new ConsumptionTax()
15:   t.true(consumptionTax.calc(100) === 108)
16: }) 

 このテストを実行するとt.true(consumptionTax.calc(100) === 108)が失敗することになります。ここで重要なのはコードはエラー無く走るけど、テストだけ失敗するという状況です。エラーが出るのであれば意味はありません。あと偶然テストが通るといった事例は、正しい実装に変化したことがわかりにくくなるため良くありません。

 次はGreenをリスト5.5のように作ります。ここでの目安は一分以内にできる実装にとどめることです。Greenの段階では、コピペや定数を返すなどなんでもありです。とにかくすぐさまテストだけが通るコードを書いてください。

リスト5.5: Green

 1: // consumption_tax.js
 2: class ConsumptionTax {
 3:   calc(price) {
 4:     return Math.floor(price * 1.08)
 5:   }
 6: }
 7: 
 8: module.exports = ConsumptionTax 

 次はRefactorです。マジックナンバーをハードコーディングするのは良くないのでthis.rateという形に変えてみました。リファクタリングで重要なのは挙動を絶対に変更しないということです。Greenでテストが通るようになっており、そのテストを使って挙動を変えることなくコードだけきれいにする必要があります。

リスト5.6: Refactor

 1: // consumption_tax.js
 2: class ConsumptionTax {
 3:   constructor() {
 4:     this.rate = 1.08
 5:   }
 6: 
 7:   calc(price) {
 8:     return Math.floor(price * this.rate)
 9:   }
10: }
11: 
12: module.exports = ConsumptionTax 

 ここまででRed, Green, Refactorの1サイクルをまわすことができました。次はコンストラクタにDateオブジェクトを渡すことで、その日付においての消費税計算ができるようにしましょう。リスト5.7では2013年1月1日時点の消費税計算のテストを追加しました。当時は5%だったので105が帰ってくるはずなのです。もちろん現時点での実装では108しか帰ってきません。

リスト5.7: Red

 1: // consumption_tax.test.js
 2: const ConsumptionTax = require('./consumption_tax')
 3: const test = require('ava')
 4: 
 5: test('現時点での消費税算出', t => {
 6:   const cunsumptionTax = new ConsumptionTax()
 7:   t.true(consumptionTax.calc(100) === 108)
 8: })
 9: 
10: test('2013年1月1日時点での消費税算出', t => {
11:   const cunsumptionTax = new ConsumptionTax(new Date(2013, 1, 1))
12:   t.true(consumptionTax.calc(100) === 105)
13: }) 

 Greenとしてリスト5.8を書きました。

リスト5.8: Green

 1: class ConsumptionTax {
 2:   constructor(date) {
 3:     if (date.getTim() < (new Date(2014, 4, 1)).getTime()) {
 4:       this.rate = 1.05
 5:     } else {
 6:       this.rate = 1.08
 7:     }
 8:   }
 9: 
10:   calc(price) {
11:     return Math.floor(price * this.rate)
12:   }
13: }
14: 
15: module.exports = ConsumptionTax 

 リスト5.9でRefactorしました。厳密にいえばまだ正しくない(2014年4月1日の消費税改訂しか対応してない)のですが、ユニットテストに加えた範囲内では正しいはずです。

リスト5.9: Refactor

 1: class ConsumptionTax {
 2:   constructor(date) {
 3:     const change_08 = new Date(2014, 4, 1)
 4: 
 5:     if (!date) {
 6:       date = new Date()
 7:     }
 8: 
 9:     if (date.getTime() < change_08.getTime()) {
10:       this.rate = 1.05
11:     } else {
12:       this.rate = 1.08
13:     }
14:   }
15: 
16:   calc(price) {
17:     return Math.floor(price * this.rate)
18:   }
19: }
20: 
21: module.exports = ConsumptionTax 

 ここまででTDDのRed/Green/Refactorサイクルを二回まわしてみました。必要に応じてコードが色々と追加されていることを分かっていただけたでしょうか?最初からすべて実装するのではなく、必要になった段階で実装するという習慣を付けておくと、効率の良い開発が可能になります。

 あとはさらに過去の消費税改訂を掘り起こしたり、境界値テストを付け加えたりなどでしょう。実装のリファクタリングでも範囲オブジェクトの概念を導入したり、消費税変更履歴を配列で持ったり、マジックナンバーの取り扱いを改善するなど色々するべきことはあります。

 ただしTDDは自分自身が十分に仕様をちゃんと設計・実装できていると確信できるものに対してはサイクルを回す必要はありません。うまくバランスを見いだしてください。

設計はどうあるべきか

 強固な設計フェイズのようなものを設けるべきなのでしょうか?何ヶ月もUMLを描いたりするべきなのでしょうか?個人的な考えでは、設計はせいぜい長くて一時間程度で行うべきだと思っています。対象を十分理解している場合ならばあらかじめ設計をやり込むこともありえますが、それ以外は設計に時間をかけても仕方ありません。UMLやもっと単純な図、文章で考えをまとめたら、早々とTDDやスパイクに取りかかるべきです。スパイクは見積もりの為のテスト開発です。たとえば未知の技術を扱うときに、最小限の実験を行って理解を深めて見積もりの精度を上げるのです。

 ただしこれはリーン的な考え方が通用するプロダクトの話です。リーンは徹底的に無駄を省くという考え方で、たとえばリーンスタートアップという本では、スタートアップはまず仮説を立ててその仮説に従った実用可能な最小限のプロダクト(MVPという)を作成しリリースし仮説に対する検証を行う、というイテレーション(繰り返し)を素早く行いプロダクトを作るべきだと書いていますが、これが未知の物に対する一番効率の良い開発スタイルなのです。

 実用可能な最小限のプロダクト、というのが大きすぎる事例ももちろんあります。そしてそういうプロダクトではしっかり設計フェイズを設けるというのもひとつの手です。ウォーターフォール開発が向いてる環境というのはそういう現場でしょう。

 さてMVPを考えたとき過度な設計はオーバーヘッドになります。YAGNI("You ain't gonna need it")という原則があります。必要になってから考えろというものです。先回りして設計したとして、ビジネス環境の変化などからそれが無駄になるという事例もよくあるからです。

 もちろん、あまりにも先を見通さないのも良くありません。ビジネス環境や技術環境などの変化を見据えた上で、オーバーヘッドにならない程度に先を見通すバランス感覚が必要でしょう。

 Facebook社のマーク・ザッカーバーグが言ったとされる言葉に "Done is better than perfect." というものがあります。完璧を目指すよりもまずは終わらせろ、という意味ですが、完璧を目指すよりもその労力で物をいったん作りそれのフィードバックを得て改良する方がよほど良い物を効率的に作ることができるためです。

5.3 ウェブブラウザ向けの開発におけるテスト

 ユニットテストはスコープのとても小さなテストの積み重ねでしたが、もっと大きな単位テストに、E2Eテスト7(End to End テスト)があります。ただこれは書かなくて済むなら書かない方がいいとされています。なぜなら、テストの実行時間が大幅に伸びるのと、ちゃんとしたE2Eテストを書こうとすると非効率的だからです。またテストをする為のソフトウェア自体の不安定さに苦しめられ、とても遠回りになってしまうこともあります。安定したE2Eテストを確立するためには多少なりともノウハウが必要なのです。

 ただJavaScriptでフロントエンドの場合、どうしてもブラウザの挙動という厄介なものを相手にするため、E2Eテストが必要な局面というのも確かにあります。そこでE2Eテストは本当に必要な場合に絞りましょう。E2Eテストでカバレッジを上げるよりはユニットテストでカバレッジを上げる方が良いでしょう。

 少なくともE2Eテストとユニットテストは分けておきましょう。ユニットテストはコーディングの途中にカジュアルに回せるべきですが、E2Eテストでそれをやるのは大変です。必要に応じてそれぞれを別にテストできるようにしておけば、必要な時だけE2Eテストを走らせることができます。

5.3.1 E2Eテストの選択肢

 さて、E2Eテストにはいくつかの選択肢があります。この分野で昔から使われてきたSelenium WebDriverはさまざまなブラウザに対応できてとても強力ですが、JVM上で動くselenium-serverを必要とする為、インストール・運用の手間や実行コストが大きいという問題があります。実行すると最低でも数十秒、長くて数分単位で時間がかかるからです。

 WebDriverを使う場合、JS側のライブラリで何を使うかによってもテストのコードの書きやすさなどが多少変わります。

 Phantom.jsはWebKit(Safariのブラウザエンジン)を内蔵し、画面出力を仮想バッファにすることで画面を表示させずにブラウザを起動して制御する、ヘッドレスブラウザといわれる物です。しかしPhantom.jsは不安定なイメージが強く、別のいくつかのヘッドレスブラウザも登場しています。MozillaのエンジンであるGeckoやSpiderMonkeyを使ったSlimerJSやElectronを使ったNightmare8などもあります。

 もっと軽量なアプローチとして、ウェブブラウザのテストではないですがjsdomというものがあります。これはSSRによく使われる技術でもあるのですが、DOMの仕様にしたがって挙動をエミュレートする軽量のライブラリです。コンポーネントのユニットテスト9に使うことができます。ただ、難点としてjsdomはあくまでDOMの機能をエミュレーションするだけであるため、ユーザーの想定入力は自前でJavaScriptを使ったエミュレーションをしなければなりません。この点がちょっと敷居の高いところです。Reactのテストで使われるEnzymeはここをうまくサポートしてくれます。

 さて、これらの選択肢からどう選べばいいのでしょうか?クロスブラウザを含めてガチガチにテストするならWebDriverということになるでしょう。コストが高いですが採用事例や資料も多いです。

 ただし、現代においてはウェブブラウザの大半がWebKitやそのフォークであるBlinkに集約されていることと、昔は独自規格路線だったMicrosoftもいまでは標準規格を重要視するようになったため、ブラウザごとの挙動の違いが問題になりにくいのです。古いバージョンのInternet Explorerをサポートしないならば、徹底的なクロスブラウザテストをする必要はあまりないでしょう。

 ちなみにどれを選んだとしても、jsdom以外であればテストのコードは大体同じような考え方です。ユーザーのUI操作を抽象化してメソッドという形で操作のシナリオを記述して得られた結果を判定するというのが大筋です。

 プロダクトの考え方次第ではありますが、Reactの開発であればコンポーネント単位のユニットテストを Enzymeを使ってテストを行い、E2EテストはEnzymeで確認しきれない時に対応、よくあるシナリオやどうしても気になるシナリオなどでの結合テストとして書いておけばよいでしょう。

5.3.2 Nightmare

 NightmareはElectronを使ったテストツールです。実行時間はどうしてもjsdomより遙かに必要になるためユニットテストには不適ですが、WebDriverよりは気軽に、実際のChrome/Chromiumと同じ挙動をテストできるため、E2Eテストに良い選択肢です。

$ npm install nightmare -D 

リスト5.10: nightmareによるコード

 1: const Nightmare = require('nightmare')
 2: const nightmare = Nightmare({show: true})
 3: 
 4: nightmare
 5:   .goto('http://qiita.com/search')
 6:   .type('input#q', 'electron')
 7:   .type('input#q', '\u000d')
 8:   .click('h1.searchResult_itemTitle > a')
 9:   .evaluate(() => document.querySelector('h1').innerHTML)
10:   .end()
11:   .then(result => console.log(result))
12:   .catch(err => console.error('Error:', err)) 

 このコードを走らせると画面にウェブブラウザに似たウィンドウが表示され、プログラマの技術情報サイトであるQiitaが表示されると思います。そこからはElectronの文字が入力されたかと思うと画面遷移をしてからウィンドウを閉じて、コンソールにQiitaでElectronを検索した時にヒットした記事のタイトルがコンソールに表示されたと思います10

 2行目の{show:true}はブラウザ画面を表示するかどうかです。実行時間はほとんど変わらないはずなので表示しておくといいでしょう(CIサーバーなどで動かすときはfalseにしましょう)。

 あとはメソッド名で大体想像が付くとは思いますが、gotoメソッドで指定したQiitaの検索画面のURLにアクセスして、electronという文字をタイプしてからEnterである\u000dを送信します。これで検索結果の画面に遷移するので今度は、DOMのクエリセレクタで検索結果の最初のリンクに飛びます。そして出てくる記事のタイトルを取得してウィンドウを閉じてnightmareとしての処理は終了です。この一連の処理に続けてPromiseのthen, catchで後の判定を行います。今回のサンプルではresult => console.log(result)しているだけですが、実際のテストでは、assertを書くことになるでしょう。

テストハーネスとレガシーコード改善

 レガシーコードというのは「レガシーコード改善ガイド」という書籍で提唱されている概念で、テストが無いコードのことです。テストが無いせいで変更を加えたときに動作確認の手間が増えてしまうのです。テストの利点はいくつもあります。たとえばリファクタリングをする時に動作に変更を加えてないかを保証してくれますし、しっかりと作ったテストは動く仕様書としての意味合いもあります。人間の力で頑張ってテストをするよりも、色々な問題を簡単にすることができるのです。

 さて、レガシーコードをメンテナンスしなければならない時はどうすればいいのでしょうか?動作の破壊的変更を覚悟して、恐怖と戦いながらメンテナンスをするのでしょうか?そういったことは最小限したいものです。そこで使うのがテストハーネスという技法です。思いつくさまざまなシナリオ、特に人間が手で確認していたようなものを、E2Eテストでまず書いてみましょう。E2Eテストは重たいテストですがテストハーネスにおいてはとても重要です。

 この範囲を保証すれば大丈夫だ、というE2Eテストができあがったら次の工程です。E2Eテストを命綱(だからテストハーネスです)としたうえでコードを修正しましょう。できればもちろんユニットテストが欲しいのですが、レガシーコードの場合、ユニットテストに適合しない設計やコードになっていることも多いのです。状況によってどうするべきかは大きく変わりますのでここではあまり深くは述べませんが、大きく複合的な物を少しずつ分解する、あるいは細かすぎて意味を失ったものを結合して意味のある単位に変更するというのが目安になると思います。そうやって手を加えながら少しずつ秩序ある領域を増やしていくのです。

 秩序が回復し始めると、少しずつユニットテストも増えていくでしょう。そうすればいつかはレガシーからの脱却ができるはずです。ただし、レガシーコードを改善するのが本当にベストかどうかは考える必要があります。そのコードのもつ価値と、レガシー改善のコスト、新しく作り直した場合のコスト、それぞれのバランスを見極める必要があります。



1. とはいえ、ユニットテスト至上になってしまうのも問題です。テストがカバーできる比率をカバレッジといいますが、カバレッジはある一定まで上げると、それ以上を上げるのが困難になります。そのため、ユニットテストでは完璧を目指さず、プロダクトの特性に合わせた分量のテストを書くことが重要です。

2. https://mochajs.org/

3. https://github.com/avajs/ava

4. ブラウザ上でも動きます。特にNode.jsでテストできなかったような時代ではブラウザ上で動く必要がありました。現代においてもE2Eテストの一環でブラウザ上で動かすこともあります。

5. https://github.com/power-assert-js/power-assert

6. 別のディレクトリに置く、たとえばソース本体はsrc/hello.jsとしてテストコードをtest/hello.jsとするような流儀もありますが、require/importが面倒になるのでこのやり方の方がお勧めです。

7. APIのようなバックエンドも含めたものにするのが本来のE2Eテストですが、APIをたとえばモックサーバーを使うなどの場合もここではE2Eテストと呼びます。

8. https://github.com/segmentio/nightmare

9. 人によってはユニットテストと呼ばずUIテストと呼ぶ人もいるようです。

10. ネットワークが不調だったりQiitaのサーバーに障害があったり、本書執筆時からQiitaのDOM構造が変化すると動かない・エラーがでることもある点にご注意ください。

高校生のときにパソ通にハマリ、その後紆余曲折を経てテキストエディタやMSXエミュレータその他を開発。技術者として勤務した後、現在はフリーランスエンジニア。技術同人誌や技術ブログやマッハ新書などを書いている。新しい技術に目が無い。アルゴリズム大好き。著書に『最新JavaScript開発~ES2017対応モダンプログラミング』(インプレスR&D)など。

連載バックナンバー

開発言語
第5回

ユニットテストをしよう

2018/6/11
【最新JavaScript開発~ES2017対応モダンプログラミング】 株式会社インプレスR&Dより発行された「最新JavaScript開発~ES2017対応モダンプログラミング」の立ち読みコーナー第5回です。
開発言語書籍・書評
第4回

型の恩恵をうける

2018/6/5
【最新JavaScript開発~ES2017対応モダンプログラミング】 株式会社インプレスR&Dより発行された「最新JavaScript開発~ES2017対応モダンプログラミング」の立ち読みコーナー第4回です。
開発言語書籍・書評
第3回

ECMAScript

2018/5/29
【最新JavaScript開発~ES2017対応モダンプログラミング】 株式会社インプレスR&Dより発行された「最新JavaScript開発~ES2017対応モダンプログラミング」の立ち読みコーナー第3回です。

Think IT会員サービス無料登録受付中

Think ITでは、より付加価値の高いコンテンツを会員サービスとして提供しています。会員登録を済ませてThink ITのWebサイトにログインすることでさまざまな限定特典を入手できるようになります。

Think IT会員サービスの概要とメリットをチェック

他にもこの記事が読まれています