出荷した注文を取り消せないことを、荷物が一方向へ進む仕分け装置で表したイラスト

設計・テスト・レビュー

Javaのenumで注文の状態遷移を管理する|出荷後のキャンセルを防ぐ

この記事でわかること

Javaのenumで注文の状態を表しても、setterから自由に書き換えられると、出荷済みをキャンセルできます。出荷とキャンセルを注文の操作にし、拒否した後も状態が変わらないことをJUnitで確かめます。

新人SE
新人SE
出荷済みの注文なのに、管理画面からキャンセルできました。注文の状態はenumにしたのに、どこが足りなかったんでしょう。
ポンコツSE
ポンコツSE
「キャンセル済み」という値は使えます。次に、「今の状態からキャンセルできるか」を、注文を変更するときに確認しましょう。

注文管理画面のキャンセルボタンから、注文の状態を更新する処理を作ります。注文が未出荷なら、そのままキャンセルできます。すでに荷物を出荷した注文までキャンセル済みになると、倉庫では出荷済みなのに管理画面ではキャンセル済み、という食い違いが起きます。

対象は、Javaのクラスとenumを読める人です。まず自由に状態を指定できる例で問題を再現し、次に「出荷する」「キャンセルする」という操作を通してだけ状態を変える形へ直します。

実行サンプル

setter版と修正版、8件のJUnit、pom.xml、READMEを収録。JDK 17とMavenで、展開したディレクトリから mvn clean test を実行できます。

出荷とキャンセルを許す状態を表にする

この例では、注文を作ると未出荷になります。未出荷からは出荷かキャンセルのどちらかを選べます。出荷済みとキャンセル済みになった後は、同じ操作の再送も含めて変更を拒否します。拒否した場合は、操作前の状態を維持します。

今の状態出荷するキャンセルする
未出荷出荷済みになるキャンセル済みになる
出荷済み拒否し、出荷済みのまま拒否し、出荷済みのまま
キャンセル済み拒否し、キャンセル済みのまま拒否し、キャンセル済みのまま

次がOrderStatus.javaの全文です。このenumが表すのは、注文が取り得る状態です。「いま何ができるか」は、表のように現在の状態と操作の組で決まります。

package example.order;

enum OrderStatus {
    UNSHIPPED,
    SHIPPED,
    CANCELLED
}

setterで、出荷済みがキャンセル済みに変わる

次は修正前のBrokenOrder.javaの全文です。setStatusへ渡せる状態はenumで定義した値ですが、現在の状態は確認していません。出荷処理がsetStatus(SHIPPED)を呼んだ後でも、管理画面の処理がsetStatus(CANCELLED)を呼ぶと、そのままキャンセル済みに変わります。

package example.order;

final class BrokenOrder {
    private OrderStatus status = OrderStatus.UNSHIPPED;

    OrderStatus status() {
        return status;
    }

    void setStatus(OrderStatus status) {
        this.status = status;
    }
}

出荷画面と管理画面の両方にifを追加する方法もあります。その後でバッチからも注文を変えるとき、バッチでも同じ確認が必要になります。注文をどこから変更しても表の条件を守りたいので、注文の状態を書き換える処理へ確認を移します。

注文のshipとcancelに、変更前の状態確認を置く

修正版Order.javaでは、呼び出し側がship()かcancel()を選びます。どちらも注文の中で現在の状態を読み、未出荷のときだけ、操作ごとの次の状態を代入します。未出荷でなければIllegalStateExceptionを投げます。変更できない注文には代入まで進みません。

package example.order;

final class Order {
    private OrderStatus status = OrderStatus.UNSHIPPED;

    OrderStatus status() {
        return status;
    }

    void ship() {
        transitionFromUnshipped(OrderStatus.SHIPPED);
    }

    void cancel() {
        transitionFromUnshipped(OrderStatus.CANCELLED);
    }

    private void transitionFromUnshipped(OrderStatus next) {
        if (status != OrderStatus.UNSHIPPED) {
            throw new IllegalStateException("未出荷の注文だけを変更できます");
        }
        status = next;
    }
}

この例では出荷とキャンセルの前提がどちらも未出荷なので、同じprivateメソッドにしています。後から「出荷後の返品」を追加するなら、cancel()の条件を緩める前に、返品という操作が許される状態と、処理後の状態を表へ追加します。管理画面では呼び出した操作の結果を表示し、注文クラスは状態を変えてよいかを判断します。

外部から来た文字列をOrderStatusへ変換するときのエラーは別の段階です。まず入力が定義済みの値かを判断し、操作として許されるかはこの章の条件で判断します。

関連記事Javaの「No enum constant」エラー原因|Enum.valueOfの安全な使い方 外部から受け取った文字列が、定義した状態に変換できないときの確認順です。 記事を読む

拒否時は、例外と変更前の状態を両方確かめる

配布ZIPを展開し、pom.xmlのある場所でmvn clean testを実行します。OrderTest.javaには、setter版の問題を観測する1件、修正版の初期状態1件、許可する2操作、拒否する4操作があります。通常は計8件が成功します。

