二つに分けた搬送機から同じ順序で荷物が出ていくイラスト。分ける前に、動作を残す

設計・テスト・レビュー

Javaのリファクタリング前に書くJUnit|仕様書にない既存動作を残す

この記事でわかること

仕様書にない既存動作を調べ、残すべき戻り値をJUnitで確かめる手順です。Javaの配送判定を、整理する前、整理した後、新しい仕様を入れた後の3段階で比較します。

新人SE
新人SE
離島向けの確認を追加したいので、配送判定クラスを先に分けました。普段の注文は通ります。ほかに何を見ればいいですか?

ポンコツSE
ポンコツSE
禁止品なのに郵便番号も未入力だったら、今は何を返している? その値を受け取る側が何をするかまで見よう。

保守チームに「離島の注文は発送前に人が確認する」という依頼が来ました。配送判定のクラスにはすでに、発送できない品目を止める処理と、住所が不足した注文を保留する処理が混ざっています。担当を分ければ離島の条件は追加しやすくなります。順番を入れ替えただけで、発送禁止の品が確認待ちに流れると困ります。

仕様書には「配送できない場合はエラー」としかありません。既存コードが実際に返す値を調べ、連携先での意味を確認し、残すと決めた動作をテストへ移します。ここではこの作業を、既存の振る舞いを記録する特性テストと呼びます。

実行用サンプル

ZIPには、整理する前と後のコード、DeliveryDecisionTest.java、pom.xml、READMEを収録。JDK 17とMaven 3.9系で mvn test を実行できます。DBやSpringは不要です。

先に、連携先が受け取る3つの値を確認する

受付側から見ると、判定メソッド check(boolean prohibited, String postalCode, String zone) は、品目の禁止フラグ、配送先の郵便番号、地域コードを受け取ります。戻り値は文字列です。今回の架空システムでは、その文字列に応じて連携先が次の処理を選びます。

戻り値連携先の処理
OK通常の発送処理へ進める
HOLD住所や地域を担当者が確認する
NG発送対象から除外する

まず連携先の分岐やテスト、過去の仕様確認を調べます。旧版を動かして分かるのは「今は何を返すか」です。「変更後も何を返すべきか」は、連携先の処理と今回の依頼を照らして、保守担当と決めます。

たとえば郵便番号未入力で HOLD になる処理は、担当者が住所を補う運用に使われていました。一方、禁止品を NG にする理由は、発送対象から外すためです。両条件に該当しても禁止品を除外する、と確認しておけば、分割後に守る順番が決まります。連携先を確認できない結果は、観測結果として残し、確定した仕様と分けて扱います。

分割前のコードを動かし、条件が重なるケースを選ぶ

分割前の LegacyDeliveryDecision.java です。最初に禁止品を判定し、次に郵便番号の欠損を調べます。zone は既存の呼び出し口にありますが、この版は使っていません。呼び出し側の引数を変えず、既存の結果を調べるところから始めます。

package info.syachiku.characterization;

public final class LegacyDeliveryDecision implements DeliveryDecision {
    @Override
    public String check(boolean prohibited, String postalCode, String zone) {
        if (prohibited) {
            return "NG";
        }
        if (postalCode == null || postalCode.isBlank()) {
            return "HOLD";
        }
        return "OK";
    }
}

通常注文、住所が足りない注文、禁止品を一つずつ動かします。そのあとに、住所が足りない禁止品を用意します。単独の条件がすべて正しく動いていても、条件が重なったときの優先順位は、両方を満たす入力を作って初めて確かめられます。地域ルールを足す予定なので、変更予定の離島注文と、今後も止めたい離島の禁止品も先に記録します。

先に分割前のC02とC05だけを動かします。冒頭のZIPを展開し、pom.xml がある場所で mvn compile を実行します。同じ場所に ObserveLegacy.java を作り、次のコードを保存してください。2つの呼び出しはどちらも郵便番号が半角空白1文字で、禁止品フラグだけが違います。

import info.syachiku.characterization.LegacyDeliveryDecision;

class ObserveLegacy {
    public static void main(String[] args) {
        var legacy = new LegacyDeliveryDecision();
        System.out.println("C02 " + legacy.check(false, " ", "NORMAL"));
        System.out.println("C05 " + legacy.check(true, " ", "NORMAL"));
    }
}

java -cp target/classes ObserveLegacy.java を実行すると、通常品の C02 HOLD と禁止品の C05 NG が順に出ます。住所の状態をそろえて禁止品だけに NG が返ると分かったら、連携先でも NG を発送対象から外すことを確認します。この2行を、下の表の「分割前・分割後」列に対応させてください。

ID禁止品郵便番号地域分割前・分割後地域変更後
C01いいえ100-0001NORMALOKOK
C02いいえ半角空白1文字NORMALHOLDHOLD
C03いいえnullNORMALHOLDHOLD
C04はい100-0001NORMALNGNG
C05はい半角空白1文字NORMALNGNG
C06いいえ100-0001REMOTEOKHOLD
C07はい100-0001REMOTENGNG

