😎

Stateパターンを使ってストップウォッチを実装する、ついでにTDDしてみる

2022/02/05に公開

はじめに

ちょろっと書いて自分のブログに投稿しようとしていたんですが、想定以上の文量になりせっかくなのでZennに投稿することにしました。

この記事は、TDDを実践してみながらストップウォッチを実装し、その過程でStateパターンを使う内容となっています。
いかんせん記事が長いのでStateパターン使っているところだけ見たいんじゃ!という方は仕様をチラ見した後、状態による振る舞いの制御の仕方を考えるを読んで頂ければと思います。

動機

  • Stateパターンの理解のためなにか実装したい
    • Stateパターンを「これはStateパターンである」と念頭に置きながら実装したことがないため
  • ついでにTDDやったことないから試してみれば、デザパタとTDD勉強できてお得じゃん!

仕様,TODOリスト

仕様

ストップウォッチは下記表の通りに状態遷移するとします。

状態 \ イベント START
ボタン押下
STOP
ボタン押下
RESET
ボタン押下
停止中 計測中 - -
計測中 - 結果表示中 -
結果表示中 計測中 - 停止中

この表は【この1冊でよくわかる】ソフトウェアテストの教科書[増補改訂 第2版]p.120より引用。
(いい感じに状態遷移するもの探してるときにこの表を思い出したのでストップウォッチを実装することにしました。この表自体は状態遷移テストの説明で出てきたものです)

表中の値は、遷移先の状態を示します。
たとえば停止中でSTARTボタンを押下した場合計測中に遷移します。
表中の「-」は遷移先の状態がないことを表しています。
たとえば停止中にSTOPボタンを押しても状態遷移しません。

ストップウォッチは、状態と計測値を確認できます。
初期状態は停止中、計測値は0です。

TODOリスト

実装する/テストする内容をTODOリストに一覧化します。
これをもとにTDDを進めていきます。

  • 初期状態が停止中、計測値が0
  • 停止中でSTOPボタン、RESETボタン押下で何もおきない
  • 停止中でSTARTボタン押下で状態が計測中に遷移する
  • 計測中でSTARTボタン、RESETボタン押下で何もおきない
  • 計測中でSTOPボタン押下で状態が結果表示中に遷移する
  • 結果表示中の計測値は変化しない
  • 結果表示中でSTOPボタン押下で何もおきない
  • 結果表示中でSTARTボタン押下で計測中に遷移する
  • 結果表示中でRESETボタン押下で停止中に遷移する

意外と実装すること多いですね。。。
1つずつ消化していきましょう!

TDDで実装していく

TDDは下記を繰り返して実装を進めるので、それに合わせて実装を進めます。

レッド:動作しない、おそらく最初のうちはコンパイルも通らないテストを1つ書く。
グリーン:そのテストを迅速に動作させる。このステップでは罪を犯してもよい。
リファクタリング:テストを通すために発生した重複をすべて除去する。

テスト駆動開発 p.ⅹより引用。

初期状態が停止中、計測値が0

レッド

まずはコンパイルすら通らないテストを書きます。

public class StopwatchTest {
  @Test
  public void testStopState() {
    Stopwatch stopwatch = new Stopwatch();
    assertEquals("IDLE", stopwatch.state);
    assertEquals(0l, stopwatch.time);
  }
}

コンパイルエラーを直す実装を追加します。

public class Stopwatch {

  public long time;
  public String state;

  public Stopwatch() {
  }

}

コンパイルを通すためだけの最小の実装なのでツッコミどころはありますが、よしとします。
TODOリストに付け加えて、後回しにします。

  • 初期状態が停止中、計測値が0
  • 停止中でSTOPボタン、RESETボタン押下で何もおきない
  • ...(略)...
  • time, stateをprivateにする(NEW)
  • stateはStringで問題ないだろうか(NEW)

テストを実行してみましょう。

tdd-red

失敗するテストの実行ができました!
次のステップであるグリーンに行きましょう。

グリーン

フィールドの初期化を追加します。

public class Stopwatch {

  public long time;
  public String state = "IDLE"; // ★追加

  public Stopwatch() {
  }

}

テストを実行してみます。

tdd-green

通りました。グリーンバーです。
(javaでlongはデフォルト値が0になるので、明示的に0を代入しなくてもテストが通ります)

※これ以降はグリーン、レッドのスクショを省略します。

リファクタリング

リファクタリングは、振る舞いを変えずに内部の構造を改善する作業ですが、今必要でしょうか?
timeの値がデフォルトで0になるっていうことは、言語仕様を知っていないとわからない、コードの読み手の能力に依存する部分なので明示的に0を入れておきたいです(私はグリーンのテストを実行して思い出しました)。
また、これは私の好みですが、変数の初期化をコンストラクタ内でしたくなったのでそうします。そういう癖なのかもしれませんが何故かそっちの方が心地良いです。こういう修正もすでにテストが書いてあるので気軽にできるのがいいですね。

public class Stopwatch {

  public long time;
  public String state;

  public Stopwatch() {
    this.time = 0l;
    this.state = "IDLE";
  }

}

テストを通してみます。。。グリーンバーでした!
振る舞いを変えずに内部の構造を変えることができました。
TODOリストにチェックをいれます。

  • 初期状態が停止中、計測値が0
  • 停止中でSTOPボタン、RESETボタン押下で何もおきない
  • ...(略)...
  • time, stateをprivateにする
  • stateはStringで問題ないだろうか?

次は「time, stateをprivateにする」が簡単そうなので手をつけます。

time, stateをprivateにする

レッド

privateにしてgetterを追加したいので、これに対応するテストに変更します。

public class StopwatchTest {
  @Test
  public void testStopState() {
    Stopwatch stopwatch = new Stopwatch();
    assertEquals("IDLE", stopwatch.getState());
    assertEquals(0l, stopwatch.getTime());
  }
}

今のままではコンパイルエラーになるので修正を行います。

public class Stopwatch {

  // 変更
  private long time;
  private String state;

  public Stopwatch() {
    this.time = 0l;
    this.state = "IDLE";
  }

  // 追加
  public long getTime() {
    return 0l;
  }

  public String getState() {
    return "";
  }
}

フィールドの可視性をprivateに変更し、getterを追加しました。
コンパイルが通るようになりました。テストを実行します。。。レッドバーでした。
返却値はコンパイルを通すためだけに入れた仮の値なのでassertEquals("IDLE", stopwatch.getState());でエラーになります。
次のグリーンで修正します。

グリーン

テストを通すために、テストが通る値をベタ書きで実装します。仮実装します。

public class Stopwatch {

  public long getTime() {
    return 0l;
  }

  public String getState() {
    return "IDLE";
  }
}

※必要な箇所だけ実装を抜き出しています。以降、特に断らずに実装を必要な部分のみ抜き出すことがあります。

テストを実行します。。。グリーンバーでした。
このステップではグリーンバーを得ることが優先され、罪を犯すことが許されるので仮実装もOKです。
仮実装は次のステップですぐにリファクタリングしていくので問題ありません。グリーンバーは、レッドバーであることより気持ちを落ち着かせてくれます。安心してリファクタリングの作業に取り組めます。

リファクタリング

getTime(),getState()で返却する値をフィールドの値に変えます。

public class Stopwatch {

  private long time;
  private String state;

  public long getTime() {
    return this.time;
  }

  public String getState() {
    return this.state;
  }
}

テストを実行します。。。グリーンバーでした。
これで完了です。TODOリストにチェックを入れましょう。

  • 初期状態が停止中、計測値が0
  • 停止中でSTOPボタン、RESETボタン押下で何もおきない
  • ...(略)...
  • time, stateをprivateにする
  • stateはStringで問題ないだろうか?

