この記事は、Techouse Advent Calendar 2024 23日目です。
昨日は codingalone さんによる Amazon Neptune を人事労務 SaaS へ実戦投入してみた感想 - RDB との比較 でした。
はじめに
はじめまして。現在クラウドハウス労務でバックエンドエンジニアとして長期インターンをしているMatsu-Nobuです。
最近関わった機能開発において、JavaScriptを使ってフロントエンドのコードを書く機会がありました。
本記事では、そのJavaScriptコードの単体テストを書く際に苦戦した理由と、テストを書きやすくするために行った工夫を書いていきます。
JavaScriptのテストフレームワーク: Jest
Jestは、JavaScriptのテストフレームワークです。
クラウドハウス労務では今年からJestが導入され、新規に開発された機能については単体テストが書かれていますし、インターン生を中心にテストが書かれていない既存の機能のテストを書くプロジェクトが進行しています。
ソフトウェアの品質を継続的に保つためには自動テストは不可欠です。
特に、クラウドハウス労務のような短いスパンでのリリースを繰り返すSaaSプロダクトでは、新機能のリリースに伴うデグレードを回避するために自動テストは殊更重要です。
私たちのチームではテスト駆動開発が推奨されており、コードレビューの際には、テストが欠けたコードをリリースしようとすると必ず指摘を受けます。
JavaScriptのファイル構成
クラウドハウス労務ではJavaScriptモジュールバンドラとしてWebpackerを使用しています。
JavaScriptファイルは基本的には画面単位で分割されており、対応するRailsのビューが読み込まれる際に、各画面のJavaScriptファイルはその他のJavaScriptのモジュールやCSSスタイルシートなどと合わせて自動的にバンドルされて利用されます。
いっぽう、テストコードはテストコード用の別ディレクトリに配置されています。
私が先日行った機能開発では、既存の画面に新規の入力フォームを追加する必要がありました。そのため、その画面に対応するJavaScriptファイルに新規の入力フォームを制御するコードを追記しました。
続いて、テストを書こうと思ったのですが、私が追記したファイルのテストコードがまだ存在していなかったために、テストファイルを新規作成してテストコードを書くことにしました。
ところが、ここで思わぬ困難に直面します。
テストが書けない!
私が編集したファイルは以下のようなものでした。
フォームを制御する関数がchunkOfFunctions
という1つの関数の中に全てまとまっています。この内側にあるexistingFunction
やfunctionIAdded
の単体テストをしたいのに、これでは外から触ることができません。
しかも、このファイルはwebpackerによって自動でバンドルされて利用されるので、chunkOfFunctions
がエクスポートされていません。
これではそもそもテストファイルからchunkOfFunctions
にアクセスできません。
// sample_form.js const chunkOfFunctions = function() { const existingFunction = () => { // フォームの制御処理 }; const functionIAdded = () => { // フォームの制御処理 }; return { init: function() { existingFunction(); functionIAdded(); }; }; }(); jQuery(document).ready(function() { chunkOfFunctions.init(); });
テストのためにリファクタリングする
テストが書けずに困ってしまって、先輩のインターンの方や社員の方に相談したところ、「テストを書けるようにするためにリファクタリングが必要だ」と言われました。そのように言われたとき、個人的には、テストを書きやすくするために元々のコードを変える必要があるというのが意外に感じました。
そこで頂いたアドバイスや、自分で調べたことから、テストを書きやすくするためのコードの工夫を以下にまとめていきます。
テストが書きやすいコードとは
モジュールに分割する
テストを書きやすくするためには、巨大な関数やクラスを分割し、それらを別の箇所に切り出してモジュール化することが重要です。
先ほどの例に出てきたようなchunkOfFunctions
のような巨大な塊はどんどん分割して別の箇所に切り出していくとテストを書きやすくなります。
まずは塊(chunk)になっているのを分割してしまいましょう。
// sample_form.js const existingFunction = () => { // フォームの制御処理 }; const functionIAdded = () => { // フォームの制御処理 }; jQuery(document).ready(function() { existingFunction(); functionIAdded(); });
次に、分割した関数をモジュールとして別ファイルに切り出してみましょう。
// sample_form.js import {existingFunction, functionIAdded} from 'sample_form_lib' jQuery(document).ready(function() { existingFunction(); functionIAdded(); });
// sample_form_lib.js export const existingFunction = () => { // フォームの制御処理 }; export const functionIAdded = () => { // フォームの制御処理 };
このように関数を分割してモジュール化することで、existingFunction
とfunctionIAdded
の単体テストを書くことができるようになりました。
巨大な関数やクラスはしばしば、内部に複雑なロジックを持つことになります。
そのため、巨大な関数やクラスをテストしようとすると膨大な数のテストケースが必要となり、結果としてテストを書くのに必要な工数の増大や、テストの漏れによるバグの見逃しを招きます。
適切な分割とモジュール化によって、テスト対象を明確にすることができれば、テストに必要な工数が減り、テストの漏れも少なくできることが期待できます。
また、適切な分割とモジュール化には、テストを書きやすくするのみならず、モジュール強度を高め、コードの可読性と保守性を向上させるという利点もあります。
分割によって各モジュールの責務が明確になると、モジュールの役割を理解しやすくなり、可読性が向上します。
さらに、仕様の変更が発生した際に、コードの変更範囲が小さくなるために、工数の削減やデグレードの防止の効果も期待できます。
依存性の注入(Dependency Injection)を利用する
依存性の注入とは、オブジェクトが必要とする依存関係を外部から注入する設計パターンのことを指します。
テスト対象のクラスや関数が外部の要素に依存するときに、依存する要素を外部から渡すようにすると、テスト時に依存する外部要素を取り除くことができ、テスト対象のクラスや関数の特定のロジックだけを検証する単体テストが書きやすくなります。
例えば、以下のようにFetch APIを用いてAPIエンドポイントにリクエストを送り、データを取得する関数を考えます。
// apiService.js export async function fetchData(endpoint) { const response = await fetch(endpoint); if (!response.ok) { throw new Error('Network response was not ok'); } return await response.json(); }
上記のコードで、例えば「200 OK 以外のレスポンスが返ってきたときエラーを投げる」ことをテストするテストコードを書こうとすると、テスト時にfetch
の動作をモックする必要が生じてしまい、テストコードが複雑になります。
そこで、HTTPリクエストを送信するhttpClient
を引数で渡すように変更すると、以下のように書き換えられます。
// apiService.js export async function fetchData(endpoint, httpClient) { const response = await httpClient(endpoint); if (!response.ok) { throw new Error('Network response was not ok'); } return await response.json(); }
このコードで、「200 OK 以外のレスポンスが返ってきたときエラーを投げる」ことをテストするテストコードを書くと以下のようになります。
以下ではhttpClient
をモックすることでテストを簡潔に書くことができています。
import { fetchData } from 'apiService'; describe('fetchData', () => { it('should throw an error when response is not ok', async () => { const mockHttpClient = jest.fn().mockResolvedValue({ ok: false, }); await expect(fetchData('https://example.com/api', mockHttpClient)).rejects.toThrow( 'Network response was not ok' ); }); });
また、上記のようにhttpClient
を外部から渡すようにすると、単体テストが書きやすくなるだけでなく、変更に対しても強くなり、コードの保守性が高まります。
例えば、HTTPクライアントをFetch APIから別のものに変える場合に、関数内に直接fetch
が書かれている場合だとAPIを呼び出している全ての関数を書き換える必要がありますが、httpClient
を外部から渡すようにすれば、httpClient
を書き換える変更だけで済みます。
そもそも、テストが必要なロジックを書かない
テストを書きやすくするためには、そもそもテストが必要なロジックを書かないという視点を持つことも重要です。
特に、データベースへのアクセスを伴うビジネスロジックに関してはフロントエンドではなくバックエンドに書くべきです。
また、それ以外にもフロントエンドに書くロジックとして、フォームの必須要素のチェックやユーザーの入力した値のバリデーションなどが考えられますが、ユーザー体験とのトレードオフを考慮した上で、複雑なものはバックエンドでのバリデーションに任せるなど、ロジックが過剰に複雑になりすぎないようにすることもテストを書きやすくするためには重要です。
まとめ
本記事では、JavaScriptのテストコードを書くにあたって、コードがモジュール化されておらず一つの塊のようになっていたために苦戦したことや、テストを書くためにリファクタリングを行い、ファイルを分割してモジュール化したことについて書きました。
また、そのことをきっかけに、テストを書きやすいコードとはどのようなものかを調べてわかったことをまとめました。
テストを書きやすくするための工夫は他にも考えられますが、記事内で挙げたテストを書きやすくする工夫を行なったコードは、可読性や保守性が高く、テストの書きやすさ以外の観点でも「良いコード」であることが個人的には印象的でした。
今回調べて学んだことを活かして、テストによって品質が保証され、可読性や保守性も高い「良いコード」を書けるように精進していきます。
Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。