C02とC03は、フォームから空白が来る場合と、データ連携で値が欠ける場合を分けています。C05は、郵便番号より禁止品が優先される確認です。C07は同じ優先順位を離島条件が加わった後も確かめる行です。郵便番号の桁数や地域マスタの妥当性は、今回の改修で決める配送判定の範囲に入れていません。

テストは呼び出し口に置き、判断を分けても使い回す

整理した後は、保留理由の判定を ShipmentReviewPolicy に移します。禁止品を先に除外して最後に戻り値を決める役割は、RefactoredDeliveryDecision に残します。地域の追加条件は「担当者の確認が必要か」に関わるので、後で保留判定へ足せます。

package info.syachiku.characterization;

public final class RefactoredDeliveryDecision implements DeliveryDecision {
    private final ShipmentReviewPolicy reviewPolicy;

    public RefactoredDeliveryDecision() {
        this(new ShipmentReviewPolicy(false));
    }

    private RefactoredDeliveryDecision(ShipmentReviewPolicy reviewPolicy) {
        this.reviewPolicy = reviewPolicy;
    }

    public static RefactoredDeliveryDecision withRemoteReview() {
        return new RefactoredDeliveryDecision(new ShipmentReviewPolicy(true));
    }

    @Override
    public String check(boolean prohibited, String postalCode, String zone) {
        if (prohibited) {
            return "NG";
        }
        if (reviewPolicy.requiresReview(postalCode, zone)) {
            return "HOLD";
        }
        return "OK";
    }
}
package info.syachiku.characterization;

final class ShipmentReviewPolicy {
    private final boolean holdRemoteZone;

    ShipmentReviewPolicy(boolean holdRemoteZone) {
        this.holdRemoteZone = holdRemoteZone;
    }

    boolean requiresReview(String postalCode, String zone) {
        return postalCode == null || postalCode.isBlank()
                || (holdRemoteZone && "REMOTE".equals(zone));
    }
}

分割だけを確認するときは、new RefactoredDeliveryDecision() を使います。この入口が選ぶ保留条件は分割前と同じです。withRemoteReview() は仕様変更後の比較用で、両方を一つのプロジェクトで動かせるよう残しています。実案件で旧仕様を残す必要がなければ、移行のためだけに切り替え設定を作る必要はありません。

テストが呼ぶのは両方のクラスが提供する check() です。中で ShipmentReviewPolicy を何回呼ぶかや、どのprivateメソッドに処理があるかをテストの条件にすると、返す値が同じでも移動のたびにテストまで直すことになります。今回の連携先が使うのは戻り値なので、そこを比較します。通知や保存が呼び出し口の約束に含まれる改修なら、その結果も別の期待値として追加します。

関連記事Javaのメソッド分割の基準|privateに切るか、別クラスにするか 同じクラスのメソッドに切り出すか、別クラスへ移すか。変更する理由と、処理の進行を読みやすくする方法を比較します。 記事を読む

同じ7ケースを分割前後に当て、離島の1行だけ変更する

テストの全文です。sameBehaviorCases() は、表の「分割前・分割後」列を2つの実装へ適用します。remoteReviewCases() は、離島を保留する版に表の最後の列を適用します。C06以外の期待値は、どの版でも同じです。

package info.syachiku.characterization;

import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import static org.junit.jupiter.api.Assertions.assertEquals;

class DeliveryDecisionTest {
    @ParameterizedTest(name = "{0} {1}")
    @MethodSource("sameBehaviorCases")
    void beforeAndAfterGiveTheSameResult(
            String version, String caseId, DeliveryDecision decision,
            boolean prohibited, String postalCode, String zone, String expected) {
        assertEquals(expected, decision.check(prohibited, postalCode, zone));
    }

    @ParameterizedTest(name = "{0}")
    @MethodSource("remoteReviewCases")
    void remoteReviewChangesOnlyTheApprovedCase(
            String caseId, boolean prohibited, String postalCode, String zone, String expected) {
        var decision = RefactoredDeliveryDecision.withRemoteReview();

        assertEquals(expected, decision.check(prohibited, postalCode, zone));
    }

    static Stream<Arguments> sameBehaviorCases() {
        return Stream.concat(
                cases(false).map(c -> c.forImplementation("Before", new LegacyDeliveryDecision())),
                cases(false).map(c -> c.forImplementation("After", new RefactoredDeliveryDecision())));
    }

    static Stream<Arguments> remoteReviewCases() {
        return cases(true).map(c -> Arguments.of(c.id(), c.prohibited(), c.postalCode(), c.zone(), c.expected()));
    }