stateの扱いはまだ置いて気になったら取り組むことにします。
次は「停止中でSTOPボタン、RESETボタン押下で何もおきない」に取り組みます。

停止中でSTOPボタン、RESETボタン押下で何もおきない

レッド

ストップウォッチのSTOPボタン、RESETボタンを押すかのようなインターフェイスがいいのでそれっぽいテストを書きます。

  @Test
  public void testStopState() {
    Stopwatch stopwatch = new Stopwatch();
    // -略-
    stopwatch.stop();
    stopwatch.reset();
    assertEquals("IDLE", stopwatch.getState());
    assertEquals(0l, stopwatch.getTime());
  }

コンパイルエラーになるのでこれを解消するためのコードを追加します。

public class Stopwatch {
  public void stop() {
  }

  public void reset() {
  }

}

テストを実行します。。。おっと、グリーンバーが現れました。
偶然にも何もしないことを期待していたので、いきなりグリーンバーにたどり着きました。

グリーン

すでにグリーンバーを得ていますので、本節はないです。

リファクタリング

リファクタリングが必要でしょうか?
停止中なら何もしない、ということが仕事であることを明示したいのでこれがわかる形にしたいです。コメントを追加します。
一瞬何もしないなら例外を投げるのはどうだろうか?と思いましたが、ストップウォッチのボタンはいつでも押せるのが通常で、停止中でもボタンが押せるのは一般的なことなので例外は投げないことにします。例外が発生する、エラーになる方がびっくりします。手元にあったEffective Java 第3版をチラッとみてみても「例外的状態にだけ例外を使う」と書いてありました。

  public void stop() {
    // if state is idle, do nothing.
  }

  public void reset() {
    // if state is idle, do nothing.
  }

テストを実行します。。。当然グリーンバーのままでした。
コメントを書いて思い出しましたが、そういえば停止中なら何もしない、を実装していたはずでした。
停止中なら、のif文が必要そうですが、現段階ではまだ停止中の状態しかなく影響がないので一旦先送りにします。
TODOリストに追加して、現在のタスクを完了にしましよう。

  • 初期状態が停止中、計測値が0
  • 停止中でSTOPボタン、RESETボタン押下で何もおきない
  • 停止中でSTARTボタン押下で状態が計測中に遷移する
  • ...(略)...
  • stateはStringで問題ないだろうか?
  • 状態による振る舞いの制御の仕方を考える(NEW)

次は「停止中でSTARTボタン押下で状態が計測中に遷移する」を実装していきます。

停止中でSTARTボタン押下で状態が計測中に遷移する

レッド

STARTボタン押下のテストケースを追加します。

  @Test
  public void testMeasuringState() {
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.start();
    assertEquals("MEASURING", stopwatch.getState());
  }

コンパイルが通るように実装を追加します。

public class Stopwatch {

  public void start() {
  }

}

テストを実行します。。。レッドバーでした。
次のステップに移りましょう。

グリーン

現在の状態を変える実装を加えます。
やるべきことが明らかなので今回は仮実装をせずに、そのまま実装します。

public class Stopwatch {

  public void start() {
    this.state = "MEASURING";
  }

}

テストを実行します。。。グリーンバーでした。
今回のような実装は、明白な実装と呼ばれます。
一旦ここで仮実装と明白な実装の定義を確認してみましょう。

  • 仮実装(p.217):コードでまずベタ書きの値を使い、実装を進めるに従って、徐々に変数に置き換えていく。
  • 明白な実装(p.221):すぐに頭の中の実装をコードに落とす。

テスト駆動開発 p.16より引用。
(p.217)(p.221)はそのページで仮実装、明白な実装の詳しい説明がされています。

次のステップに移ります。

リファクタリング

リファクタリングは必要でしょうか?
前回の停止中でSTOPボタン、RESETボタン押下で何もおきない同様に、停止中ならのifが必要そうですがまだ他の状態を実装していないので置いておきます。
現在のタスクを完了にします。

  • 初期状態が停止中、計測値が0
  • 停止中でSTOPボタン、RESETボタン押下で何もおきない
  • 停止中でSTARTボタン押下で状態が計測中に遷移する
  • 計測中でSTARTボタン、RESETボタン押下で何もおきない
  • ...(略)...
  • stateはStringで問題ないだろうか?
  • 状態による振る舞いの制御の仕方を考える

次は「計測中でSTARTボタン、RESETボタン押下で何もおきない」に移ります。

計測中でSTARTボタン、RESETボタン押下で何もおきない

レッド

テストケースを追加します。

  @Test
  public void testMeasuringState() throws Exception {
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.start();
    assertEquals("MEASURING", stopwatch.getState());

    // 計測中でSTARTボタン押下
    long firstTime = stopwatch.getTime();
    stopwatch.start();
    // ★sleepを挟むことで、計測し続けているなら絶対に以前取得した計測値より大きくなるはず。
    Thread.sleep(10l);
    long secondTime = stopwatch.getTime();
    assertEquals(true, firstTime < secondTime);
    assertEquals("MEASURING", stopwatch.getState());

    // 計測中でRESETボタン押下
    stopwatch.reset();
    Thread.sleep(10l);
    long thirdTime = stopwatch.getTime();
    assertEquals(true, secondTime < thirdTime);
    assertEquals("MEASURING", stopwatch.getState());
  }

ちょっとコード量が多いので整理します。以下を確認するテストケースです。

  • 計測中でSTARTボタン押下で計測が途切れずに続いていること
    • 状態が計測中のままである
    • 計測値はSTARTボタン再押下後の値が、再押下前の値より大きい(計測が続行されている)
  • 計測中でSRESETボタン押下で計測が途切れずに続いていること
    • 状態が計測中のままである
    • 計測値はRESETボタン再押下後の値が、再押下前の値より大きい(計測が続行されている)

未実装のメソッド呼び出しはないのでコンパイルエラーにはなりません。
テストを実行しみます。。。レッドバーでした。

計測値を更新せずに0を返し続けているので、そこでエラーになっています。

// 関係ある箇所の現在の実装
public class Stopwatch {
  public Stopwatch() {
    this.time = 0l;
    this.state = "IDLE";
  }

  public long getTime() {
    return this.time;
  }

  public void start() {
    this.state = "MEASURING";
  }

}

ここを次のグリーン通すようにします。ですが、その前にTDDの威力を感じたのでちょっとだけ。。

stopwatch.getTime()で計測中の値も取得するようにしていますが、
もしTDDでなかったら、呼び出し側目線からの開発をしていなかったら、次の実装をしてしまいそうです。

  • stopwatch.getTime()を使うのではなく、新しく計測中の値を返却するメソッドを追加する
  • stopwatch.getTime()は確定した値、停止中または結果表示中の値取得で使う

既にあるgetterをいじったことがあんまりないからか、新メソッド追加でなんとなくやってしまいそうです。ですが、現実のストップウォッチを考えると、状態に応じて現在の値を確認する手段がかわるでしょうか。そうではなく常に一つのインターフェイス、画面で現在の値が表示され続けているように思います。これを考えると一つのpublicなメソッドから常に現在の値を取得できる実装になっているのが自然で使いやすいコードだと思います。また闇雲にpublicなメソッドを追加するのもあまり良くないでしょう。

特に意識せずにstopwatch.getTime()で計測中の値も取得するようにしました。
呼び出し側から実装を考えることで、使い易い形、現実に即していて理解しやすい形、にたどりつくことができました。

