プログラムを動かしながら作る人ほどテスティングフレームワークを使ってほしい

こんにちは、KUJIRAです。
プログラムを書いていると、どうも学生時代のやり方が抜けないKUJIRAはいつもコードを少し書いては動かして確認をするというのをよくやります。プロ、アマ、学生問わず、結構プログラムを書いている人でこういうことをやっている人は多いのではないでしょうか。
しかし、このやり方、冷静に考えてみるとなんとも曖昧なやり方で、 何を持ってプログラムが完成するのかというのが全く見えない のでとてもよくないと常日頃から思っています。
特にバッチなんか作成するときには顕著で、毎回コマンド叩いてみたり、IDEで実行時のパラメータに色々設定して試したりと、とても非効率極まりない上に、入力するパラメータが抜け落ちていることにも気づかずに動作確認を行い機能が不十分なプログラムが出来上がることも多々あります。
で、こんなことを続けていたくないのでKUJIRAがよくやる方法を備忘録も兼ねて書きたいと思います。
そもそもどうして動かしながら作りたくなるのか
この衝動はどうしても抑えられないと思います。だって、 結果が目に見えるし何と言っても早い!
これが動かしながら作りたくなる大きな原因です。早いし動いているから楽しいんです。しかし、冷静に考えてみてください。これって本当に早いでしょうか?
仮に簡単なバッチプログラムを作成するならいいかもしれません。でも、複数のクラスを読み込んで複雑な処理を行わせるプログラムの場合はどうでしょうか?
メイン文から直接呼び出されない処理の動作確認は最後まで作成しないとできないでしょう。 ようやっと全てを結合し終わって動かしてみたら全く動かなくて途方に暮れる可能性も高いと思います。そんなことを続けていたのでは、いつまで経ってもプログラムは完成しません。
動かしながら作る上でデグレが一番腹がたつ
それに動かしながら作っていて一番腹がたつのがデグレです。ある値を入れたらプログラムが異常終了したので、原因を調べてプログラムを修正したら別のバグが発生した。もしくは以前修正したバグが復活していた。そんな経験があると思います。
これはイライラするし、とてもモチベーションを下げます。そして挙句の果てにはプログラムを修正するのが面倒臭くなり、何か改変を加えるとプログラムが動かなくなるのではという恐怖心に変わります。そうなるともうプログラムは書けません。これらのアートとも呼べる代物を触るのに必要なのは低いモチベーションでもやりきる不屈の精神だけです。
こんなのちっとも楽しくないし、精神を病んで病院送りになるのが目に見えます。
テスティングフレームワークを積極的に使っていくという考え
そこでよく提案しているのが、テスティングフレームワークを積極的に使っていく方法です。やり方はとても簡単です。
- JUnitでもなんでもテスティングフレームワークのライブラリを落とす(Scalaを多用しているKUJIRAはよくScalaTestを使用します)
- 空のテストケースを作成する
- テストケース内にプログラムを書く
- 結果をassertで評価する
これだけです。一つできたら新たなテストケースを作成します。テストを行うファイルは機能ごとに分けた方がいいですが、面倒臭かったら分けなくてもいいです。テストケース内に書くのでクラスとかメソッドとかそこらへんは最初は気にしなくていいです。一つの機能で複数のテストを実行するときに初めてメソッドとして外に切り出すぐらいでいいと思います。
重要なのはリファクタリング
さて、上記の要領でテストを作成していくと、同じ処理や機能が明確になってきます。そうしたら、今度はテストケースからメソッドへの切り出しやクラスの作成を行なっていきます。
ここで重要になってくるのがリファクタリングの技術です。リファクタリングをする際のポイントとしては テストの部分と処理の部分の境界を考え意識すること です。テストケースの中でassertも使用し評価を行なっているので、前提条件とassertの部分に着目してそれ以外を外に出します。そうすることで、テストで重要なインプットに対するアウトプットの評価が崩れることなくプログラムを整理できます。
これらの作業を行うには、IDEのリファクタリング機能がとても便利なので、eclipseを使っている人もIntellij IDEAを使用している人もご自身のツールの使い方を勉強してリファクタリングをしてみてください。
実際にちょっとやってみる
たとえば、ある文字列から数字を抽出して配列に格納するプログラムを作成すると仮定した場合のサンプルを以下に示します。
まずテストケースを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
package sample.test; import org.junit.Test; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; import static org.junit.Assert.*; public class ExtractNumbersTest { @Test public void testExtractNumbers() { String testString = "Snake1Case2Test3Str4ing"; // ここに結果が格納される。 List<Integer> result = null; Integer[] intArray = {1,2,3,4}; List<Integer> expected = Arrays.asList(intArray); assertEquals(expected, result); } } |
このテストは失敗します(笑)ここで重要なのはテストの成功/失敗ではなく、想定するインプットに対して期待するアウトプットがちゃんと示せるかです。
次にこのテストの結果が期待に沿うようにプログラムを組んでいきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
package sample.test; import org.junit.Test; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; import static org.junit.Assert.*; public class ExtractNumbersTest { @Test public void testExtractNumbers() { String testString = "Snake1Case2Test3Str4ing"; String numberSeparateString = testString .replaceAll("[^1-9]*", ",") .replaceAll(",+", ",") .replaceAll("^,", "") .replaceAll(",$", ""); List<String> numberStringList = Arrays.asList(numberSeparateString.split(",")); List<Integer> result = numberStringList.stream().map(Integer::parseInt).collect(Collectors.toList()); Integer[] intArray = {1,2,3,4}; List<Integer> expected = Arrays.asList(intArray); assertEquals(expected, result); } } |
汚いコードで申し訳ないですが、これで処理としては期待通りの結果が得られます。
今度はこの処理を別のメソッドに移設します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
package sample.test; import org.junit.Test; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; import static org.junit.Assert.*; public class ExtractNumbersTest { @Test public void testExtractNumbers() { String testString = "Snake1Case2Test3Str4ing"; List<Integer> result = getIntegers(testString); Integer[] intArray = {1,2,3,4}; List<Integer> expected = Arrays.asList(intArray); assertEquals(expected, result); } private List<Integer> getIntegers(String testString) { String numberSeparateString = testString .replaceAll("[^1-9]*", ",") .replaceAll(",+", ",") .replaceAll("^,", "") .replaceAll(",$", ""); List<String> numberStringList = Arrays.asList(numberSeparateString.split(",")); return numberStringList.stream().map(Integer::parseInt).collect(Collectors.toList()); } } |
最後に、移設したメソッドを別クラスに移設します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
package sample.test; import org.junit.Test; import java.util.Arrays; import java.util.List; import static org.junit.Assert.*; public class ExtractNumbersTest { @Test public void testExtractNumbers() { String testString = "Snake1Case2Test3Str4ing"; List<Integer> result = (new ExtractNumbers()).getIntegers(testString); Integer[] intArray = {1,2,3,4}; List<Integer> expected = Arrays.asList(intArray); assertEquals(expected, result); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
package sample.test; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; public class ExtractNumbers { public List<Integer> getIntegers(String testString) { String numberSeparateString = testString .replaceAll("[^1-9]*", ",") .replaceAll(",+", ",") .replaceAll("^,", "") .replaceAll(",$", ""); List<String> numberStringList = Arrays.asList(numberSeparateString.split(",")); return numberStringList.stream().map(Integer::parseInt).collect(Collectors.toList()); } } |
これでプログラムのプロトタイプは作成が完了です。
まとめ
今回はプログラムを作成する上で動かしながらプログラムを作る際のメソッドを自分なりに考えてみました。ポイントは
- テスティングフレームワークのテストケースを利用する
- あらかじめ作りたい処理が期待するインプットとアウトプットを定義しておく
- テストケースの中に処理を書いてテストが通るようにする
- リファクタリングで処理を外出しする
です。この方法でプログラムの作成が少しでも効率の良いものになってくれれば幸いです。