はじめまして。SD部のヤマナです。

Javaエンジニアの方なら、一度はJUnitでテストケースの実装を行ったことがあると思います。

一度作ってしまえば便利ですよね。
実装した処理を手元で手早く確認したり、リファクタリング前後での挙動変化が無いことを確認したり。
そう、一度作ってしまえば便利。でも、一度作るまでが大変なんです。

結局、カバレッジを通すだけのテストケースになってしまって十分なassertが書かれていなかったり、
本質的な確認を行うテストケースになっていなかったり・・・。

そんなテストケースくらい、楽して書きたいものです。

そこで、「Better Java」なGroovyの出番です。
流行りのgradleにも利用されていますね。

Groovyとは

JVM上で動作する、Java互換の動的スクリプト言語です。
以下のように、Javaでは冗長になりがちな処理を非常に短く記述できるという特徴があります。

  • 変数の型を省略可能
  • List, Mapをリテラルで初期化可能

Java

Map<String, Integer> map = new LinkedHashMap<String, Integer>();
map.put("a", 1);
map.put("b", 2);
map.put("c", 3);

Groovy

def map = ["a":1, "b": 2, "c": 3]
println map.getClass() // java.util.LinkedHashMap
  • BeanのプロパティもMapリテラルで初期化可能

Java

Bean bean = new Bean();
bean.fieldA = 1;
bean.fieldB = "abc";
bean.fieldC = true;

Groovy

def bean = new Bean(fieldA: 1, fieldB: "abc", fieldC: true);
// 以下のように書く事も可能
// def bean = [fieldA: 1, fieldB: "abc", fieldC: true] as Bean 
  • Groovy側からの呼び出しからに限り、既存メソッドを動的にオーバーライド可能
  • privateメソッドの直接呼び出しが可能(静的コンパイルをしない場合に限る)

如何でしょうか。等価の処理をとてもシンプルに記述できますね。
この性質を利用して、実際にJUnitを記述してみましょう。

Eclipse Groovyプラグインのインストール

Eclipseのメニューより、[ヘルプ]->[新規ソフトウェアのインストール]と進み、以下の表より手元のEclipseのバージョンと合致するものをURL欄へ入力してください。

Eclipseバージョン Update Site URL
4.4 (Luna) http://dist.springsource.org/release/GRECLIPSE/e4.4/
4.3 (Kepler) http://dist.springsource.org/release/GRECLIPSE/e4.3/
4.3-8 (Kepler with Java 8) http://dist.springsource.org/release/GRECLIPSE/e4.3-j8/
4.2 and 3.8 (Juno) http://dist.springsource.org/release/GRECLIPSE/e4.2/
3.7 (Indigo) http://dist.springsource.org/release/GRECLIPSE/e3.7/

以下のように選択し、後は「OK」を選択してください。

完了後、Eclipseの再起動を求められますので、再起動してください。

※既存プロジェクトに対してGroovyサポートを有効化するには、プロジェクトメニューを右クリックし、
[Configure]->[Convert to Groovy Project]を実行してください

テストクラスの実装

テスト対象のクラスとして、以下のようなPersonクラスおよびそれを扱うUtilクラスをJavaで作成しました。

public enum Gender {
  MALE,
  FEMALE,
}

public class Person {
  private String country;
  private String name;
  private Gender gender;
  // getter,setter省略
}

public class PersonUtils {

  /**
   * PersonリストをPerson.countryについてグループ化したMapを返します
   *
   * @param persons グループ化対象List<Person>
   * @return Person.countryについてグループ化されたMap
   */
  public static Map<String, List<Person>> groupByCountry(List<Person> persons) {
    Map<String, List<Person>> personsByCountry = new HashMap<String, List<Person>>();

    for (Person person : persons) {
      String country = person.getCountry();
      if (!personsByCountry.containsKey(country)){
        personsByCountry.put(country, new ArrayList<Person>());
      }
      personsByCountry.get(country).add(person);
    }

    return personsByCountry;
  }