グリーン

複雑な問題に直面する予感がし若干不安なので、今回は仮実装から始めます。
まずはグリーンバーという心理的に安全な領域を手に入れにいきます。

問題はtimeが0を返し続けていることで発生しているので、状態が計測中の場合はgetTime()で常に現在のシステム時間を返すように仮実装します。

  public long getTime() {
    if (this.state.equals("MEASURING")) {
      return System.currentTimeMillis();
    } else {
      return this.time;
    }
  }

テストを実行します。。。グリーンバーの取得に成功しました。安心を手に入れました。
一旦ここまでの実装を置いておきます。リファクタリングに進みます。

public class Stopwatch {

  private long time;
  private String state;

  public Stopwatch() {
    this.time = 0l;
    this.state = "IDLE";
  }

  public long getTime() {
    if (this.state.equals("MEASURING")) {
      return System.currentTimeMillis();
    } else {
      return this.time;
    }
  }

  public String getState() {
    return this.state;
  }

  public void stop() {
    // if state is idle, do nothing.
  }

  public void reset() {
    // if state is idle, do nothing.
  }

  public void start() {
    this.state = "MEASURING";
  }

}
public class StopwatchTest {
  @Test
  public void testStopState() {
    Stopwatch stopwatch = new Stopwatch();
    assertEquals("IDLE", stopwatch.getState());
    assertEquals(0l, stopwatch.getTime());
    stopwatch.stop();
    stopwatch.reset();
    assertEquals("IDLE", stopwatch.getState());
    assertEquals(0l, stopwatch.getTime());
  }

  @Test
  public void testMeasuringState() throws Exception {
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.start();
    assertEquals("MEASURING", stopwatch.getState());

    // 計測中でSTARTボタン押下
    long firstTime = stopwatch.getTime();
    stopwatch.start();
    Thread.sleep(10l);
    long secondTime = stopwatch.getTime();
    assertEquals(true, firstTime < secondTime);
    assertEquals("MEASURING", stopwatch.getState());

    // 計測中でRESETボタン押下
    stopwatch.reset();
    Thread.sleep(10l);
    long thirdTime = stopwatch.getTime();
    assertEquals(true, secondTime < thirdTime);
    assertEquals("MEASURING", stopwatch.getState());
  }
}

リファクタリング

さて、計測中のgetTime()で返して欲しい値はSTARTボタン押下時からどれだけ時間が経ったか、です。
これを計算する式は、現在の時間 - STARTボタン押下時の時間となります。
このためSTARTボタン押下時の値を保持する必要があるので、まずこの実装を加えましょう。

public class Stopwatch {

  private long time;
  private String state;
  private long startTime; // ★追加

  public void start() {
    this.state = "MEASURING";
    this.startTime = System.currentTimeMillis(); //★追加
  }

}

テストを実行します。。。グリーンバーのままです。
ではgetTime()で、現在の時間 - STARTボタン押下時の時間、の値を返すようにします。

public class Stopwatch {
  public long getTime() {
    if (this.state.equals("MEASURING")) {
      return System.currentTimeMillis() - this.startTime;
    } else {
      return this.time;
    }
  }
}

テストを実行します。。。グリーンバーです!
停止中の状態の返却値ですが、

else {
      return this.time;
    }

常に0を返却することに気づいたので、そのように修正します。

  public long getTime() {
    if (this.state.equals("MEASURING")) {
      return System.currentTimeMillis() - this.startTime;
    } else {
      return 0l;
    }
  }

テストを通します。。。グリーンバーです。
この修正によりフィールドのtimeが不要になったので消します。コンストラクラの初期化も消します。
ついでにstartTimeですが、0で初期しておきます。明示的に0を入れておきたい気持ちです。

public class Stopwatch {

  private String state;
  private long startTime;

  public Stopwatch() {
    this.state = "IDLE";
    this.startTime = 0l;
  }

}

テストを通します。。。グリーンバーでした。
TODOリストにチェックを入れます。

  • 初期状態が停止中、計測値が0
  • 停止中でSTOPボタン、RESETボタン押下で何もおきない
  • 停止中でSTARTボタン押下で状態が計測中に遷移する
  • 計測中でSTARTボタン、RESETボタン押下で何もおきない
  • 計測中でSTOPボタン押下で状態が結果表示中に遷移する
  • ...(略)...
  • stateはStringで問題ないだろうか?
  • 状態による振る舞いの制御の仕方を考える

次は「計測中でSTOPボタン押下で状態が結果表示中に遷移する」に取り組みます。

計測中でSTOPボタン押下で状態が結果表示中に遷移する

レッド

テストケースを追加します。

@Test
  public void testMeasuringState() throws Exception {
    // -略-
    // 計測中でSTOPボタン押下
    long stoppedTime = stopwatch.getTime();
    stopwatch.stop();
    Thread.sleep(10l);
    assertEquals("RESULT", stopwatch.getState());
    assertEquals(stoppedTime, stopwatch.getTime());
  }

STOPボタン押下時に期待する動作は下記です。

  • 状態が結果表示中に遷移する。
  • stop()時に計測値を保存する。
  • stop()呼び出し以降、計測値が更新されない。

実行します。。。レッドバーです。

グリーン

今回は仮実装をはさまないで実装します。
特に不安がないので一気に明白な実装にジャンプします。

public class Stopwatch {

  private String state;
  private long startTime;
  private long time; // 復活

  public Stopwatch() {
    this.state = "IDLE";
    this.startTime = 0l;
    this.time = 0l; // 復活
  }

  // -略-
  public void stop() {
    if (state.equals("MEASURING")) {
      this.state = "RESULT";
      this.time = System.currentTimeMillis() - this.startTime;
    } else if (state.equals("IDLE")) {
      // if state is idle, do nothing.
    }
  }

下記の実装を加えました。

  • 停止ボタン押下時に計測値を保存する必要があったので、前節で消したtimeを復活
  • stop()に計測中なら状態を結果表示中に変更する、計測値を保存する実装を追加

テストを動かしてみます。。。

レッドバーでした。。。あれっ?
一気にジャンプしすぎたようです。スタックトレースを確認します。

org.opentest4j.AssertionFailedError: expected: <21> but was: <0>
  at org.junit.jupiter.api.AssertionUtils.fail(AssertionUtils.java:55)

expected: <21> but was: <0>ここが曲者のようです。
どうやら21を期待しているのに0だったと。。

なるほど、そういえばgetTime()で、結果表示中の状態の計測値を取得する実装がないことに気づきました。これがなければ計測中 -> 結果表示中の遷移時に正しく計測値を保存できているか確認できません。実装を加えます。

public long getTime() {
    if (this.state.equals("MEASURING")) {
      return System.currentTimeMillis() - this.startTime;
    } else if (this.state.equals("RESULT")) {
      // ★追加
      return this.time;
    } else {
      return 0l;
    }
  }

テストが成功するのを祈りつつ実行します。。。グリーンバーでした。
一旦ここで区切ります。
いろんなところでstateによる制御が出てきたので、「状態による振る舞いの制御の仕方を考える」に移ります。リファクタリングはその節で行います。

一旦TODOリストにチェックを入れます。

