100超のSpekテストをスクリプトでJUnitに一括移行した話

事例紹介 SpekをJUnit5に置き換える

『ホットペッパービューティー』のAndroidアプリ開発ではユニットテストにSpekというフレームワークを長らく採用していました。しかし、Spekの更新が2021年8月を最後になくなりEoLを迎えたと判断したためフレームワークを乗り換える必要が出てきました。 対象が少なければ手作業で直すことも可能でしたが、100超のテストが修正対象となっており、とても現実的ではありませんでした。そこでスクリプトによる一括置換を検討・実施しました。

実施した内容

以下の流れに沿って進めました。

  1. 移行先の検討
  2. 事前の認識合わせ
  3. 移行作業

移行先の検討

まずはSpekを何に置き換えるかを検討しました。今回の技術選定は、 機械的な処理で移行 ができるかが重要な観点です。

Kotest[1]やJUnit4[2]への移行も検討しましたが、最終的にJUnit5[3]を採用しました。機械的に処理するのが難しいためJUnit4は候補から外しました(理由は後述します)。

事前の認識合わせ

続けて実際に作業する前に課題の洗い出しと方針決めを行いました。

一括置換をすると差分が巨大になり、影響範囲も大きくなります。加えて機械的なシンプルな移行処理しかできないために生じる課題もあります。本ケースでは以下の課題がありました。

  • PRを分割するか一括で出すか(=レビュー見落としをどの程度許容するか)
  • BDDスタイルを維持したままにするか
  • メソッド名やクラス名をどうするか

今回の移行対象はテストなので、仮に移行でミスがあったとしてもプロダクション影響が即時に出てくるものではありません。移行中のテスト消失は実行テスト数である程度判断可能で、移行後の軽微なミスの検知はユニットテストの実行結果から判断できます。そのためレビューの精度が多少下がってもPRを一括で出す方針となりました。

スタイルはBDDの構造をそのまま引き継ぎました。一括置換では構造を変更するのが困難で可読性・変更しやすさに大きく影響しないため元々の構造で問題ないと判断しました。

メソッド名やインナークラスのクラス名は悩みどころでした。簡単に思いつくのは describe context it の中身をそのままメソッド名にすることです。ですがメソッド名が日本語になり読点等をエスケープしないといけないため扱いづらいです。しかし @DisplayName でテストの概要を伝えられるため、メソッド名やクラス名に意味を持たせなくても大きな問題にはなりません。そこで一時的に許容する形で命名はtest始まりのランダムな文字列数文字としました。

PR作成後の手戻りが少なくなるように事前に検討・議論しました。

移行作業

機械的な置換はあまり難しい処理にならないようにしました。スクリプト作成に時間を取られるのは避けたいからです。まず置き換え前後の姿を観察して、どういうスクリプトを書けば良いか考えました。

// Spek
class HogeSpek : Spek({
    describe("doSomethingメソッド") {
        context("サーバーと通信できる時") {
            it("結果を返すこと") {
                // do something
            }
        }
    }
})
// JUnit5
class HogeTest {
    @Nested
    @DisplayName("doSomethingメソッド")
    inner class DoSomethingTest {
        @Nested
        @DisplayName("サーバーと通信できる時")
        inner class WhenConnectToServer {
            @Test
            @DisplayName("結果を返すこと")
            fun test() {
                // do something
            }
        }
    }
}

describecontext をアノテーション付きの inner class へ、 it をテストメソッドに直せば良さそうということがわかりました。

ここでJUnit4への移行ができなかった理由を説明します。JUnit4でテストをネストする際には @RunWith(Enclosed::class.java) を使います。しかしこのアノテーション、ネストできません。Spekで書かれたテストは深いネストをしているものもあります。ネスト構造を機械的に平らにするのも容易ではないため断念しました。