  /**
   * どこかで見たようなボスのPersonインスタンスリストを返します
   *
   * @return どこかで見たようなボス達
   */
  public static List<Person> getBosses(){
    Person bison = new Person();
    bison.setName("M.Bison");
    bison.setCountry("USA");
    bison.setGender(Gender.MALE);

    Person balrog = new Person();
    balrog.setName("Balrog");
    balrog.setCountry("Spain");
    balrog.setGender(Gender.MALE);

    Person sagat = new Person();
    sagat.setName("Sagat");
    sagat.setCountry("Thailand");
    sagat.setGender(Gender.MALE);

    Person vega = new Person();
    vega.setName("Vega");
    vega.setCountry("Thailand");
    vega.setGender(Gender.MALE);

    return Arrays.asList(bison, balrog, sagat, vega);
  }

  /**
   * 渡されたインスンタンスがnullの場合、独自例外を送出します
   *
   * @param チェック対象person
   */
  public static void validatePerson(Person person){
    if (person == null){
      throw new StreetException("HERE COMES A NEW CHALLENGER!");
    }
  }

  /** 独自例外 */
  public static class StreetException extends Exception {
    public StreetException(String message){
      super(message);
    }
  }
}

まず、普通にJavaで記述したテストコードは以下の通りです。

import static org.junit.Assert.*;

import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import jp.co.casleyconsulting.PersonUtils.StreetException;
import jp.co.casleyconsulting.beans.Person;
import jp.co.casleyconsulting.enums.Gender;

import org.junit.Test;

public class UtilsTestJava {

  @Test
  public void test_gourpByCountry() {
    Person ryu    = createPerson("Ryu",     "Japan", Gender.MALE);
    Person honda  = createPerson("E.Honda", "Japan", Gender.MALE);
    Person ken    = createPerson("Ken",     "USA",   Gender.MALE);
    Person guile  = createPerson("Guile",   "USA",   Gender.MALE);
    Person chunLi = createPerson("Chun Li", "China", Gender.FEMALE);

    Map<String, List<Person>> groupByCountry = PersonUtils.groupByCountry(Arrays.asList(ryu, honda, ken, guile, chunLi));

    Map<String, List<Person>> expected = new HashMap<String, List<Person>>();
    expected.put("Japan", Arrays.asList(ryu, honda));
    expected.put("USA",   Arrays.asList(ken, guile));
    expected.put("China", Arrays.asList(chunLi));

    assertEquals(expected, groupByCountry);
  }

  @Test
  public void test_groupByCountryWithNull() {
    try {
      PersonUtils.validatePerson(null);
      fail();
    } catch (StreetException ex){
      assertEquals("HERE COMES A NEW CHALLENGER!", ex.getMessage());
    }
  }

  @Test
  public void test_getBosses(){
    List<Person> expectedBosses = Arrays.asList(
      createPerson("M.Bison", "USA",      Gender.MALE),
      createPerson("Balrog",  "Spain",    Gender.MALE),
      createPerson("Sagat",   "Thailand", Gender.MALE),
      createPerson("Vega",    "Thailand", Gender.MALE)
    );

    List<Person> bosses = PersonUtils.getBosses();

    assertEquals(expectedBosses.size(), bosses.size());

    Iterator<Person> iterator = expectedBosses.iterator();
    for (Person boss : bosses) {
      Person expected = iterator.next();

      assertEquals(expected.getName(), boss.getName());
      assertEquals(expected.getCountry(), boss.getCountry());
      assertEquals(expected.getGender(), boss.getGender());
    }
  }

  private Person createPerson(String name, String country, Gender gender) {
    Person person = new Person();
    person.setName(name);
    person.setCountry(country);
    person.setGender(gender);
    return person;
  }
}

こちらに載せるのが躊躇われるほど長いコードです。
さて、同様の物をGroovyで書いたのが以下です。

import static groovy.test.GroovyAssert.*
import static groovy.util.GroovyTestCase.assertEquals
import jp.co.casleyconsulting.PersonUtils.StreetException
import jp.co.casleyconsulting.beans.Person
import junit.framework.TestCase