  • 初期状態が停止中、計測値が0
  • 停止中でSTOPボタン、RESETボタン押下で何もおきない
  • 停止中でSTARTボタン押下で状態が計測中に遷移する
  • 計測中でSTARTボタン、RESETボタン押下で何もおきない
  • 計測中でSTOPボタン押下で状態が結果表示中に遷移する
  • ...(略)...
  • stateはStringで問題ないだろうか?
  • 状態による振る舞いの制御の仕方を考える,stop,reset,startに制御追加

状態による振る舞いの制御の仕方を考える

Stateパターンを使うのが記事の趣旨の一つなのでここでパターンを適用したいのですが、まずは今の実装の問題点を明らかにしたいです。stateによる状態分岐がある実装を確認します。

public class Stopwatch {
  public long getTime() {
    if (this.state.equals("MEASURING")) {
      return System.currentTimeMillis() - this.startTime;
    } else if (this.state.equals("RESULT")) {
      return this.time;
    } else {
      return 0l;
    }
  }

  public void stop() {
    if (state.equals("MEASURING")) {
      this.state = "RESULT";
      this.time = System.currentTimeMillis() - this.startTime;
    } else if (state.equals("IDLE")) {
      // if state is idle, do nothing.
    }
  }

  public void start() {
    if (this.state.equals("IDLE")) {
      this.state = "MEASURING";
      this.startTime = System.currentTimeMillis();
    }
  }
}

眺めてみると下記の問題が見えてきます。

  • 状態による分岐が散らばっているため、変更を入れる場合あちこちに手を入れる必要がある。重要な変更を実装し忘れる可能性が出てくる。実際、先ほど実装し忘れにより想定外のレッドバーが出た。
  • その状態に依る実装がどこにあるかちょっとわかりづらい。ifの条件を確認しないとどこに修正をいれるべきかわからない。

さて、どんな道具を使えば問題が解決できるでしょうか?
Stateパターンの説明をみてみましょう。

◉目的
オブジェクトの内部状態が変化したときに、オブジェクトが振る舞いを変えるようにする。クラス内では、振る舞いの変化を記述せず、状態を表すオブジェクトを導入することでこれを実現する。

◉適用可能性
次に示すいずれかの場合に、Stateパターンを利用する。
(中略)
●オペレーションが、オブジェクトの状態に依存した多岐にわたる条件文を持っている場合。
(中略)Stateパターンでは、1つ1つの条件分岐を別々のクラスに受け持たせる。(後略)

◉結果
Stateパターンは次のような結果をもたらす。

  1. 状態に依存した振る舞いを局所化し、状態ごとに振る舞いを分割する。(後略)

オブジェクト指向における再利用のためのデザインパターン(改訂版)pp.325-327より

  • クラス内では振る舞いの変化を記述せず、状態を表すオブジェクトを導入することでこれを実現する
  • Stateパターンでは、1つ1つの条件分岐を別々のクラスに受け持たせる
  • 状態に依存した振る舞いを局所化し、状態ごとに振る舞いを分割する

-> 状態による条件分岐を1つ1つのクラスに押し込めそう、カプセル化できそうです。

なんだか現在直面している問題に対して有効そうなので、Stateパターンを使ってリファクタリングします。しかし大手術になるので、Stateパターンへの実装を入れ替えはすべて、具象クラスを直接呼び出す形で実装 -> インターフェイス呼び出しに実装を変更、という流れでリファクタリングを行います。振る舞いは変えずに内部構造を変える行為なので、テストは既に書いてあるものを利用していきます。

リファクタリング

手術第1弾: State具象クラスを呼び出すように実装を変える

まず、全ての状態クラスが実装するStateインターフェイスを作ります。
一旦、条件分岐がすくないstartメソッドをStateインターフェイスに移すことから始めます。

interface State {
  public void start(Stopwatch stopwatch);
}
// ------------------------------------------
public class Stopwatch {
  public void start() {
    if (this.state.equals("IDLE")) {
      this.state = "MEASURING";
      this.startTime = System.currentTimeMillis();
    }
  }
}

それぞれの状態分、Stateの具象クラスを追加します。

public class InitState implements State {
  @Override
  public void start(Stopwatch stopwatch) {
    // TODO 自動生成されたメソッド・スタブ
  }
}
// ------------------------------------------
public class MeasuringState implements State {
  @Override
  public void start(Stopwatch stopwatch) {
    // TODO 自動生成されたメソッド・スタブ
  }
}
// ------------------------------------------
public class ResultState implements State {
  @Override
  public void start(Stopwatch stopwatch) {
    // TODO 自動生成されたメソッド・スタブ
  }
}
  • 停止中、初期状態のクラスをInitStateと命名しました。
    • 今まで"IDLE"としていたけど、なんかしっくりこなかったので。結果表示中 -> RESETボタン押下で初期状態にもどるっていう風に考えたら、Initがいい感じかなと思いました。
  • 計測中のクラスをMeasuringState, 結果表示中のクラスをResultStateとしました。

stopwatch.start()の処理をInitStateに移します。

public class Stopwatch {

  public void start() {
    new InitState().start(this);
  }
}
// ------------------------------------------
public class InitState implements State {
  @Override
  public void start(Stopwatch stopwatch) {
    this.state = "MEASURING";
    this.startTime = System.currentTimeMillis();
  }
}
// ------------------------------------------
public class MeasuringState implements State {
  @Override
  public void start(Stopwatch stopwatch) {
    // do nothing.
  }
}

stopwatchの変数をInitStateから弄れるようにsetterを追加します。

public class Stopwatch {

  private String state;
  private long startTime;

  void setState(String state) {
    this.state = state;
  }

  void setStartTime(long startTime) {
    this.startTime = startTime;
  }
// ------------------------------------------
public class InitState implements State {

  @Override
  public void start(Stopwatch stopwatch) {
    stopwatch.setState("MEASURING");
    stopwatch.setStartTime(System.currentTimeMillis());
  }

}

ここで一旦テストを動かします。。。グリーンバーでした。

次はstopwatch.stop()の手術に手をつけます。
Stateインターフェイスにstop()メソッドを追加し、stopwatch.stop()の処理を移します。

interface State {
  public void stop(Stopwatch stopwatch);
}
// ------------------------------------------
public class InitState implements State {
  @Override
  public void stop(Stopwatch stopwatch) {
    // do nothing.
  }
// ------------------------------------------
public class MeasuringState implements State {
  @Override
  public void stop(Stopwatch stopwatch) {
    this.state = "RESULT";
    this.time = System.currentTimeMillis() - this.startTime;
  }
// ------------------------------------------
public class ResultState implements State {
  @Override
  public void stop(Stopwatch stopwatch) {
    // TODO 自動生成されたメソッド・スタブ
  }
}

StopwatchにstartTimeのgetter, timeのsetterを追加します。
MeasuringStateのstop()でstopwatchのtimeの計算/設定をできるようにするためです。

public class Stopwatch {

  private long startTime;
  private long time;

  long getStartTime() {
    return this.startTime;
  }

  void setTime(long time) {
    this.time = time;
  }

  void setState(String state) {
    this.state = state;
  }
}
// ------------------------------------------
public class MeasuringState implements State {

  @Override
  public void stop(Stopwatch stopwatch) {
    stopwatch.setState("RESULT");
    stopwatch.setTime(System.currentTimeMillis() - stopwatch.getStartTime());
  }
}

Stopwatchのstop()からState具象クラスのメソッドを呼び出すようにします。

public class Stopwatch {

  public void stop() {
    if (state.equals("MEASURING")) {
      new MeasuringState().stop(this);
    } else if (state.equals("IDLE")) {
      new InitState().stop(this);
    }
  }

}

手術完了です。テストを実行します。。。グリーンバーでした。
次にstopwatch.getTime()を考えます。やることは大体同じなので一気に実装します。

public class Stopwatch {

  private long startTime;
  private long time;

  long getStartTime() {
    return this.startTime;
  }

