Java | オブジェクト指向:テストしやすい設計

Java Java
スポンサーリンク

「テストしやすい設計」とは何か

テストしやすい設計は
「そのクラスやメソッドを、単体でサクッと動かして結果を確認できる設計」
のことです。

もっと言うと、

テストを書くために余計な準備がいらない
テストしたい部分だけを切り出して動かせる
入力と結果が読みやすく、期待値を考えやすい

こういう設計です。

逆にテストしにくい設計は、

たった一つのロジックを確かめたいだけなのに、DB・Web・ファイルなど全部準備しないと動かない
テストしようとすると大量のモックやスタブが必要
どこで何が起きているか分かりにくい

という状態になりがちです。

ここから「何がテストを難しくし、どう設計するとテストが楽になるか」を、具体例で見ていきます。


典型的な「テストしにくい」クラスの例

何でも詰め込み Service

まず、あえて悪い例から。

public class OrderService {

    private final DataSource dataSource;

    public OrderService(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void placeOrder(HttpServletRequest request,
                           HttpServletResponse response) {

        try (Connection con = dataSource.getConnection()) {
            String userId = request.getParameter("userId");
            String itemId = request.getParameter("itemId");

            PreparedStatement ps = con.prepareStatement(
                "INSERT INTO orders(user_id, item_id) VALUES (?, ?)");
            ps.setString(1, userId);
            ps.setString(2, itemId);
            ps.executeUpdate();

            response.getWriter().write("OK");
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}
Java

このメソッドを単体テストしようとした瞬間、こうなります。

DataSource を用意しないといけない
本物の DB につなぐか、テスト用の埋め込み DB を立てる必要がある
HttpServletRequest と HttpServletResponse のダミーを作る必要がある
テストしたいのは「注文のロジック」なのに、技術要素の準備にばかり時間を取られる

つまり「1 行のビジネスロジック」を確認したいだけなのに、環境の準備が大仕事になります。

これは、ビジネスロジック・インフラ(DB)・Web 層を全部ひとつのメソッドに押し込めているから起きることです。


テストしやすい設計の第一歩は「責務を分ける」こと(重要)

純粋なロジックを、外の世界から切り離す

さきほどの placeOrder の中には、実は性質の違う処理が混ざっています。

HTTP リクエストから値を取り出す
注文として妥当かどうかチェックする
DB に保存する
HTTP レスポンスに結果を書く

このうち、「テストしたい本質」はだいたい「注文として妥当か」「どんな注文オブジェクトを作るか」です。

そこで、ビジネスロジックだけを取り出したクラスを作ります。

public final class OrderFactory {

    public Order create(String userId, String itemId) {
        if (userId == null || userId.isBlank()) {
            throw new IllegalArgumentException("ユーザID必須");
        }
        if (itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("商品ID必須");
        }
        return new Order(userId, itemId);
    }
}
Java

Order 自体もシンプルにしておきます。

public final class Order {

    private final String userId;
    private final String itemId;

    public Order(String userId, String itemId) {
        this.userId = userId;
        this.itemId = itemId;
    }

    public String userId() { return userId; }
    public String itemId() { return itemId; }
}
Java

こうしておけば、OrderFactory のテストは単純です。

テストコード側で、ただ new OrderFactory().create("u1", "i1") を呼ぶだけでよい
DB も HTTP もいらない
「引数」と「結果の Order」を比較するだけでロジックを確認できる

このように、純粋なロジックを外部から切り離したクラスを作ることが、テストしやすい設計の土台です。


副作用を減らして「入力→出力」にする

戻り値を返すメソッドはテストしやすい

テストしやすいメソッドの形は、とてもシンプルです。

引数を受け取る
計算する
結果を返す

例えば割引計算。

public final class DiscountCalculator {

    public int calcDiscount(int price, int rate) {
        if (price < 0) {
            throw new IllegalArgumentException("price < 0");
        }
        if (rate < 0 || rate > 100) {
            throw new IllegalArgumentException("rate 0〜100");
        }
        return price * rate / 100;
    }
}
Java

このメソッドのテストは、思い浮かべるだけで書けます。

価格 1000、率 10 なら 100
価格 0、率 50 なら 0
価格がマイナスなら例外

引数と戻り値だけ見ればよくて、外部状態も使わないし変えません。
こういう「関数っぽい」メソッドは、テストが圧倒的に楽です。

反対に、グローバル変数を書き換えたり、隠れた場所に結果を保存したりするメソッドは、
テストが途端に難しくなります。

「テストしたい処理を、なるべく入力→出力の形に寄せる」
これはかなり効きます。


依存を「注入」できるようにする

直接 new するとテストで差し替えにくい

例えば、メール送信を行うサービスを考えます。

public class UserService {

    public void register(User user) {
        // 何らかの登録処理…

        SmtpMailSender sender = new SmtpMailSender();
        sender.sendWelcomeMail(user.email());
    }
}
Java

register をテストするとき、「実際にメールは送ってほしくない」ことが多いです。
しかし、この書き方だと内部で勝手に new SmtpMailSender() してしまうので、差し替えができません。

テストしやすくするには、「外から依存を渡せる」ようにします。

public interface MailSender {
    void sendWelcomeMail(String email);
}
Java
public class SmtpMailSender implements MailSender {
    @Override
    public void sendWelcomeMail(String email) {
        // 本物のメール送信処理
    }
}
Java
public class UserService {

    private final MailSender mailSender;

    public UserService(MailSender mailSender) {
        this.mailSender = mailSender;
    }

    public void register(User user) {
        // 何らかの登録処理…

        mailSender.sendWelcomeMail(user.email());
    }
}
Java

テストでは、ダミーの MailSender を渡せます。

public class DummyMailSender implements MailSender {

    private boolean called;
    private String lastEmail;

    @Override
    public void sendWelcomeMail(String email) {
        called = true;
        lastEmail = email;
    }

    public boolean called() { return called; }
    public String lastEmail() { return lastEmail; }
}
Java

テストコードで

ダミー MailSender を new する
それを UserService のコンストラクタに渡す
register を呼ぶ
ダミーの状態(called や lastEmail)をアサートする

これで、「メール送信が呼ばれたかどうか」だけ安全に検証できます。

ポイントは「UserService が MailSender の具体クラスを知らない」ことです。
インターフェースに依存しているからこそ、テスト用実装を差し込めます。
これが依存性注入(DI)の基本的な考え方です。


クラスを小さく保つことは、そのままテスト容易性に効く

太ったクラスはテストも太る

クラスが肥大化すると、テストも複雑になります。

フィールドがたくさん
コンストラクタ引数がたくさん
メソッドの中で、いろんなことをやっている

こうなると、「この 1 メソッドだけテストしたい」というときにも、
大量の依存オブジェクトを作って渡さないとコンパイルも通らない、ということが起きます。

テストしやすいクラスは、たいてい次のような特徴を持ちます。

コンストラクタ引数が少ない
1 クラスの責務がはっきりしている
1 メソッドの中でやることの種類が少ない

これは「きれいな設計」がそのまま「テストしやすさ」に繋がっている例です。

「このクラスのテスト書くのしんどいな」と感じたら、そのクラスはたぶん肥大化しています。
テストの書きにくさは、設計が苦しがっているサインになりやすいです。


時刻・乱数・外部 I/O をどう扱うか

固定できないものを隠しておく

テストで困る代表が「現在時刻」「乱数」「外部 I/O」です。

現在時刻を直接 LocalDateTime.now() で取ってしまうと、
テストのたびに値が変わるので、期待値を固定できません。

そこで「時間供給者」を挟みます。

public interface Clock {
    LocalDateTime now();
}
Java
public final class SystemClock implements Clock {
    @Override
    public LocalDateTime now() {
        return LocalDateTime.now();
    }
}
Java
public final class FixedClock implements Clock {

    private final LocalDateTime fixed;

    public FixedClock(LocalDateTime fixed) {
        this.fixed = fixed;
    }

    @Override
    public LocalDateTime now() {
        return fixed;
    }
}
Java

本番では SystemClock を使い、テストでは FixedClock を渡すようにすれば
「常に 2025-01-01 09:00 のつもりでテストする」といったことが可能になります。

乱数や UUID 生成も同じです。
「直接 static 呼び出しする」のではなく、「供給者インターフェース」を挟んでおくと、
テストで好きな値に差し替えられるので、結果の検証が楽になります。


まとめ:テストしやすいかどうかをチェックする視点

テストしやすい設計かどうかは、次のような質問で見えてきます。

そのクラスのロジックをテストするとき、最低限何を準備しないと動かないか
純粋なロジック(計算や判定)が、外部技術(DB、HTTP、ファイル)から切り離されているか
依存しているオブジェクトを、外から差し込めるようになっているか
メソッドは「入力→出力」スタイルになっているか、それとも隠れた副作用に頼っていないか
テストを書こうとしたとき、「面倒くささ」より「書けそう」が勝つか

ここで「全部面倒だ」「モックだらけになりそう」と感じるなら、
その部分の設計を見直す価値があります。

タイトルとURLをコピーしました