関数型プログラミングとテスト。個別に試したことがあっても、両方を作業に取り入れた経験はないと思います。単独では無害ですが、組み合わせると耐えがたい誘惑を生み出します。その魅力にとつかれたら、より簡潔で、無駄がなく、メンテナンス性のあるコードを書かずにはいられなります。それほどのメリットがあるのです。
本記事では、関数型JavaScriptのテストの原則を紹介します。フレームワークJasmineを使って、テスト駆動開発で純粋関数を開発する方法を説明していきます。
テストが必要な理由
テストはアプリケーションのコードが期待どおりに動くこと、コードに変更を加えたあとコードが期待通りに動くことを確認して、正しく動作する製品を作ります。テストは所定の条件で発生する機能を定義します。コードに対してテストを実行し、期待通りの結果にならなければ警告を通知します。コードを修正して警告をなくします。
エラーのない製品を作り、報酬をもらいます。そして、幸せな気持ちになれます。
テストにはざっくりわけると次の種類があります。
- 単体テスト:個々のコードの機能を確認する
- 統合テスト:データフローやコンポーネント間のやりとりを確認する
- 機能テスト:アプリケーション全体の動作を確認する
注記:機能テスト(functional testing)は本記事で扱う関数型JavaScriptのテスト(testing functional JavaScript)とは別のテストです。機能テストはアプリケーション全体の動作を確認するためのテストです。関数型プログラミングが本当に役立つのは、単体テストです。
コーディングのどの時点でテストを書いても良いですが、個人的には関数を書く前にその関数の単体テストを書くのが効率が良いと感じます。テスト駆動開発(TDD:test-driven development)では、コードを書く前にアプリケーションの機能を細分化し、コードの各セクションでどんな結果を得たいかを定め、先にテストを書いて、そのあとに求める結果を実現するためのコードを書きます。
TDDのテストは、1つパスすることは簡単なのですが、考え得るすべての項目の扱いを決めることや、不具合を起こさずにすべて処理する方法を考えることが大変です。書いているコードが顧客の求めているものと一致しているか確認するため、顧客と詳しく話す機会が増るメリットがあります。
関数型を使う理由
コードの書き方とテストのしやすさは比例します。ある関数の動作と別の関数の動作を密結合させるコードパターンや、グローバル変数に大きく依存するコードパターンでは、単体テストは困難です。場合によっては、テスト可能なパラメーターや結果を取得するために外部データベースの動作を「モックアップ」したり、複雑なランタイム環境をシミュレーションしたりと面倒な手法が必要です。たいていの場合、特定のコードを分離することでより簡単にテストできます。
関数型プログラミングではアプリケーション内のデータと動作を別々に扱います。単独で動作し、外部状態に依存しない関数群を作ってアプリケーションを構築すると、コンパクトで、動作が分かりやすく定義が明確な一貫性のある関数による、自己文書化(self-documenting)されたコードができます。
関数型プログラミングは命令型プログラミングやオブジェクト指向型プログラミングと対比されますが、組み合わせることもできます。JavaScriptはこれらをすべてサポートしています。命令型プログラミングは、命令コードのシーケンスを作成し、結果が返されるまでの複数のステップにおけるアプリケーションの状態を監視します。オブジェクト指向型プログラミングは、特定のデータ構造に適用するメソッドをすべてカプセル化した複雑なオブジェクトを組み合わせてアプリケーションを構築します。関数型プログラミングは、こうした手法に代わる選択肢です。
純粋関数について
関数型プログラミングは1つの決まった動作を実行します。入力が同じなら必ず同じ値を返します。コンパクトで再利用可能、コンポーザブルな関数でアプリケーションを作ります。関数型プログラミングの基礎である関数を純粋関数と呼びます。純粋関数は次の3つの特性を持ちます。
- 外部の状態や変数に依存しない
- 副作用がなく、外部の変数の値を変えない
- 同じ入力には必ず同じ結果を返す
関数型のコードは、単体テストがとても簡単になるメリットがあります。多くのコードを単体テストすれば、将来コードをリファクタリングする際に重要な機能を壊してしまう可能性が減り、確実な作業ができます。
なぜ関数型のコードはテストが簡単なのか?
上述のコンセプトから、関数型コードのテストが簡単な理由が見えてくると思います。純粋関数のテストは、入力に必ず対応する出力があります。必要なのは、動作を定義し、コードが定義通りに動くかテストするだけです。テストの条件を整えたり、関数内部の依存オブジェクトを監視したり、関数外部の状態遷移をシミュレーションしたり、変数を使って外部データソースをモックアップしたりする必要はありません。
テストには本格的なフレームワークからユーティリティライブラリー、単純なテストハーネスに至るまで多くの選択肢が用意されています。たとえば、Jasmine、Mocha、Enzyme、Jestなどがあります。それぞれに異なる長所と短所、最適な用途があり、忠実なフォロワーもいます。Jasmineはさまざまな状況で使用できる堅牢なフレームワークです。JasmineとTDDを使ってブラウザー上で純粋関数を開発する方法を簡単に解説します。
JasmingのテストライブラリーをローカルまたはCDNから読み込むHTMLドキュメントを作成します。Jasmineライブラリーとテストランナーを含むページの例です。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Jasmine Test</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.6.1/jasmine.min.css">
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.6.1/jasmine.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.6.1/jasmine-html.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.6.1/boot.min.js"></script>
</body>
</html>
Jasmineライブラリー、HTML、bootスクリプト、スタイルシートを読み込みます。例ではドキュメントのボディが空なので、テスト対象のJavaScriptとJasmineのテストをあとで追加します。
関数型JavaScriptのテスト:最初のテスト
最初のテストを書きます。テストを書くのは別のドキュメントでも、ページの<script>要素内でも大丈夫です。Jasmineライブラリーのdescribe関数で、新規作成する関数の動作を定義します。
関数isPalindromeを作成し、渡した文字列が前から読んでも後ろから読んでも同じ場合はtrueを、そうでない場合はfalseを返します。テストは以下の通りです。
describe("isPalindrome", () => {
it("returns true if the string is a palindrome", () => {
expect(isPalindrome("abba")).toEqual(true);
});
});
上記のテストをページのスクリプトに追加し、ページをブラウザーで読み込むと、Jasmineのレポートページがエラーを表示します。テストを実行して失敗になることを確認するのが目的なので、エラーで構いません。エラーにより修正が必要だと分かります。
テストをパスするための最低限のロジックで、単純なJavaScript関数を書きます。今回は期待値を返すことで1つのテストをパスする関数です。
const isPalindrome = (str) => true;
これで大丈夫です。
テストをもう一度実行すると当然ですがパスできます。このコードの機能はテストにパスするための最低限のコードであり、回文を評価できるコードではありません。関数の動作をさらに定義します。describe関数にアサーションを1つ追加します。
describe("isPalindrome", () => {
it("returns true if the string is a palindrome", () => {
expect(isPalindrome("abba")).toEqual(true);
});
it("returns false if the string isn't a palindrome", () => {
expect(isPalindrome("Bubba")).toEqual(false);
});
});
ページを再読み込みすると、エラーの内容を示すメッセージが表示され、テスト結果が赤色に変わります。
赤い色で問題があることを脳に伝えます。
毎回trueを返す単純なisPalindrome関数では新たなテストをパスできません。そこでisPalindromeを更新し、与えられた文字列を前から読んだ場合と後ろから読んだ場合を比較します。
const isPalindrome = (str) => {
return str
.split("")
.reverse()
.join("") === str;
};
テストには中毒性がある
緑になりました。気分がいいですね。ドーパミンが分泌される感覚を味わえましたか?
新たなコードは文字列を前から読んだ場合と後ろから読んだ場合を比較し、同じになっていればtrueを返し、そうでなければfalseを返します。この変更でテストをパスしました。
このコードは純粋関数です。たった1つの動作を実行し、同じ入力値に対する動作は常に同じで、副作用がなく、関数外部の変数に影響を与えず、アプリケーションの状態に依存しません。関数に文字列を渡せば、呼び出すタイミングや呼び出し方にかかわらず、文字列を前から読んだ場合と後ろから読んだ場合を比較して結果を返します。
一貫性で関数の単体テストが簡単になると理解できたと思います。純粋関数はテストや修正がとても簡単なので、テスト駆動開発では純粋関数の作成を促します。そろそろ、テストをパスして満足感を得たい誘惑があるでしょう。
純粋関数のリファクタリング
文字列以外の入力に対する処理や、大文字と小文字の違いを無視する処理などの追加機能の作成も簡単です。クライアントに希望の動作を確認しましょう。エラーチェックの追加や文字列への変換といった文字列以外の値に対する動作も可能です。
たとえば、1001の数値を検証するテストを追加します。文字列なら、回文だと判断されます。
describe("isPalindrome", () => {
it("returns true if the string is a palindrome", () => {
expect(isPalindrome("abba")).toEqual(true);
});
it("returns false if the string isn't a palindrome", () => {
expect(isPalindrome("Bubba")).toEqual(false);
});
it("returns true if a number is a palindrome", () => {
expect(isPalindrome(1001)).toEqual(true);
});
});
赤色の画面が表示され、再びテストに失敗します。現状のisPalindrome関数は文字列以外の入力を処理できません。
コードを更新し、文字列以外の入力を文字列に変換してから回文を確認します。
const isPalindrome = (str) => {
return str
.toString()
.split("")
.reverse()
.join("") === str.toString();
};
テストをすべてパスし、緑色の画面が表示されます。テスト駆動脳がドーパミンで満たされます。
評価用のメソッドチェインにtoString()を追加して、文字列以外の入力を文字列に変換しテストできました。ほかのテストも毎回実行されるので、新たな機能を作成したことで以前に作成した機能が壊れていないかを確認できます。以下が最終的な成果物です。
このテストに少し触れてから、Jasmineや好きなテストライブラリーを使って自分自身でテストを書いてみてください。
コードデザインのワークフローにテストを取り入れ、純粋関数を書き、その単体テストを実行する経験を一度でも味わうと、昔のやり方には戻れなくなります。もっとも、戻りたくなることもありませんが。
本記事はVildan Softicが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
(原文:A Beginner’s Guide to Testing Functional JavaScript)
[翻訳:薮田佳佑/編集:Livit]