import org.junit.Test

class UtilsTest {

  @Test
  void test_gourpByCountry(){
    def ryu    = new Person(name: 'Ryu',     country: 'Japan', gender: 'MALE')
    def honda  = new Person(name: 'E.Honda', country: 'Japan', gender: 'MALE')
    def ken    = new Person(name: 'Ken',     country: 'USA',   gender: 'MALE')
    def guile  = new Person(name: 'Guile',   country: 'USA',   gender: 'MALE')
    def chunLi = new Person(name: 'Chun Li', country: 'China', gender: 'FEMALE')

    def groupByCountry = PersonUtils.groupByCountry([ryu, honda, ken, guile, chunLi])

    def expected = [
      Japan: [ryu, honda],
      USA:   [ken, guile],
      China: [chunLi]
    ]

    assertEquals expected, groupByCountry
  }

  @Test
  void test_groupByCountryWithNull(){
    def expectedException = shouldFail(StreetException){
      PersonUtils.validatePerson(null)
    }

    assertEquals "HERE COMES A NEW CHALLENGER!", expectedException.message
  }

  @Test
  void test_getBosses(){
    def expectedBosses = [
      new Person(name: 'M.Bison', country: 'USA',      gender: 'MALE'),
      new Person(name: 'Balrog',  country: 'Spain',    gender: 'MALE'),
      new Person(name: 'Sagat',   country: 'Thailand', gender: 'MALE'),
      new Person(name: 'Vega',    country: 'Thailand', gender: 'MALE'),
    ]

    def bosses = PersonUtils.getBosses()

    Person.metaClass.equals = {
      it != null &&
      delegate.class   == it.class   &&
      delegate.name    == it.name    &&
      delegate.country == it.country &&
      delegate.gender  == it.gender
    }

    assertEquals expectedBosses, bosses
  }
}

この規模のコードでも400文字ほど簡略化されています。
変数の型宣言、メソッド呼び出しのカッコ、行末のセミコロンが不要である事や、
先例の通りListやMapの初期化が簡潔なため、スッキリとした見た目であることがおわかりいただけると思います。
また、test_getBosses()の例では、Personクラスのequalsメソッドを動的にオーバーライドすることにより、
assertが1行にまとまっています。
プロパティ構文の存在により、getterを明示的に呼出さなくて良い点も見た目をシンプルにしています。
apache commonsのEqualsBuilder.reflectionEqualsに置換えれば更に簡略化も可能ですね。

※ただし、オーバーライドしたequalsが呼び出されるのは、GroovyTestCase.assertEqualを使用した場合です!

さらに細かい所では、以下の相違点もあります。

  • enum型のフィールドをenumの名前の文字列により初期化できる
  • List,Mapは構文として組み込まれているためimport宣言が不要

最後に

如何でしたでしょうか。

コンパイル、テストの実行を行うためのbuild.gradleも掲載しておきます。
※ 各ディレクトリはmaven標準である前提です

apply plugin: 'java'
apply plugin: 'groovy'

repositories {
  mavenCentral()
}

dependencies {
  testCompile "org.codehaus.groovy:groovy-all:2.4.3"
  testCompile group: 'junit', name: 'junit', version: '4.4'
}

GroovyでJavaをテストするという切り口では、さらに発展させたBDDフレームワーク「Spock」という物もありますが、
こちらはJUnit4.7以上を要求します。
私の現在参画しているプロジェクトでは、フレームワークの都合によりJUnit4.4の縛りがあるため、
残念ながら使用できませんが、Groovy単体でもだいぶシンプルに記述できる事がお分かりいただけたかと思います。

経験の浅いメンバーにとって、普段業務で触っている以外の言語に触れる事への心理的な障壁を下げるという効果も
期待できるのではないでしょうか。
テストコードの自動生成も楽になりそうですね。

Better JavaなJVM言語は他にも色々とあるため、皆様にとって何かの参考になれば幸いです。