観察が終わったのでスクリプトを作成して実行するのみです。完成したスクリプトは以下になります(一部抜粋)。

 
# Spekで書かれたテストのパスを取得
targets = `git grep --name-only -e ' : Spek(' | sort | uniq`.split("\u000a")
# Spekファイルを1つ1つ開いて更新
targets.filter { _1 != 'app.rb' }.each do |file_name|
  File.open(file_name, 'r+') do |f|
    raw_txt = f
      .readlines
      .insert(3, "import org.junit.jupiter.api.BeforeEach\n", "import org.junit.jupiter.api.AfterEach\n", "import org.junit.jupiter.api.Nested\n", "import org.junit.jupiter.api.Test\n", "import org.mockito.Mockito.mock\n") # 必要なimportの挿入
      .join('')
    res = raw_txt
      .gsub(/^object/, "class")
      .gsub(' : Spek(', ' ')
      .gsub(/^\}\)/, '}')
      .gsub(/(?:it\.)?describe\("(.*)"\)/) {
        target = $1.gsub(":", "").gsub("\\", "").gsub("[", "").gsub("]", "").gsub(".", "").gsub("<", "<").gsub(">", ">").gsub("/", "/").gsub(/(^[a-z])(.+)/) { $1.upcase + $2 } 
        if target.match(/\s|、|。|\{|\}|\(|\)|\"|^\d|「|」|=|)|(|/|・|:|<|>|-|〜/)
          "@Nested\ninner class `#{target}`"
        else
          "@Nested\ninner class #{target}"
        end
      }
      .gsub(/context\("(.*)"\)/) {
         target = $1.gsub(":", "").gsub("\\", "").gsub("[", "").gsub("]", "").gsub(".", "").gsub("<", "<").gsub(">", ">").gsub("/", "/").gsub(/(^[a-z])(.+)/) { $1.upcase + $2 }
         if target.match(/\s|、|。|\{|\}|\(|\)|\"|^\d|「|」|=|)|(|/|・|:|<|>|-|〜/)
           "@Nested\ninner class `#{target}`"
         else
           "@Nested\ninner class #{target}"
         end
       }
      .gsub(/it\s*\("(.*)"\)/) {
         target = $1.gsub(":", "").gsub("\\", "").gsub("[", "").gsub("]", "").gsub(".", "").gsub("<", "<").gsub(">", ">").gsub("/", "/").gsub(/(^[a-z])(.+)/) { $1.upcase + $2 }
         if target.match(/\s|、|。|\{|\}|\(|\)|\"|^\d|「|」|=|)|(|/|・|:|<|>|-|〜/)
           "@Test\nfun  `#{target}`()"
         else
           "@Test\nfun  #{target}()"
         end
       }
      .gsub(/beforeEachTest \{/, "@BeforeEach\nfun setup() {")
      .gsub(/afterEachTest \{/, "@AfterEach\nfun tearDown() {")
      .gsub("by (?:it\.)?mock()", "= mock()")
      .gsub(/by (?:it\.)?mock\<(.*)\>\(\)/, "= mock<\\1>()")
    f.reopen(file_name, 'w')
    f.print res
  end
end

一発で成功はせず、イレギュラーがいくつも見つかるので何回も修正して再実行しました。しかし、全部をスクリプトで処理しないといけないわけではありません。機械的に処理するのが難しいか逆に時間がかかる場所はスクリプト実行後に人力で直しました。一番大変だったのはパラメータライズドテストでした。

 
// Spek時代のパラメータライズドテスト。fixtures部分が変数になっているパターンやリストベタ書き(これも1行で書かれているもの複数行に及ぶものに分かれる)とパターンが多い上に正規表現で処理するのが容易ではなかった
fixtures.forEach { (_this, _other, tester) ->
    context("this = $_this, other = $_other") {
        it("thisはother${tester.msg}") {
            val actual = _this.compareTo(_other)
            assertThat(tester.test(actual)).isTrue()
        }
    }
}

人力で直したところはコミットやPRを分けました。

一括置換と言っても 全てスクリプト任せではなく、細かいところは人手を使うことが工数を下げるポイントになります。

結果

大きなトラブルもなく無事に一括置換を終え、SpekはJUnit5のテストに置き換わりました。

ではどれぐらい工数が削減されたのでしょうか?(必要なデータを計測していないため、あくまで試算になります。)

  • 全て手作業でかかる総工数: 0.25人日 × 約100ファイル = 約25人日
  • 一括置換でかかった総工数: 5人日程度

大幅に工数を削減することができました。

まとめ

  • 単調な繰り返しのリファクタリングは一括置換を検討する
  • 一括置換の性質を理解し、先に課題の洗い出しや方針決めをする
  • 一括置換と言えど人の手で修正してはいけないわけではないので、うまく使い分けて工数を下げる

脚注

  1. https://kotest.io/ 。Spekとほぼ同じシグニチャで移行しやすそうだったため候補に上がりました。JUnitでテストを揃えた方が認知負荷が下がるだろうということで今回は採用見送りとなりました。 ↩︎

  2. InstrumentationTestや一部テストがJUnit4なので統一感観点で候補に上がりました。 ↩︎

  3. 特に@DisplayNameや@Nested、より進化したパラメータライズドテストを使いたいという声は大きかったです。 ↩︎

記事内容及び組織や事業・職種などの名称は、編集・執筆当時のものです。