  // ResultStateからtimeを使う都合でgetter追加。
  long getTimeInner() {
    return this.time;
  }

  public long getTime() {
    if (this.state.equals("MEASURING")) {
      return new MeasuringState().getTime(this);
    } else if (this.state.equals("RESULT")) {
      return new ResultState().getTime(this);
    } else {
      return new InitState().getTime(this);
    }
  }
}
// ------------------------------------------
interface State {
  public long getTime(Stopwatch stopwatch);
}
// ------------------------------------------
public class InitState implements State {
  @Override
  public long getTime(Stopwatch stopwatch) {
    return 0l;
  }
}
// ------------------------------------------
public class MeasuringState implements State {
  @Override
  public long getTime(Stopwatch stopwatch) {
    return System.currentTimeMillis() - stopwatch.getStartTime();
  }
}
// ------------------------------------------
public class ResultState implements State {
  @Override
  public long getTime(Stopwatch stopwatch) {
    return stopwatch.getTimeInner();
  }
}

テストを実行します。。。グリーンバーでした。
stopwatch.getState()を考えます。
このメソッドは現在の状態を示す値を返します。これも状態に依る実装なのでStateクラスに処理を委譲しましょう。

public class Stopwatch {
  public String getState() {
    if (this.state.equals("MEASURING")) {
      return new MeasuringState().getState();
    } else if (this.state.equals("RESULT")) {
      return new ResultState().getState();
    } else {
      return new InitState().getState();
    }
  }
}
// ------------------------------------------
interface State {
  public String getState();
}
// ------------------------------------------
public class InitState implements State {
  @Override
  public String getState() {
    return "IDLE";
  }
}
// ------------------------------------------
public class MeasuringState implements State {
  @Override
  public String getState() {
    return "MEASURING";
  }
}
// ------------------------------------------
public class ResultState implements State {
  @Override
  public String getState() {
    return "RESUlT";
  }
}

テストを動かします。。。グリーンバーでした。
手術第一弾が終わりました。

手術第2弾: Stateインターフェイスを呼び出す実装に変える

Stateを使用している箇所を抜き出してコードを見てみます。

public class Stopwatch {

  private String state;
  private long startTime;
  private long time;

  void setState(String state) {
    this.state = state;
  }

  public long getTime() {
    if (this.state.equals("MEASURING")) {
      return new MeasuringState().getTime(this);
    } else if (this.state.equals("RESULT")) {
      return new ResultState().getTime(this);
    } else {
      return new InitState().getTime(this);
    }
  }

  public String getState() {
    if (this.state.equals("MEASURING")) {
      return new MeasuringState().getState();
    } else if (this.state.equals("RESULT")) {
      return new ResultState().getState();
    } else {
      return new InitState().getState();
    }
  }

  public void stop() {
    if (state.equals("MEASURING")) {
      new MeasuringState().stop(this);
    } else if (state.equals("IDLE")) {
      new InitState().stop(this);
    }
  }

  public void start() {
    new InitState().start(this);
  }
}

Stopwatch内のifを消したいです。ポリモフィズムを使います。
State具象クラスを使っている箇所をインターフェイスの呼び出しに変えます。
クラス内でStateインターフェイスを持ち、状態遷移時に具象クラスを入れ替えていきます。
private String state;private State state;に変更し、setState()呼び出し時に次に遷移するState具象クラスを設定します。

public class Stopwatch {

  private State state;
  private long startTime;
  private long time;

  public Stopwatch() {
    this.state = new InitState();
    this.startTime = 0l;
    this.time = 0l;
  }

  public long getTime() {
    return this.state.getTime(this); // インターフェイスの呼び出し
  }

  public String getState() {
    return this.state.getState(); // インターフェイスの呼び出し
  }

  void setState(State state) {
    this.state = state;
  }

  public void stop() {
    this.state.stop(this); // インターフェイスの呼び出し
  }

  public void start() {
    this.state.start(this); // インターフェイスの呼び出し
  }
}
// ------------------------------------------
public class InitState implements State {

  private final MeasuringState measuringState = new MeasuringState();

  @Override
  public void start(Stopwatch stopwatch) {
    stopwatch.setState(this.measuringState); // 次に遷移する状態を設定
    stopwatch.setStartTime(System.currentTimeMillis());
  }
}
// ------------------------------------------
public class MeasuringState implements State {

  private final ResultState resultState = new ResultState();

  @Override
  public void stop(Stopwatch stopwatch) {
    stopwatch.setState(this.resultState); // 次に遷移する状態を設定
    stopwatch.setTime(System.currentTimeMillis() - stopwatch.getStartTime());
  }
}

テストを実行します。。。グリーンバーでした。
State具象クラス内で次の状態遷移先のインスタンスを保持していますが、

public class InitState implements State {
  private final MeasuringState measuringState = new MeasuringState();
}

public class MeasuringState implements State {
  private final ResultState resultState = new ResultState();
}

Stopwatchに状態クラスのインスタンスを保持していて、その状態に遷移する方が自然な感じがします。状態自体はStopwatch内部にあるはずです。
また、InitState -> MeasuringState, ResultState -> MeasuringStateと、MeasuringStateへの遷移が2回あります。まだ後者の実装がないので表面化していないですが、将来的にprivate final MeasuringState measuringState = new MeasuringState();が重複しそうです。
Stopwatchに状態クラスのインスタンスを移動させます。

public class Stopwatch {

  private final MeasuringState measuringState = new MeasuringState();
  private final ResultState resultState = new ResultState();

  MeasuringState getMeasuringState() {
    return this.measuringState;
  }

  ResultState getResultState() {
    return this.resultState;
  }
// ------------------------------------------
public class InitState implements State {
  @Override
  public void start(Stopwatch stopwatch) {
    stopwatch.setState(stopwatch.getMeasuringState());
    stopwatch.setStartTime(System.currentTimeMillis());
  }
}
// ------------------------------------------
public class MeasuringState implements State {
  @Override
  public void stop(Stopwatch stopwatch) {
    stopwatch.setState(stopwatch.getResultState());
    stopwatch.setTime(System.currentTimeMillis() - stopwatch.getStartTime());
  }

テストを実行します。。。グリーンバーです。

そういえば、これまでstopwatch.reset()の期待動作が全て何もしないことだったので具体的な実装はありませんが、これも状態により振る舞いが変わる箇所なのでStateインターフェイスに処理を委譲します。

interface State {
  public void reset(Stopwatch stopwatch);
}
// ------------------------------------------
public class Stopwatch {
    public void reset() {
    this.state.reset(this);
  }
}
// ------------------------------------------
public class InitState implements State {
  @Override
  public void reset(Stopwatch stopwatch) {
    // do nothing.
  }
}
// ------------------------------------------
public class MeasuringState implements State {
  @Override
  public void reset(Stopwatch stopwatch) {
    // do nothing.
  }
}
// ------------------------------------------
public class ResultState implements State {
  @Override
  public void reset(Stopwatch stopwatch) {
    // TODO 自動生成されたメソッド・スタブ
  }
}

テストを実行します。。。グリーンバーです。
ここまでで概ねStateパターンへの変更は完了です。
最後にgetState()で返している型がStringなのがちょっと嫌なのでenumに変更します。定数にはenumを使いたい気持ちです。

interface State {
  public String getState();
}

まずテストを修正します。 こんな感じで書いてたのを

assertEquals("MEASURING", stopwatch.getState());

下記に修正します。

assertEquals(StopwatchState.MEASURING, stopwatch.getState());

コンパイルエラーになるのでenumを作ります。

public enum StopwatchState {
  INIT, MEASURING, RESULT;
}

テストを実行します。。。レッドバーです。
ではgetState()で今作ったenumを返却するように修正します。

interface State {
  public StopwatchState getState();
}
// ------------------------------------------
public class InitState implements State {
  @Override
  public StopwatchState getState() {
    return StopwatchState.INIT;
  }
}
// ------------------------------------------
public class MeasuringState implements State {
  @Override
  public StopwatchState getState() {
    return StopwatchState.MEASURING;
  }
}
// ------------------------------------------
public class ResultState implements State {
  @Override
  public StopwatchState getState() {
    return StopwatchState.RESULT;
  }
}
// ------------------------------------------
public class Stopwatch {
  public StopwatchState getState() {
    return this.state.getState();
  }
}

テストを実行します。。。グリーンバーです。
これでStateパターンの適用は完了です。TODOリストにチェックを入れます。振る舞いの制御を変えて、stateの型を変えました。
次は「結果表示中の計測値は変化しない」に取り組みますが、一旦今回の振り返りを挟みます。

