本文へスキップ
BecomeCoder

Java MVVMコース · 第3章 Factory ― 生成をひとまとめに · レッスン11

Helper ― 時刻とID生成を切り出す

ブラウザで完結

導入

前レッスンのTaskFactoryは、staticなカウンタで自前にIDを発行していました。これは「呼ぶたびに値が変わる」という点で、Clock(現在時刻)のようなものと同じ性質を持ちます。今はまだ困りませんが、この先の章で「ID自体をテストで検証したい」「時刻に依存する機能(期限切れの判定など)をテストしたい」となったとき、内部に隠れたstaticカウンタや本物の時刻のままでは、決まった値を期待するテストが書けません。こういう「実行するたびに変わる値」を、IdGeneratorClockという小さな抽象の後ろに隠し、外から注入(コンストラクタで受け取る)できるようにすると、テストでは固定値を返す偽物(Fake)に差し替えられます。JavaではFakeをクラスとして書かなくても、抽象がメソッド1つだけの関数型インターフェースなら、ラムダ式1行で済みます。

図解

classDiagram
    class IdGenerator {
        <<interface>>
        +String next()
    }
    class Clock {
        <<interface>>
        +long today()
    }
    class TaskFactory {
        -IdGenerator idGenerator
        +create(String, Priority) TaskItem
    }
    TaskFactory --> IdGenerator

サンプル

IdGeneratorString next()を1つだけ持つ関数型インターフェースです。本番用の実装をわざわざクラスで書かなくても、テストでは() -> "固定の値"というラムダ式がそのまま実装になります。

public interface IdGenerator {
    String next();
}
public class ReceiptFactory {
    private final IdGenerator idGenerator;

    public ReceiptFactory(IdGenerator idGenerator) {
        this.idGenerator = idGenerator;
    }

    public String issue() {
        return "R-" + idGenerator.next();
    }
}
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class ReceiptFactoryTest {
    @Test
    void koteiNoIdWoWatasereba_ItsuJikkoshitemo_OnajiRyoshuban() {
        // 本番は "1", "2", ... と払い出す本物のIdGeneratorを渡すが、
        // テストでは常に同じ値を返すラムダをそのまま渡せる
        ReceiptFactory factory = new ReceiptFactory(() -> "0001");

        assertEquals("R-0001", factory.issue(), "固定のIDを注入すれば、何度実行しても同じ結果になる");
    }
}
  • IdGeneratorは抽象メソッドが1つだけなので、() -> "0001"というラムダ式がそのまま実装になる(new IdGenerator() { ... }のような匿名クラスを書く必要がない)
  • 本番用には、実際に連番やUUIDを払い出す実装クラスを別途用意するが、テストではラムダで固定値のFakeに差し替えられる
  • Clocklong today())も同じ形。() -> 20Lと書けば「今日は20日目」に固定したFakeになる

演習

TaskFactoryを書き換えて、IDの発行をIdGeneratorという抽象の後ろに追い出します。あわせて、同じ考え方のClock(まだTaskItemでは使いませんが、後の章で使う土台としてここで定義します)も用意してください。TaskItemは前章の完成形をそのままTaskItem.javaとして使い、IdGeneratorClockTaskFactoryの実装はTaskFactory.java、テストはTaskFactoryTest.javaという別ファイルに分けます。

public final class TaskTitle {
    private final String value;

    public TaskTitle(String value) {
        if (value == null || value.isBlank()) throw new IllegalArgumentException("タイトルは空にできません");
        if (value.length() > 50) throw new IllegalArgumentException("タイトルは50文字以内です");
        this.value = value.trim();
    }

    public String getValue() { return value; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof TaskTitle)) return false;
        return value.equals(((TaskTitle) o).value);
    }

    @Override
    public int hashCode() { return value.hashCode(); }

    @Override
    public String toString() { return value; }
}

public enum Priority {
    LOW("低"), MEDIUM("中"), HIGH("高");

    private final String label;

    Priority(String label) { this.label = label; }

    public String getLabel() { return label; }
}

public class TaskItem {
    private final String id;
    private TaskTitle title;
    private Priority priority;
    private boolean completed;

    public TaskItem(String id, TaskTitle title, Priority priority) {
        this.id = id;
        this.title = title;
        this.priority = priority;
    }

    public String getId() { return id; }
    public TaskTitle getTitle() { return title; }
    public Priority getPriority() { return priority; }
    public boolean isCompleted() { return completed; }

    public void complete() { completed = true; }

