Java コードのテスト容易性を向上させる設計パターンと実践

外部依存の分離と注入

ユニットテストにおいて最も困難な課題の一つは、コード内部にハードコードされた外部依存存在です。静的変数、システム時刻、ThreadLocal などの状態がメソッド内部で直接参照されると、テスト実行環境によって結果が変動し、再現性が失われます。

これを回避するための基本的なアプローチは、依存関係をコンストラクタまたはメソッド引数を通じて外部から注入することです。

以下の例は、静的な設定値に依存した良くない設計です。


// 修正前の例:静的依存によりテストが困難
class RegionBasedLogger {
    private final StorageAdapter storage;

    public RegionBasedLogger() {
        this.storage = new StorageAdapter();
    }

    public void logAction(Object action) {
        // 静的メソッド呼び出しによりモック化が不可能
        if ("US-East".equals(SystemConfig.getRegion())) {
            storage.persist(action);
        } else {
            System.out.println("Logging disabled for this region");
        }
    }
}

class SystemConfig {
    public static String getRegion() {
        // 環境変数などから取得する模擬コード
        return "US-East";
    }
}

interface StorageAdapter {
    void persist(Object data);
}

改善策としては、設定値をインスタンス変数として保持するか、設定取得機能自体をインターフェースとして注入する方法があります。単一メソッドの修正であれば引数追加で suffice しますが、クラス全体に影響する場合は依存性注入(DI)コンテナやコンストラクタ注入を用いて、状態をオブジェクト lifecycle に紐付けるのが適切です。

テスト容易性を高めるためには、静的メソッドや静的値をモック化する必要がある状況自体を避けるべきです。

データアクセスとビジネスロジックの分離

データベースへの読み書き操作を含むコードは、そのままではユニットテストに適しません。一般的なパターンとして、データ取得、処理、保存の各段階を明確に分離します。


// 推奨される分離パターン
var rawData = repository.fetchData();
var processedData = businessLogic.process(rawData);
repository.save(processedData);

このように分離することで、process メソッドは純粋な関数としてテスト可能になり、再利用性やリファクタリングの容易さも向上します。基本的に、データベースとの相互作用自体にはユニットテストではなく統合テストを適用し、コアロジック部分にユニットテストを集中させるのが効率的です。

サービス層の責務定義

多くのプロジェクトでは Service クラスにビジネスロジックの大半を実装しがちですが、これはテスト難易度を上げます。サービス層は本来、複数のドメインオブジェクトやユースケースを調整するオーケストレーターとして振る舞うべきです。

具体的な計算や規則判定などのロジックは、ドメインモデルや専用のロジッククラスに委譲します。サービス層の单元测试では、内部ロジックの正しさではなく、適切なコンポーネントが呼び出されたか(Mockito.verify など)を確認する検証が主目的となります。レガシーシステムでは、extract method リファクタリングを用いて、まずロジック部分を独立したメソッドとして切り出すことから始めます。

テストための可視性制御

カプセル化の原則では private メンバーを推奨しますが、複雑なロジックが private メソッドに隠れている場合、その動作を検証できないことがあります。このような場合、アクセス修飾子を package-private に変更するか、Guava の @VisibleForTesting のような注解を用いて、テストコードからのアクセスを明示的に許可する方針が有効です。

推奨ツールスタック

現代的な Java テスト環境としては、JUnit 5、Mockito、AssertJ の組み合わせが標準的です。AssertJ の fluent な assertion 構文は、テストコードの可読性を大幅に向上させます。ボイラープレートコードの生成は IDE の機能やコード生成ツールを活用し、開発者はテストケースの本質的な設計に集中すべきです。

実装例:メンバーシップ階層判定

ここでは、ユーザーの登録期間に基づいてメンバーシップ tier を判定するクラスを例に挙げます。時刻依存性を Clock の注入によって解決し、ロジックをテスト可能にしています。


import java.time.*;
import java.util.*;
import java.util.function.Function;

class MembershipTierResolver {
    private final Clock clock;
    private final Map<Long, String> userRegistrationMap;

    // 登録期間(日)と Tier のマッピング
    private static final NavigableMap<Long, String> TIER_RULES = new TreeMap<>();
    static {
        TIER_RULES.put(30L, "Trial");
        TIER_RULES.put(365L, "Standard");
        TIER_RULES.put(Long.MAX_VALUE, "Premium");
    }

    public MembershipTierResolver(Clock clock, Map<Long, String> userRegistrationMap) {
        this.clock = clock;
        this.userRegistrationMap = userRegistrationMap;
    }

    public Map<Long, String> resolveAllTiers() {
        Map<Long, String> result = new HashMap<>();
        for (Map.Entry<Long, String> entry : userRegistrationMap.entrySet()) {
            Instant regDate = Instant.parse(entry.getValue());
            String tier = calculateTier(regDate);
            result.put(entry.getKey(), tier);
        }
        return result;
    }

    @VisibleForTesting
    String calculateTier(Instant registrationDate) {
        long days = Duration.between(registrationDate, Instant.now(clock)).toDays();
        return TIER_RULES.higherEntry(days).getValue();
    }
}

対応するテストコードでは、Clock を固定時刻にモック化することで、時間経過に依存しない安定したテストを実現できます。


import org.junit.jupiter.api.*;
import static org.assertj.core.api.Assertions.*;

class MembershipTierResolverTest {
    private MembershipTierResolver resolver;
    private Clock fixedClock;

    @BeforeEach
    void setUp() {
        // 2024-01-01 正午に時刻を固定
        Instant fixedInstant = Instant.parse("2024-01-01T12:00:00Z");
        fixedClock = Clock.fixed(fixedInstant, ZoneId.of("UTC"));
    }

    @Test
    void testResolveAllTiers() {
        Map<Long, String> registrations = new HashMap<>();
        // 10 日前に登録(Trial)
        registrations.put(1L, "2023-12-22T12:00:00Z");
        // 100 日前に登録(Standard)
        registrations.put(2L, "2023-09-23T12:00:00Z");
        // 400 日前に登録(Premium)
        registrations.put(3L, "2022-11-27T12:00:00Z");

        resolver = new MembershipTierResolver(fixedClock, registrations);
        Map<Long, String> tiers = resolver.resolveAllTiers();

        assertThat(tiers.get(1L)).isEqualTo("Trial");
        assertThat(tiers.get(2L)).isEqualTo("Standard");
        assertThat(tiers.get(3L)).isEqualTo("Premium");
    }

    @Test
    void testCalculateTierBoundary() {
        resolver = new MembershipTierResolver(fixedClock, new HashMap<>());
        
        // ちょうど 30 日前
        Instant thirtyDaysAgo = Instant.parse("2023-12-02T12:00:00Z");
        assertThat(resolver.calculateTier(thirtyDaysAgo)).isEqualTo("Standard");

        // ちょうど 365 日前
        Instant yearAgo = Instant.parse("2023-01-01T12:00:00Z");
        assertThat(resolver.calculateTier(yearAgo)).isEqualTo("Premium");
    }
}

タグ: Java JUnit5 Mockito AssertJ DependencyInjection

8月1日 09:38 投稿