WHIの山﨑です。
私は2022年の1月にWHIに転職したのですが、転職して早々に不具合を発生させてしまったことで、自動テストを書くようになったので、その時の失敗談と学びについてまとめてみました。
「この人みたいな失敗をしないように、自動テスト書くようにしよう。」と思ってもらえれば幸いです。
ぬるぽとの出会い
私は前職ではPythonを利用していたので、もちろんぬるぽなんぞ意識せずに開発をしてきました。
しかし、WHIではJavaをメインで利用しています。
慣れない言語、特にオブジェクト指向と静的な型付けに苦労しつつも開発を行っていました。
そんなある日のこと、自分の修正したプログラムで不具合が発生しました。
内容としては、以下の通りです。
ClassA a = variable.getAItem();
ClassB b = a.getBItem();
上記のようなコードがあったとします。(もちろん、業務コードはちゃんとした命名がされています。)
基本的には、varibale.getAItem()
からは必ずClassAの値が帰ってきます。
データが設定されていないとしてもです。
ですが、特殊な条件下かつ、データが設定されていない場合、variable.getAItem()
からnullが返ってきます。
よって、a.getBItem()
の段階でぬるぽが発生するわけです。
Pythonで開発していた頃は、関数型プログラミングに近い実装をしていたため、nullが入ると思われる変数のメソッドを呼ぶことなんてなかったので、意識できていなかったのです。
variable.getAItem()
でどんな場合であっても、nullを返さないように修正すればいいのですが、nullを返すことで呼び出し元で条件分岐を行っているメソッドがある可能性があるので、根幹に近いクラスであればあるほど気軽には修正できません。
今回の場合は、a.getBItem()
を実行する前に、aがnullでないことの確認(いわゆるnullチェック)をすることで回避することにしました。
JUnitを使ってみる
今回は不具合が発生してしまいましたが、できれば、不具合が発生する前に検知したい…
でも、通常の条件下ではnullが返ってこない…
そこで、JUnitを使ってテストできないか調べてみました。
JUnitとはJavaで開発されたユニットテストの自動化を行うためのフレームワークである。
Wikipediaと記載があるようにJavaで使えるフレームワークなのですが、ユニットテストってなんやねん。という方にユニットテストの説明を。
ユニットテスト(単体テスト)はプログラムの最小単位の部品をテストすることです。
一方、複数のプログラムの組み合わせた際の動作についてテストする結合テストがあります。
プログラムは形のないものなので、何をもって最小単位とするのかは人によって微妙に異なります。
なので、一旦、複数のプログラムを組み合わせた状態で行うテストを結合テスト、それ以下の単位で行うテストをユニットテストと理解しておくといいと思います。
JUnitではJavaで書かれたプログラムのユニットテストを自動化できます。
例えば、受け取った整数を倍にして返すメソッド
public static int getDouble (int arg){
return arg * 2;
}
これに対して、以下のようなテストを記載できます。
@Test
public void getDoubleTest() {
assertEquals(4, main.getDouble(2));
}
getDouble()
に2を渡すと4が返ってくるはずです。
そこで4とmain.getDouble(2)
が等しいかを確認しています。
もちろん今回の場合はテストをクリアできます。
仮に以下のように書き換えると、
@Test
public void getDoubleTest() {
assertEquals(3, main.getDouble(2));
}
テストに失敗します。
というように、簡単なプログラムだったり、テストがしやすい関数型の場合は話は簡単なんです。
わかります。
「こんなに簡単にテストが書けるようなプログラムじゃないんだ。」
「オブジェクト指向で書かれてて、様々なクラスが絡み合っててテストなんかできねーよ。」
そんな声が聞こえてきそうです。
そんなときに役に立つのがmockやspyです。
以下のコードで試します。
public static ClassB getTargetClassB(ArgClass arg) {
return arg.getAItem().getBItem();
}
このメソッドがちゃんと動くかは以下のようなテストコードで確認できます。
@Test
public void getTargetClassBTest() {
// ArgClassのモックを作成
ArgClass argClassMock = mock(ArgClass.class);
// ClassAのモックを作成
ClassA aMock = mock(ClassA.class);
// ArgClassモックのメソッドgetAItem()でClassAのモックを返すように設定
when(argClassMock.getAItem()).thenReturn(aMock);
// ClassBのモックを作成
ClassB bMock = mock(ClassB.class);
// ClassAモックのメソッドgetBItem()でclassBのモックを返すように設定
when(aMock.getBItem()).thenReturn(bMock);
// ClassBモックのメソッドgetName()で指定した文字列を返すように設定
when(bMock.getName()).thenReturn("classB01");
ClassB b = main.getTargetClassB(argClassMock);
assertEquals("classB01", b.getName());
}
渡したargClassMockからbMockが取得でき、設定した名前が返ってきてることがわかります。
JUnitを使えば、処理が正しく動くかを担保できます。(厳密に確認するには、ClassBのすべての変数を比較するメソッドを作成する必要がありますが…)
また、モックをうまく使いこなせば、ある程度複雑なメソッドのテストができることがわかるかと思います。
私なりの学び
JUnitを使えば処理が正しく動くかを確認することができました。
(でも、これでは不具合の検知はできないよな…)
(仮に、getAItem()
でnullが返ってきたらどうするんだ…)
ということで、getAItem()
がnullを返すように設定してみます。
@Test
public void getTargetClassBTest_A_Is_Null() {
// ArgClassのモックを作成
ArgClass argClassMock = mock(ArgClass.class);
// ArgClassモックのメソッドgetAItem()でnullを返すように設定
when(argClassMock.getAItem()).thenReturn(null);
ClassB b = main.getTargetClassB(argClassMock);
assertEquals("classB01", b.getName());
}
すると、getAItem()
でnullが返ってくるため、getBItem()
の時点でぬるぽが発生します。
そこで、コードを以下のように書き換えた方がいいことがわかります。
public static ClassB getTargetClassB(ArgClass arg) {
ClassA a = arg.getAItem();
if(a == null) {
return new ClassB(0, "classB-not-set", "設定されていません。");
}
return a.getBItem();
}
}
そして、テストはクリアする形に書き換えます。
@Test
public void getTargetClassBTest_A_Is_Null() {
ArgClass argClassMock = mock(ArgClass.class);
when(argClassMock.getAItem()).thenReturn(null);
ClassB b = main.getTargetClassB(argClassMock);
assertEquals("classB-not-set", b.getName());
}
次に、getBItem()
でnullが返ってきたときにどのような処理をするのか確かめてみます。
@Test
public void getTargetClassBTest_B_Is_Null() {
// ArgClassのモックを作成
ArgClass argClassMock = mock(ArgClass.class);
// ArgClassモックのメソッドgetAItem()でnullを返すように設定
when(argClassMock.getAItem()).thenReturn(null);
// ClassBのモックを作成
ClassA aMock = mock(ClassA.class);
// ArgClassモックのメソッドgetAItem()でClassAのモックを返すように設定
when(argClassMock.getAItem()).thenReturn(aMock);
// ClassAモックのメソッドgetBItem()でclassBのモックを返すように設定
when(aMock.getBItem()).thenReturn(null);
ClassB b = main.getTargetClassB(argClassMock);
assertEquals(null, b);
}
nullが返ってきていることが確認できました。
(でも、変数にnullを入れたくないから、nullを入れて欲しくない…)
ということで、ClassAがnullだったときと同じようにClassBが返ってくるように修正します。
public static ClassB getTargetClassB(ArgClass arg) {
ClassA a = arg.getAItem();
if(a == null) {
return new ClassB(0, "classB-not-set", "設定されていません。");
}
return a.getBItem() == null ? new ClassB(0, "classB-not-set", "設定されていません。") : a.getBItem();
}
そして、テストはクリアする形に書き換えます。
@Test
public void getTargetClassBTest_B_Is_Null() {
// ArgClassのモックを作成
ArgClass argClassMock = mock(ArgClass.class);
// ArgClassモックのメソッドgetAItem()でnullを返すように設定
when(argClassMock.getAItem()).thenReturn(null);
// ClassBのモックを作成
ClassA aMock = mock(ClassA.class);
// ArgClassモックのメソッドgetAItem()でClassAのモックを返すように設定
when(argClassMock.getAItem()).thenReturn(aMock);
// ClassAモックのメソッドgetBItem()でclassBのモックを返すように設定
when(aMock.getBItem()).thenReturn(null);
ClassB b = main.getTargetClassB(argClassMock);
assertEquals("classB-not-set", b.getName());
}
この手順だけでも、ぬるぽに強いメソッドに書き換えれたと思います。
ここまで見てもらったらわかるように、自動テストでは正しく動くかだけではなく、エラーが発生するようにテストを作成して、想定した挙動になるかを確認するという使い方もできます。
また、それを応用して、リファクタリングにも活用できることがわかってもらえたと思います。
まとめ
1. 言語によっては、ユニットテストを自動化できるフレームワークが用意されてます。(JavaではJUnit、JavaScriptではJest、Pythonだとpytestなど)
2. モックを使えば、思っている以上に複雑なメソッドに対応できます。
3. 正しく動くかはもちろん、エラーが出たときに適切に処理してくれるかというテストもできます。
若手の皆さんへ
私は失敗と自動テストとの出会いを経てから、自分が作成および修正を行ったメソッドについては意識して自動テストを書くようになりました。
皆さんはぜひ最初から積極的に自動テストを書くようにして、私と同じような失敗はしないでください。