    public void rename(TaskTitle newTitle) { title = newTitle; }
}
// TODO: 次を満たす IdGenerator・Clock・TaskFactory を実装してください
//   ・interface IdGenerator は String next() を持つ
//   ・interface Clock は long today() を持つ(「今日」を通し番号で返す。今回はまだ使わないが定義だけしておく)
//   ・TaskFactory はコンストラクタで IdGenerator を受け取り、フィールドに保持する
//     (前レッスンにあった static なカウンタはもう使わない)
//   ・create(String title, Priority priority) は
//       new TaskTitle(title) でタイトルを検証してから
//       new TaskItem(idGenerator.next(), title, priority) を返す
//   ・create(String title) は create(title, Priority.MEDIUM) を呼ぶ(既定値)
public interface IdGenerator {
}

public interface Clock {
}

public class TaskFactory {
}
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class TaskFactoryTest {
    @Test
    void shoryakuJi_HaPriorityMedium() {
        TaskFactory factory = new TaskFactory(() -> "T-1");
        TaskItem t1 = factory.create("牛乳を買う");

        assertEquals("牛乳を買う", t1.getTitle().getValue(), "titleを保持している");
        assertEquals(Priority.MEDIUM, t1.getPriority(), "省略時はMEDIUM(既定値)");
    }

    @Test
    void yusenidoWoShiteiDekiru() {
        TaskFactory factory = new TaskFactory(() -> "T-2");
        TaskItem t2 = factory.create("卵を買う", Priority.HIGH);
        assertEquals(Priority.HIGH, t2.getPriority(), "優先度を指定できる");
    }

    @Test
    void idHaIdGenerator_GaSonomamaWatasareru() {
        TaskFactory factory = new TaskFactory(() -> "T-99");
        TaskItem t1 = factory.create("牛乳を買う");
        assertEquals("T-99", t1.getId(), "idはIdGeneratorが返した値がそのまま使われる");
    }

    @Test
    void karaBunjiHaTsukurenai() {
        TaskFactory factory = new TaskFactory(() -> "T-1");
        assertThrows(IllegalArgumentException.class, () -> factory.create(""), "空のタイトルは作れない(TaskTitleの検証がそのまま効く)");
    }

    @Test
    void clockMoLambda_DeKoteiDekiru() {
        Clock fixedClock = () -> 20L;
        assertEquals(20L, fixedClock.today(), "Clockも1メソッドの抽象なので、ラムダで固定値を返すFakeに差し替えられる");
    }
}
  • 期待される結果: 5件のテストがすべて成功
ヒント1を見る

TaskFactory.javaに実装します。public interface IdGenerator { String next(); } public interface Clock { long today(); } public class TaskFactory { private final IdGenerator idGenerator; public TaskFactory(IdGenerator idGenerator) { this.idGenerator = idGenerator; } public TaskItem create(String title, Priority priority) { TaskTitle validated = new TaskTitle(title); return new TaskItem(idGenerator.next(), validated, priority); } public TaskItem create(String title) { return create(title, Priority.MEDIUM); } }

ヒント2を見る

テストではnew TaskFactory(() -> "T-99")のように、IdGeneratorが要求する箇所へ直接ラムダ式を渡せます。抽象メソッドが1つのインターフェースだからです

まとめ

  • staticカウンタや本物の時刻のように「実行するたびに変わる値」を直接コードに埋め込むと、テストが非決定的になる
  • IdGenerator/Clockのような、抽象メソッドが1つだけの薄い抽象(関数型インターフェース)を挟むと、本番は本物の実装・テストはラムダ式のFakeを注入でき、同じロジックを決定的にテストできる
  • 第9章以降で「期限切れの強調表示」を作るとき、このClockをそのまま使う

次章: 作ったタスクを保持しておくRepositoryです。

実際に動かしてみよう

このレッスンのサンプルは、実務と同じように役割ごとの .java ファイルへ分けてあります。下のエディタは最初からその複数ファイルが入った状態で、上のタブでファイルを切り替えられます。そのまま「▶ 実行」を押せば全ファイルをまとめて解釈して動かせます。@Test の付いたテストがあるレッスンでは、テストメソッドごとに ✅/❌ の一覧(Red/Green)が出るので、まずテストを赤くしてから実装で緑にする、というTDDの回し方をその場で体験できます(本物のJVMではなく、JUnit・Mockitoの主要な書き方まで再現した学習用シミュレータです)。

Java — ブラウザ内で実行(学習用シミュレータ)

Javaの教材サブセットを動かす学習用シミュレータを読み込みます(本物のJVMではなく、動きを再現した軽量な自作エンジンです)。
スクロールして表示された時点でも自動で読み込まれます。