  • 初期状態が停止中、計測値が0
  • 停止中でSTOPボタン、RESETボタン押下で何もおきない
  • 停止中でSTARTボタン押下で状態が計測中に遷移する
  • 計測中でSTARTボタン、RESETボタン押下で何もおきない
  • 計測中でSTOPボタン押下で状態が結果表示中に遷移する
  • 結果表示中の計測値は変化しない
  • ...(略)...
  • stateはStringで問題ないだろうか?
  • 状態による振る舞いの制御の仕方を考える

振り返り

最初にあげた問題点が解決できたか確認してみます。
stateによる状態分岐があった実装を確認します。

public class Stopwatch {

  private State state;

  void setState(State state) {
    this.state = state;
  }

  public Stopwatch() {
    this.state = new InitState();
    this.startTime = 0l;
    this.time = 0l;
  }

  public long getTime() {
    return this.state.getTime(this);
  }

  public StopwatchState getState() {
    return this.state.getState();
  }

  public void stop() {
    this.state.stop(this);
  }

  public void start() {
    this.state.start(this);
  }

  public void reset() {
    this.state.reset(this);
  }
}
  • 状態による分岐が散らばっているため、変更を入れる場合あちこちに手を入れる必要がある。重要な変更を実装し忘れる可能性が出てくる。実際、先ほど実装し忘れにより想定外のレッドバーが出た。
  • その状態に依る実装がどこにあるかちょっとわかりづらい。ifの条件を確認しないとどこに修正をいれるべきかわからない。

大分スッキリしましたね。状態依存の実装をStateの具象クラスにまとめられたので、問題を解決できています。

結果表示中の計測値は変化しない

グリーン

期待動作がなにもおこらないことで、特に実装することがないので、グリーンから始めます。
テストを追加します。

  public void testResultState() throws Exception {
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.start();
    stopwatch.stop();
    assertEquals(StopwatchState.RESULT, stopwatch.getState());
    long time = stopwatch.getTime();
    Thread.sleep(10l);
    assertEquals(time, stopwatch.getTime());
  }

テストを実行します。。。グリーンでした。
TODOリストにチェックをいれて「結果表示中でSTOPボタン押下で何もおきない」に進みます。

  • 初期状態が停止中、計測値が0
  • 停止中でSTOPボタン、RESETボタン押下で何もおきない
  • 停止中でSTARTボタン押下で状態が計測中に遷移する
  • 計測中でSTARTボタン、RESETボタン押下で何もおきない
  • 計測中でSTOPボタン押下で状態が結果表示中に遷移する
  • 結果表示中の計測値は変化しない
  • 結果表示中でSTOPボタン押下で何もおきない
  • ...(略)...
  • stateはStringで問題ないだろうか?
  • 状態による振る舞いの制御の仕方を考える

結果表示中でSTOPボタン押下で何もおきない

グリーン

またしても特に実装することはないのでグリーンから始めます。
テストを追加します。

  @Test
  public void testResultState() throws Exception {
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.start();
    stopwatch.stop();
    assertEquals(StopwatchState.RESULT, stopwatch.getState());
    long time = stopwatch.getTime();
    Thread.sleep(10l);
    assertEquals(time, stopwatch.getTime());

    // 追加
    stopwatch.stop();
    Thread.sleep(10l);
    assertEquals(time, stopwatch.getTime());
    assertEquals(StopwatchState.RESULT, stopwatch.getState());
  }

テストを実行します。。。グリーンでした。
コメントで何もしないってことだけ書いおきます。

public class ResultState implements State {
  @Override
  public void stop(Stopwatch stopwatch) {
    // do nothing.
  }
}

テストを実行します。。。コメントを追加しただけなのでもちろんグリーンのままでした。
リファクタリングする内容がないので、TODOリストにチェックをいれて「結果表示中でSTARTボタン押下で計測中に遷移する」に進みます。

  • 初期状態が停止中、計測値が0
  • 停止中でSTOPボタン、RESETボタン押下で何もおきない
  • 停止中でSTARTボタン押下で状態が計測中に遷移する
  • 計測中でSTARTボタン、RESETボタン押下で何もおきない
  • 計測中でSTOPボタン押下で状態が結果表示中に遷移する
  • 結果表示中の計測値は変化しない
  • 結果表示中でSTOPボタン押下で何もおきない
  • 結果表示中でSTARTボタン押下で計測中に遷移する
  • 結果表示中でRESETボタン押下で停止中に遷移する
  • time, stateをprivateにする
  • stateはStringで問題ないだろうか?
  • 状態による振る舞いの制御の仕方を考える

結果表示中でSTARTボタン押下で計測中に遷移する

レッド

テストを追加します。

  @Test
  public void testResultState() throws Exception {
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.start();
    stopwatch.stop();
    assertEquals(StopwatchState.RESULT, stopwatch.getState());
    long time = stopwatch.getTime();
    Thread.sleep(10l);
    assertEquals(time, stopwatch.getTime());

    stopwatch.stop();
    Thread.sleep(10l);
    assertEquals(time, stopwatch.getTime());
    assertEquals(StopwatchState.RESULT, stopwatch.getState());

    // 追加
    stopwatch.start();
    assertEquals(StopwatchState.MEASURING, stopwatch.getState());
  }

テストを実行します。。。レッドバーでした。
テストを考えて気づいたのですが、計測中に遷移する際に状態を変えるのに加えて、計測時間にいままで記録した時間を足せるようにしないとだめですね。
TODOリストに加えます。

  • 初期状態が停止中、計測値が0
  • 停止中でSTOPボタン、RESETボタン押下で何もおきない
  • 停止中でSTARTボタン押下で状態が計測中に遷移する
  • 計測中でSTARTボタン、RESETボタン押下で何もおきない
  • 計測中でSTOPボタン押下で状態が結果表示中に遷移する
  • 結果表示中の計測値は変化しない
  • 結果表示中でSTOPボタン押下で何もおきない
  • 結果表示中でSTARTボタン押下で計測中に遷移する
  • 結果表示中 -> 計測中で、計測時間に記録済みの時間を足せるようにする(NEW)
  • 結果表示中でRESETボタン押下で停止中に遷移する
  • time, stateをprivateにする
  • stateはStringで問題ないだろうか?
  • 状態による振る舞いの制御の仕方を考える

グリーン

実装を追加します。

public class ResultState implements State {

  @Override
  public void start(Stopwatch stopwatch) {
    stopwatch.setState(stopwatch.getMeasuringState());
  }
}

テストを実行します。。。グリーンバーです。

ここも特にリファクタリングすることないので「結果表示中 -> 計測中で、計測時間に記録済みの時間を足せるようにする」に進みます。TODOリストにチェックを入れます。