OrderTest.javaの全文を見る
package example.order;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static example.order.OrderStatus.CANCELLED;
import static example.order.OrderStatus.SHIPPED;
import static example.order.OrderStatus.UNSHIPPED;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class OrderTest {
    @Test
    void setterLetsShippedOrdersBecomeCancelled() {
        BrokenOrder order = new BrokenOrder();
        order.setStatus(SHIPPED);

        order.setStatus(CANCELLED);

        assertEquals(CANCELLED, order.status());
    }

    @Test
    void startsAsUnshipped() {
        assertEquals(UNSHIPPED, new Order().status());
    }

    @Test
    void canShipAnUnshippedOrder() {
        Order order = new Order();

        order.ship();

        assertEquals(SHIPPED, order.status());
    }

    @Test
    void canCancelAnUnshippedOrder() {
        Order order = new Order();

        order.cancel();

        assertEquals(CANCELLED, order.status());
    }

    @ParameterizedTest(name = "{0}から{1}を拒否して、状態は変えない")
    @CsvSource({
            "SHIPPED, SHIP",
            "SHIPPED, CANCEL",
            "CANCELLED, SHIP",
            "CANCELLED, CANCEL"
    })
    void refusesForbiddenOperationsWithoutChangingStatus(OrderStatus initial, Action action) {
        Order order = new Order();
        if (initial == SHIPPED) {
            order.ship();
        } else {
            order.cancel();
        }

        assertThrows(IllegalStateException.class, () -> action.applyTo(order));
        assertEquals(initial, order.status());
    }

    private enum Action {
        SHIP, CANCEL;

        void applyTo(Order order) {
            switch (this) {
                case SHIP -> order.ship();
                case CANCEL -> order.cancel();
            }
        }
    }
}
pom.xmlの全文を見る
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>example</groupId>
  <artifactId>java-enum-order-state-transition</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.12.2</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.3</version>
      </plugin>
    </plugins>
  </build>
</project>

拒否する4ケースは、まずテストでship()かcancel()を呼び、注文を表の開始状態にします。次に禁止操作を呼び、assertThrowsで例外を確認します。その直後にassertEqualsで状態を読み直すのは、例外を投げる前に状態を書き換えてしまう実装を見つけるためです。

確認として、実装から状態チェックを外したところ、禁止操作の4件が失敗しました。次に、変更前の状態を見て例外を投げる処理は残し、代入だけ先にすると、別の状態に移る2件が失敗しました。この2件は例外自体が出るので、状態も比べるテストで検出できます。

ルールを変えるときは、どの操作を追加するか決める

レビューでsetStatusを見つけたら、「ここでは出荷後にキャンセルできません。更新先だけ渡すと今の状態を見ずに変更できるため、cancel()に置き換え、出荷済みのまま残ることもテストできますか?」と確認します。出荷とキャンセルの条件が将来分かれたら、それぞれの操作で確認する形に戻しても、呼び出し側の名前はそのままです。

  • 状態だけを直接書き換える入口を呼び出し側に渡していない。
  • 今の状態、操作、成功後の状態を表で確認できる。
  • 禁止操作と同じ操作の再送で、変更前の状態を確認した。
  • データベースで出荷とキャンセルが同時に走るなら、競合を防ぐ更新方法を別に確認する。

状態のルールを、別の業務でも置き分けるなら

状態の書き換えが複数の画面や処理に散ってきたときは、『改訂新版 良いコード/悪いコードで学ぶ設計入門』で、値と処理をどうまとめるかを掘り下げられます。今回の例を動かした後なら、自分のコードで状態を変更する入口と、その変更を判断するコードを探すところから始められます。

書籍「改訂新版 良いコード/悪いコードで学ぶ設計入門」の表紙
商品画像:Amazon.co.jp
PR Java新人・若手向け

改訂新版 良いコード/悪いコードで学ぶ設計入門

仙塲 大也 (著)

レビューで直される理由を、Javaの具体例から理解する。

命名、責務、クラス設計、不変性などを良い例と悪い例で比較できます。現場のコードレビューと結びつけやすい一冊です。

  • 設計の悪さを具体例から学びたい
  • レビュー指摘の判断軸を増やしたい

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

関連記事Javaのクラス設計は何から学ぶ?最初の1冊と「どこに書くか」の考え方 料金と表示の例から、次の変更をどのクラスで扱うかを考える本を選べます。 記事を読む 関連記事テストケースの作り方|仕様・境界値・期待結果をJUnitと対応させる実例 状態だけでなく、担当者や操作する人が条件に加わった場合に、表からテストを選べます。 記事を読む

まとめ

enumで注文の状態を表し、ship()とcancel()で状態の変更を扱います。この例では、未出荷のときだけ変更を許し、違反したら例外を投げ、状態は変えません。新しい操作を増やすときは、現在の状態と操作の表を見て、許す組と拒否した後の状態をJUnitへ書きます。

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