    private static Stream<DeliveryCase> cases(boolean holdRemoteZone) {
        return Stream.of(
                new DeliveryCase("C01", false, "100-0001", "NORMAL", "OK"),
                new DeliveryCase("C02", false, " ", "NORMAL", "HOLD"),
                new DeliveryCase("C03", false, null, "NORMAL", "HOLD"),
                new DeliveryCase("C04", true, "100-0001", "NORMAL", "NG"),
                new DeliveryCase("C05", true, " ", "NORMAL", "NG"),
                new DeliveryCase("C06", false, "100-0001", "REMOTE", holdRemoteZone ? "HOLD" : "OK"),
                new DeliveryCase("C07", true, "100-0001", "REMOTE", "NG"));
    }

    private record DeliveryCase(
            String id, boolean prohibited, String postalCode, String zone, String expected) {
        Arguments forImplementation(String version, DeliveryDecision decision) {
            return Arguments.of(version, id, decision, prohibited, postalCode, zone, expected);
        }
    }
}

ZIPを展開し、pom.xml がある場所で mvn test を実行します。分割前7件、分割後7件、仕様変更後7件が成功します。DeliveryDecisionTest の2つのテストメソッドは、表の行ごとに実行するため合計21件です。

Tests run: 21, Failures: 0, Errors: 0, Skipped: 0

変更依頼の担当者が合意したのは、地域が REMOTE で通常の商品を確認待ちにすることです。C06の期待値だけを HOLD に変えます。C07まで HOLD に変えると禁止品が確認待ちに流れてしまうため、NG のまま残します。

判定順を入れ替え、テストが退行を見つけることを確かめる

展開したサンプルのコピーで、RefactoredDeliveryDecision#check の禁止品を返す if と、保留を返す if の順番を逆にして mvn test を実行します。AfterのC05と、離島変更後のC05・C07が失敗します。どれも expected: <NG> but was: <HOLD> です。

この結果が出たら、失敗する注文と連携先での影響を確認します。C05では住所が欠けた禁止品が HOLD になり、担当者の確認待ちへ流れます。C07では離島条件を先に判定したため、郵便番号があっても同じことが起きます。禁止品を先に判定する順序へ戻し、21件が成功することを確かめます。

動作を変えない整理のコミットでこの3件が落ちたら、期待値を新しい実行結果に合わせる前に、まず分割前の値と照合します。業務仕様を変えるコミットでは、変更するケースIDと承認内容を対応させます。これでレビューする側は、コードを移した差分と、戻り値を変えた差分を分けて追えます。

レビューに、観測した事実と変えてよい値を書く

レビューで「クラスを分けました。全件通りました」だけだと、何を引き継いだかが伝わりません。今回なら、次のように実行結果と仕様変更を対応させます。

配送判定を整理する変更では、連携先が受け取るOK・HOLD・NGを7ケースで比較しました。禁止品で住所が未入力でもNGを返すことをC05で確認しています。離島の確認を加える変更では、C06だけOKからHOLDに変えています。離島でも禁止品はNGです。

  • 既存コードを動かし、戻り値と連携先の扱いを確認した。
  • 複数の条件が同時に成り立つケースを少なくとも一つ用意した。
  • 整理だけの変更は同じ期待値でテストした。仕様変更は変える行を特定した。
  • 判定順を崩したコードで失敗し、修正後に全件成功することを確認した。

テストをクラスの内部構造に結び付けてしまい、整理のたびに書き換える負担が大きいと感じたら、『単体テストの考え方/使い方』で、テストがどの結果を守るべきかを掘り下げられます。

書籍「単体テストの考え方/使い方」の表紙
商品画像:Amazon.co.jp
PR テストを学ぶ若手向け

単体テストの考え方/使い方

Vladimir Khorikov (著), 須田智之 (翻訳)

単体テストで「何を検証するか」の判断基準を学ぶ。

モックの使いどころや壊れにくいテストの考え方を整理できます。コード例はC#です。JUnitの操作入門ではなく、テストの設計と価値を深める本です。

  • テスト対象の切り分けに迷っている
  • 保守しやすい単体テストを書きたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

関連記事JUnit 5入門|Mavenで最初のテストを実行し、失敗を直す JUnitを初めて動かすときは、送料の例でmvn testを実行し、失敗結果の期待値と実際の値を読みます。 記事を読む 関連記事テストケースの作り方|仕様・境界値・期待結果をJUnitと対応させる実例 残すべき既存動作を確認した後、新しい仕様から境界値や状態を選ぶ手順へ進めます。 記事を読む

既存動作を記録してから、変える行を選ぶ

既存の配送判定を改修するとき、最初に調べるのは「どんな入力でどの値を返し、連携先が何をするか」です。残すと決めた値をJUnitで固定してからクラスを分けます。離島を保留にする仕様変更ではC06だけ期待値を変え、他の行も実行して禁止品の扱いが変わっていないことを確認します。

-設計・テスト・レビュー
-, , , ,