  • 初期状態が停止中、計測値が0
  • 停止中でSTOPボタン、RESETボタン押下で何もおきない
  • 停止中でSTARTボタン押下で状態が計測中に遷移する
  • 計測中でSTARTボタン、RESETボタン押下で何もおきない
  • 計測中でSTOPボタン押下で状態が結果表示中に遷移する
  • 結果表示中の計測値は変化しない
  • 結果表示中でSTOPボタン押下で何もおきない
  • 結果表示中でSTARTボタン押下で計測中に遷移する
  • 結果表示中 -> 計測中で、計測時間に記録済みの時間を足せるようにする
  • 結果表示中でRESETボタン押下で停止中に遷移する
  • time, stateをprivateにする
  • stateはStringで問題ないだろうか?
  • 状態による振る舞いの制御の仕方を考える

結果表示中 -> 計測中で、計測時間に記録済みの時間を足せるようにする

レッド

テストを追加します。

  @Test
  public void testResultThenStart() throws Exception {
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.start();
    Thread.sleep(10l);
    stopwatch.stop();
    stopwatch.start();
    Thread.sleep(10l);
    long time = stopwatch.getTime();
    stopwatch.stop();
    assertTrue(20l <= time && time <= 25l);
  }

計測中 -> 10ms -> 結果表示中 -> 計測中 -> 10ms -> 計測時間取得 -> 結果表示中 と遷移しているのでだいたい20msのはずです。時間の計算等があるので20msぴったりに収めることが難しそうなので、20ms <= time <= 25 ならよしとします。記録済みの計測時間が足されているのを確認するのが趣旨なので、これでもいいでしょう。(いいということにしたい)

テストを実行します。。。レッドバーでした。

グリーン

ここの実装を変えればどうにかなりそうです。

public class MeasuringState implements State {

  @Override
  public void stop(Stopwatch stopwatch) {
    stopwatch.setState(stopwatch.getResultState());
    stopwatch.setTime(System.currentTimeMillis() - stopwatch.getStartTime());
  }

  @Override
  public long getTime(Stopwatch stopwatch) {
    return System.currentTimeMillis() - stopwatch.getStartTime();
  }

}
// ------------------------------------------
public class ResultState implements State {
  @Override
  public void start(Stopwatch stopwatch) {
    stopwatch.setState(stopwatch.getMeasuringState());
  }
}

現在の時間 - STARTボタン押下時の時間と計算しているのを
現在の時間 - STARTボタン押下時の時間 + 今までの計測時間 と変更することでうまいこと計算できそうです。
計算するためには、結果表示中 -> 計測中遷移時にSTARTボタン押下時の時間を更新する実装を追加する必要があるのでこれも追加します。

public class MeasuringState implements State {

  @Override
  public void stop(Stopwatch stopwatch) {
    stopwatch.setState(stopwatch.getResultState());
    //                      現在の時間       -   STARTボタン押下時の時間   +  今までの計測時間
    stopwatch.setTime(System.currentTimeMillis() - stopwatch.getStartTime() + stopwatch.getTimeInner());
  }

  @Override
  public long getTime(Stopwatch stopwatch) {
    return System.currentTimeMillis() - stopwatch.getStartTime() + stopwatch.getTimeInner();
  }
}
// ------------------------------------------
public class ResultState implements State {
  @Override
  public void start(Stopwatch stopwatch) {
    stopwatch.setState(stopwatch.getMeasuringState());
    // 追加 STARTボタン押下時の時間を更新
    stopwatch.setStartTime(System.currentTimeMillis()); 
  }
}

テストを実行します。。。グリーンバーです。

リファクタリング

System.currentTimeMillis() - stopwatch.getStartTime() + stopwatch.getTimeInner()この計算を2箇所で行っていて、実装が重複していることに気づいたのでメソッドに抽出します。

public class MeasuringState implements State {

  @Override
  public void stop(Stopwatch stopwatch) {
    stopwatch.setState(stopwatch.getResultState());
    stopwatch.setTime(calculateTime(stopwatch));
  }

  private long calculateTime(Stopwatch stopwatch) {
    return System.currentTimeMillis() - stopwatch.getStartTime() + stopwatch.getTimeInner();
  }

  @Override
  public long getTime(Stopwatch stopwatch) {
    return calculateTime(stopwatch);
  }
}

テストを実行します。。。グリーンバーです。
TODOリストにチェックを入れて「結果表示中でRESETボタン押下で停止中に遷移する」へ進みます。

  • 初期状態が停止中、計測値が0
  • 停止中でSTOPボタン、RESETボタン押下で何もおきない
  • 停止中でSTARTボタン押下で状態が計測中に遷移する
  • 計測中でSTARTボタン、RESETボタン押下で何もおきない
  • 計測中でSTOPボタン押下で状態が結果表示中に遷移する
  • 結果表示中の計測値は変化しない
  • 結果表示中でSTOPボタン押下で何もおきない
  • 結果表示中でSTARTボタン押下で計測中に遷移する
  • 結果表示中 -> 計測中で、計測時間に記録済みの時間を足せるようにする
  • 結果表示中でRESETボタン押下で停止中に遷移する
  • time, stateをprivateにする
  • stateはStringで問題ないだろうか?
  • 状態による振る舞いの制御の仕方を考える

結果表示中でRESETボタン押下で停止中に遷移する

レッド

テストを追加します。

  @Test
  public void testResultThenReset() throws Exception {
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.start();
    Thread.sleep(10l);
    stopwatch.stop();
    stopwatch.reset();
    Thread.sleep(10l);
    assertEquals(0l, stopwatch.getTime());
    assertEquals(StopwatchState.INIT, stopwatch.getState());
  }

テストを実行します。。。レッドバーです。

グリーン

状態を変更する実装と、計測値を0にする実装を入れます。

public class Stopwatch {

  private final InitState initState = new InitState();

  InitState getInitState() {
    return this.initState;
  }
}
// ------------------------------------------
public class ResultState implements State {

  @Override
  public void reset(Stopwatch stopwatch) {
    stopwatch.setState(stopwatch.getInitState());
    stopwatch.setTime(0l);
  }

}

テストを実行します。。。グリーンバーです。

リファクタリング

この実装が目に入ったので

public class Stopwatch {

  public Stopwatch() {
    this.state = new InitState();
    this.startTime = 0l;
    this.time = 0l;
  }

}

修正します。先ほど追加したinitStateを使ってstateの初期化をするようにします。

public class Stopwatch {

  private final InitState initState = new InitState();

  InitState getInitState() {
    return this.initState;
  }

  public Stopwatch() {
    this.state = this.getInitState();
    this.startTime = 0l;
    this.time = 0l;
  }
}

テストを実行します。。。グリーンバーです。
TODOリストにチェックを入れます。

  • 初期状態が停止中、計測値が0
  • 停止中でSTOPボタン、RESETボタン押下で何もおきない
  • 停止中でSTARTボタン押下で状態が計測中に遷移する
  • 計測中でSTARTボタン、RESETボタン押下で何もおきない
  • 計測中でSTOPボタン押下で状態が結果表示中に遷移する
  • 結果表示中の計測値は変化しない
  • 結果表示中でSTOPボタン押下で何もおきない
  • 結果表示中でSTARTボタン押下で計測中に遷移する
  • 結果表示中 -> 計測中で、計測時間に記録済みの時間を足せるようにする
  • 結果表示中でRESETボタン押下で停止中に遷移する
  • time, stateをprivateにする
  • stateはStringで問題ないだろうか?
  • 状態による振る舞いの制御の仕方を考える

これで全てのTODOにチェックを入れることができました!!

ここまでのコード全部載せしておきます。

public class Stopwatch {

