はじめに
みなさん、テストは書いていますか?
私は今までいくつかのプロジェクトに参画してきましたが、テストコードがしっかり書かれているところはありませんでした。 実際、私も最近になってテストコードのメリットをだいぶ感じられるようになったので、書けるところから実践している状況です。
では、なぜ書いていないのか、書けないのか。原因は大きく分けて3つあります。
- スケジュール
- 単発
- ノウハウ
スケジュール
実際、テストコードを書くとなると、コストはかかります。主に受託開発などではウォーターフォール型で進めることも多く、 テストコードを実装する工数がほぼないのが現状です。工数を見積もる人がテストコードの重要性をどれだけ認識しているかにもよります。
あとは、テストコードではなく、納品物として単体テスト仕様書などのドキュメントを作成する場合もあります。この場合、テストコードも書くと二重管理になってしまうので、 テストコードを無理して書く必要はないと思います。
単発
サービスの拡張などがほぼない場合は、テストコードを書くメリットはあまりありません。 もちろん、あるに越したことはありませんが、一刻も早いリリースを優先するならテスト書かないのもありだと思います。
ノウハウ
テストの書き方がわからない、またはロジックが複雑すぎてどう書けばいいかわからないなどです。 もし、スケジュールに余裕があり、今後もサービスを成長させていくのなら、ぜひ始めるべきです。
前者については、勉強してください。プログラム言語ごとにユニットテストツールは用意されているので、 公式などを参照するなり、習得してください。ちなみに私が使っている言語はJavaなので、JUnitを使っています。
後者について、苦労して複雑なプロダクトコードに対してテストを書くことは非効率です。 最初はE2Eのテストを行いながらリファクタリンでもいいので、時間を書けて綺麗なテストコード、プロダクトコードを書きましょう。 今回は、テストが書きづらいコードに着目し、どのようにしてテストコードを書いていくのか順を追って説明します。
記事内の具体例として説明するコードはJavaですので、他の言語やWAFを利用している方は置き換えて読んでください。
テスト対象コード
では、今回テストコードを書いていくプロダクトコードです。 そこまで複雑ではありませんが、多少書きづらい内容となっています。
public class UserService { // 今月20歳になるユーザ情報を取得する public List<User> getThisMonthBirthdayUser(){ // 月初日付取得 LocalDate monthBegin = LocalDate.now().withDayOfMonth(1); // 月末日付を取得 LocalDate monthEnd = LocalDate.of(monthBegin.getYear(),monthBegin.getMonthValue(),monthBegin.lengthOfMonth()); UserRepository userRepository = new UserRepository(); // 今月誕生日のユーザを取得 List<User> users = userRepository.findByBirthdayBetween(monthBegin,monthEnd); List<User> adultUsers = new ArrayList<>(); // 20歳のユーザのみに絞り込む for(User user : users){ int age = ChronoUnit.YEARS.between(user.getBirthday(), monthEnd); if(age == 20){ adultUsers.add(user); } } return adultUsers; } }
上記のメソッドで利用しているUser
クラス(Lombok利用を想定)
@Getter @Setter public class User { private String name; private LocalDate birthday; }
テストが書きづらい理由として、2つあります。
- 現在日付の取得
- DBへのアクセス
ではなぜ、これらが書きづらい理由になるのか簡単に説明します。
現在日付の取得
コード内で利用しているLocalDate.now()
ですが、これはシステム日付(実行マシンに影響するもの)を取得するので、
今日実行するのと、翌日実行するのとでは取得できる値が変わりますので、同様にテストも、今日成功したけど、翌日失敗なんてことになります。
実際、LocalDateのモックを作ればテストコードは書けなくはないですが、Javaの標準APIなのでモックまでは作ることはおすすめしません。
DBへのアクセス
この例だと、テストするたびにDBからデータを取得する必要があるので、実行ごとにテストデータを登録する必要があり、時間がかかります。 しかも、テストが終わった後は初期化しないと他のテストに影響を及ぼす可能性もあります。 もちろん、UserRepositoryのメソッドのテストとしては、直接DBに接続して行う必要はありますが、 今回は、DBはあまり関係なく、UserRepositoryから値が取得できればいいわけです。
改善(リファクタリング)
先ほどのプロダクトコードに対するテストを書きやすくするために、以下のような方法が考えられます。
- 依存クラスのインターフェース作成
- DI(依存性注入)の利用
- 副作用の排除(ケースバイケースなので必須ではない)
プロダクトコードを改善しつつ順を追って見ていきます。
1. 依存クラスのインターフェース作成
UserRepositoryというクラスのインターフェースIUserRepositoryを作成します。
public interface IUserRepository { public List<User> findByBirthdayBetween(LocalDate monthBegin,LocalDate monthEnd); }
UserRepositoryに上記のインターフェースを指定します。
public class UserRepository implements IUserRepository { public List<User> findByBirthdayBetween(LocalDate monthBegin,LocalDate monthEnd){ 〜DB取得処理〜 } }
2. DI利用
Javaで最も利用されているSpringなどのフレームワークにはDI(依存性注入)という機能があるので、これを利用します。 DIの説明については割愛しますが、メリットとしては主に以下があります。
・テストが容易 ・コンピュータリソースの節約
今回はフレームワークに依存した例ではないので、単純に依存クラスをコンストラクタで受け取るようにしたいと思います。
public class UserService { private IUserRepository userRepository; public UserService(IUserRepository userRepository){ this.userRepository = userRepository; } public List<User> getThisMonthBirthdayUser(){ // 月初日付取得 LocalDate monthBegin = LocalDate.now().withDayOfMonth(1); // 月末日付を取得 LocalDate monthEnd = LocalDate.of(monthBegin.getYear(),monthBegin.getMonthValue(),monthBegin.lengthOfMonth()); // 今月誕生日のユーザを取得 List<User> users = userRepository.findByBirthdayBetween(monthBegin,monthEnd); List<User> adultUsers = new ArrayList<>(); // 20歳のユーザのみに絞り込む for(User user : users){ int age = ChronoUnit.YEARS.between(user.getBirthday(), monthEnd); if(age == 20){ adultUsers.add(user); } } return adultUsers; } }
フレームワーク側で、インターフェースを実装したクラスのインスタンスを自動で代入してくれます。
3. 副作用の排除
副作用とは、簡単に言うと「オブジェクトが変化」することです。
今回の例だと、 LocalDate.now()
にあたります。
メソッド内で取得するのではなく、引数で受け取るようにしましょう。
public class UserService { private IUserRepository userRepository; public UserService(IUserRepository userRepository){ this.userRepository = userRepository; } public List<User> getThisMonthBirthdayUser(LocalDate today){ // 月初日付を取得 LocalDate monthBegin = today.withDayOfMonth(1); // 月末日付を取得 LocalDate monthEnd = LocalDate.of(monthBegin.getYear(),monthBegin.getMonthValue(),monthBegin.lengthOfMonth()); // 今月誕生日のユーザを取得 List<User> users = userRepository.findByBirthdayBetween(monthBegin,monthEnd); List<User> adultUsers = new ArrayList<>(); // 20歳のユーザのみに絞り込む for(User user : users){ int age = ChronoUnit.YEARS.between(user.getBirthday(), monthEnd); if(age == 20){ adultUsers.add(user); } } return adultUsers; } }
4. おまけ
テストコードの書きづらさという観点ではありませんが、プロダクトコードをシンプルに保つ意味で以下のようなリファクタリングを行います。
List<User> adultUsers = new ArrayList<>(); // 20歳のユーザのみに絞り込む for(User user : users){ int age = ChronoUnit.YEARS.between(user.getBirthday(), monthEnd); if(age == 20){ adultUsers.add(user); } } return adultUsers;
このコードは以下のようにワンライナーで書けます。
return users.parallelStream().filter(user -> ChronoUnit.YEARS.between(user.getBirthday(), monthEnd) == 20).collect(Collectors.toList());
テスト
プロダクトコードがテストが書きやすい状態になったので、実際にテストコードを書いてみます。
まず、準備として、IUserRepositoryを実装したテスト用のクラスを作ります。
public class UserInMemoryRepository implements IUserRepository{ public List<User> findByBirthdayBetween(LocalDate monthBegin,LocalDate monthEnd){ List<User> users = new ArrayList<>(); // 20歳以外を想定 User user1 = new User(); user1.setName("太郎"); user1.setBirthday(LocalDate.of(1980,1,1)); // 20歳を想定 User user2 = new User(); user2.setName("二郎"); user2.setBirthday(LocalDate.of(2000,1,1)); users.add(user1); users.add(user2); return users; } }
続いて、実際のテストコードです。
public void test(){ // 上記で作成したインメモリで操作可能なUserRepositoryのインスタンスを渡す UserService userService = new UserService(new UserInMemoryRepository()); // 二郎が今月20歳になる日付を引数で渡す List<User> users = userService.getThisMonthBirthdayUser(LocalDate.of(2020,1,1)); assetThat(users.size(),is(1)); assetThat(users.get(1).getName(),is("二郎")); }
これくらいであれば、書ける気しませんか?
おわりに
今回挙げた例の他にも、外部サービスに依存しているものや、帳票やCSVなどのファイル出力など、テストが書きづらいパターンもあります。 ただ、考えることは同じで、そのメソッドが行うべき処理(責任範囲)を見極めることです。それ以外の処理はインターフェースを用意したり、モックを利用します。
サービス提供のスピードが要求される昨今、テストコードは必要不可欠です。 もし、現在テストがなく今後サービスを成長させていくのであれば、少しづつでもテストを書いていきましょう。
「テストコードは、品質をあげるものではなく維持するもの」