  private State state;
  private long startTime;
  private long time;
  private final MeasuringState measuringState = new MeasuringState();
  private final ResultState resultState = new ResultState();
  private final InitState initState = new InitState();

  MeasuringState getMeasuringState() {
    return this.measuringState;
  }

  ResultState getResultState() {
    return this.resultState;
  }

  InitState getInitState() {
    return this.initState;
  }

  void setState(State state) {
    this.state = state;
  }

  void setStartTime(long startTime) {
    this.startTime = startTime;
  }

  long getStartTime() {
    return this.startTime;
  }

  void setTime(long time) {
    this.time = time;
  }

  public Stopwatch() {
    this.state = this.getInitState();
    this.startTime = 0l;
    this.time = 0l;
  }

  long getTimeInner() {
    return this.time;
  }

  public long getTime() {
    return this.state.getTime(this);
  }

  public StopwatchState getState() {
    return this.state.getState();
  }

  public void stop() {
    this.state.stop(this);
  }

  public void reset() {
    this.state.reset(this);
  }

  public void start() {
    this.state.start(this);
  }
}
interface State {

  public void start(Stopwatch stopwatch);

  public void stop(Stopwatch stopwatch);

  public long getTime(Stopwatch stopwatch);

  public void reset(Stopwatch stopwatch);

  public StopwatchState getState();

}
public class InitState implements State {

  @Override
  public void start(Stopwatch stopwatch) {
    stopwatch.setState(stopwatch.getMeasuringState());
    stopwatch.setStartTime(System.currentTimeMillis());
  }

  @Override
  public void stop(Stopwatch stopwatch) {
    // do nothing.
  }

  @Override
  public long getTime(Stopwatch stopwatch) {
    return 0l;
  }

  @Override
  public StopwatchState getState() {
    return StopwatchState.INIT;
  }

  @Override
  public void reset(Stopwatch stopwatch) {
    // do nothing.
  }

}
public class MeasuringState implements State {

  @Override
  public void start(Stopwatch stopwatch) {
    // do nothing.
  }

  @Override
  public void stop(Stopwatch stopwatch) {
    stopwatch.setState(stopwatch.getResultState());
    stopwatch.setTime(calculateTime(stopwatch));
  }

  private long calculateTime(Stopwatch stopwatch) {
    return System.currentTimeMillis() - stopwatch.getStartTime() + stopwatch.getTimeInner();
  }

  @Override
  public long getTime(Stopwatch stopwatch) {
    return calculateTime(stopwatch);
  }

  @Override
  public StopwatchState getState() {
    return StopwatchState.MEASURING;
  }

  @Override
  public void reset(Stopwatch stopwatch) {
    // do nothing.
  }

}
public class ResultState implements State {

  @Override
  public void start(Stopwatch stopwatch) {
    stopwatch.setState(stopwatch.getMeasuringState());
    stopwatch.setStartTime(System.currentTimeMillis());
  }

  @Override
  public StopwatchState getState() {
    return StopwatchState.RESULT;
  }

  @Override
  public long getTime(Stopwatch stopwatch) {
    return stopwatch.getTimeInner();
  }

  @Override
  public void stop(Stopwatch stopwatch) {
    // do nothing.
  }

  @Override
  public void reset(Stopwatch stopwatch) {
    stopwatch.setState(stopwatch.getInitState());
    stopwatch.setTime(0l);
  }

}
import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.Test;

public class StopwatchTest {
  @Test
  public void testStopState() {
    Stopwatch stopwatch = new Stopwatch();
    assertEquals(StopwatchState.INIT, stopwatch.getState());
    assertEquals(0l, stopwatch.getTime());
    stopwatch.stop();
    stopwatch.reset();
    assertEquals(StopwatchState.INIT, stopwatch.getState());
    assertEquals(0l, stopwatch.getTime());
  }

  @Test
  public void testMeasuringState() throws Exception {
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.start();
    assertEquals(StopwatchState.MEASURING, stopwatch.getState());

    // 計測中でSTARTボタン押下
    long firstTime = stopwatch.getTime();
    stopwatch.start();
    Thread.sleep(10l);
    long secondTime = stopwatch.getTime();
    assertEquals(true, firstTime < secondTime);
    assertEquals(StopwatchState.MEASURING, stopwatch.getState());

    // 計測中でRESETボタン押下
    stopwatch.reset();
    Thread.sleep(10l);
    long thirdTime = stopwatch.getTime();
    assertEquals(true, secondTime < thirdTime);
    assertEquals(StopwatchState.MEASURING, stopwatch.getState());

    // 計測中でSTOPボタン押下
    long stoppedTime = stopwatch.getTime();
    stopwatch.stop();
    Thread.sleep(10l);
    assertEquals(StopwatchState.RESULT, stopwatch.getState());
    assertEquals(stoppedTime, stopwatch.getTime());
  }

  @Test
  public void testResultState() throws Exception {
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.start();
    stopwatch.stop();
    assertEquals(StopwatchState.RESULT, stopwatch.getState());
    long time = stopwatch.getTime();
    Thread.sleep(10l);
    assertEquals(time, stopwatch.getTime());

    stopwatch.stop();
    Thread.sleep(10l);
    assertEquals(time, stopwatch.getTime());
    assertEquals(StopwatchState.RESULT, stopwatch.getState());

    stopwatch.start();
    Thread.sleep(10l);
    assertEquals(StopwatchState.MEASURING, stopwatch.getState());
  }

  @Test
  public void testResultThenStart() throws Exception {
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.start();
    Thread.sleep(10l);
    stopwatch.stop();
    stopwatch.start();
    Thread.sleep(10l);
    long time = stopwatch.getTime();
    stopwatch.stop();
    assertTrue(20l <= time && time <= 25l);
  }

  @Test
  public void testResultThenReset() throws Exception {
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.start();
    Thread.sleep(10l);
    stopwatch.stop();
    stopwatch.reset();
    Thread.sleep(10l);
    assertEquals(0l, stopwatch.getTime());
    assertEquals(StopwatchState.INIT, stopwatch.getState());
  }

}

おわり

長々とお付き合いいただきありがとうございました。

TODOリストをもっと細かく作った方がTDD進めやすかったろうなと思います。一つのTODOで二つ以上のことをやってる箇所があり良くなかったです。タスクをうまく分割できてないってことですね。
Stateパターンを使ってリファクタリングしてみたいという意図もあり、パターンの導入が遅くなったせいで、大変な手術になってしまいました。実際は、まぁこのパターン使えばいい感じになるっしょ、っていう当たりがついているならさっさと導入する方がいいでしょう。ただパターンの導入はそれなりにコストがかかるものなので、それに見合うメリットがあるか考えてから導入したいです。
ストップウォッチっていう題材も微妙でしたね。時間計測のテストをちゃんとやる方法を知らないし、そのテストでいいのか目をつぶりながら進めた感あります。これに気づく前に、コードも記事も相当書いちゃってたんで続行しちゃいました。

レッド -> グリーン -> リファクタリングの流れは、想像以上に作業を進めやすくしてくれると感じました。次起きることが想定通りであることがかなり心理的安全を担保してくれます。とにかくグリーンに進んでそのグリーンを崩さないようにリファクタリングをすることで、安定した状況、期待通りの振る舞いがある状況、に長く居られるのがいいですね。

参考文献

GitHubで編集を提案

Discussion