変化に強いテストを育てるSpringBootのレイヤー設計

4.7K Views

May 30, 26

スライド概要

JJUG CCC 2026 Spring 登壇資料

profile-image

Java, Spring Boot, React, Vue.js など

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

ダウンロード

関連スライド

各ページのテキスト
1.

変化に強いテストを育てる SpringBootのレイヤー設計 2026-05-30 | JJUG CCC 2026 Spring 株式会社サンアーチ Takeshi Miyajima

2.

⾃⼰紹介 Takeshi Miyajima 宮島 健 “スピーキングエンジニア”を育てる 株式会社サンアーチ 業務システム開発 / 運⽤ツール開発 Java / Spring Boot / React / Vue / etc 2

3.

はじめに 3

4.

今⽇話すテストのスコープ 開発者テスト QAテスト 受⼊テスト こっちの話 4

5.

今⽇話すテストのスコープ E2E パフォーマンス 要件 設計 継続的インテグレーション 実装 テスト リリース CI ここら辺のテストの話 5

6.

今⽇の話のモチベーション • 開発を⽀えてくれる、価値あるテストを作るのは難しい • 設計が抱える問題点がテストを難しくしている事例が多い →”価値あるテストを⽀えるのは良い設計だ” という話をしたい • Spring Bootに限らない当たり前の話が多いが、 誰かの気づきになったら嬉しい 6

7.

今⽇話すこと・話さないこと 今⽇話すこと • 現場でよくある⾃動テストの悩みと、その原因 • 変化に強いテストを⽬指すための、設計からのアプローチ • 責務の分離が変化に強いテストを⽣み出す 話さないこと • JUnit・Mockito等の使い⽅ • アーキテクチャパターンの詳細 / ドメイン駆動設計の詳細 • 負荷テスト・パフォーマンステスト・E2Eテストの⼿法 7

8.

変化に強いテスト 8

9.

変化に強いテストとは 変化に強いテストとは 継続開発を⽀える品質ガード • • • • 関係ないテストは壊れない リファクタリングの影響を受けない 仕様が変わったらテストが検知してくれる いつでも素早く実⾏できる つまり・・・ として働くもの • 信頼性が⾼い • 保守コストが低い • フィードバックが早い 9

10.

変化に強いテストは、 プロダクトの進化を 加速 させる 10

11.

現場でよくある “変化に弱いテスト” あなたのプロジェクトも同じ悩みを抱えていませんか︖ 保守コストが⾼い 信頼性が低い • • • ⾃動テストは通るのに、 動かしてみると動かない テストの動作が不安定 結局⼿動テストが必要 • • 変更のたびに、⼤量の テスト修正が発⽣ 仕様は変わらないのに テストだけ壊れる フィードバックが遅い • • 実⾏が遅い エラー原因がわかりづらい → どうしたら “変化に強いテスト” が作れるのか 11

12.

変化に強いテストの鍵は ”プロダクトコードの改善” プロダクトコード まずい設計 構造を反映 テストコード ・・・結果的に・・・ 変化に弱いテスト ・・・結果的に・・・ 変化に強いテスト 改善 良い設計 12

13.

“責務の混在” が変化に弱いテストを⽣み出す 変化に弱いテスト 責務の混在 ⼊⼒チェック 処理フロー データの加⼯ 永続化 トランザクション 整合性チェック ルール 計算 判断 観点 無理やりテスト 観点 観点 観点 観点 観点 観点 13

14.

“責務の分離” が変化に強いテストを⽣むポイント 責務を分離 Presentation リクエスト受け取り 形式チェック レスポンス返却 Application データの加⼯・計算 整合性チェック トランザクション管理 DataAccess 変化に強いテスト 観点 観点 観点 観点 観点 観点 データ⼊出⼒ 観点 観点 データ変換 観点 観点 テスト 14

15.

ここまでのまとめ • 変化に強いテスト = 継続開発を⽀える品質ガード • 変化に強いテストには、良い設計 が必要 • 変化に強いテストには、責務の分離 が重要 16

16.

“変化に強いテスト” を⽬指す責務分離 ① 外部接続の分離 ② レイヤー内の分離 ③ 参照系の分離 17

17.

前提とするアーキテクチャ よくある三層アーキテクチャで考えていきます クライアント Presentation Application DataAccess Controller Service Repository リクエスト受け取り 形式チェック レスポンス返却 処理フロー制御 データの加⼯・計算 整合性チェック 業務ロジック トランザクション管理 データ⼊出⼒ データ変換 DB 18

18.

①“外部接続” と “それ以外” を明確に分ける Presentation 役割 外部接続 Application 分ける その他 DataAccess 分ける 外部接続 19

19.

②外部接続の責務を薄く、処理をシンプルにする Presentation Application DataAccess 役割 外部接続 その他 外部接続 責務 薄く 処理内容 シンプル 寄せる 厚く 複雑 寄せる 薄く シンプル 20

20.

よくある失敗① Controller肥⼤化 Controllerが処理フローを担ってしまう 処理フローの制御はサービスに寄せ、Controllerはサービスを呼び出すだけ シンプル < 複雑 複雑 > シンプル Presentation Application 制御・呼び分け Controller CheckService 改善 Presentation Application Controller ProcessService ProcessService1 テスト ProcessService2 テスト増・遅い・壊れやすい テスト 1. 2. 3. チェック 処理1 処理2 テスト少・早い・安定 21

21.

よくある失敗② Repositoryへの業務知識漏洩 SQLで状態チェック、SQLに業務仕様が埋め込まれている RepositoryはシンプルなデータIOに特化、チェック・判断はサービスやモデルへ SELECT * FROM orders o WHERE o.customer_id = ? AND o.status = 'CONFIRMED' AND o.cancelled_at IS NULL AND o.created_at >= NOW() - INTERVAL 30 DAY 改善 SELECT * FROM orders o WHERE o.customer_id = ? Order isValidForShipping() 業務仕様埋め込み シンプルIO + モデルメソッド 22

22.

分離してからどうテストするか︖ Presentation Application DataAccess 役割 外部接続 その他 外部接続 責務 薄い 厚い 薄い 処理内容 シンプル 複雑 シンプル テスト戦略 ︖ ︖ ︖ 23

23.

テスト戦略︓Presentationレイヤー • • • @WebMvcTest、MockMvc等を使う Applicationレイヤーをモックする 主なテスト観点 • リクエスト/レスポンスのやり取り • ⼊⼒チェック・認証認可チェック • Applicationレイヤーの呼び出し これ以外の観点が必要なら 責務混在の懸念あり Presentation Application Controller Service モック テスト @WebMvcTest MockMvc 24

24.

テスト戦略︓Presentationレイヤー サンプルコード @WebMvcTest(OrderController.class) class OrderControllerTest { @Autowired MockMvc mockMvc; @MockitoBean OrderService orderService; @Test void 注⽂作成が成功したら200を返す() { // モックのセットアップ when(orderService.create(any())).thenReturn(OrderId.of(1)); // mockMvcでControllerの挙動をテスト mockMvc.perform(post("/orders")...) .andExpect(status().isOk()); } } // モックの呼び出しを確認 verify(orderService).create(orderRequest()); 25

25.

テスト戦略︓DataAccessレイヤー • • • @DataJpaTest、JdbcClient等を使う H2やTestcontainersで実DBテストをする 主なテスト観点 • 検索・登録・更新・削除SQL • Javaクラスとの相互変換 • 取得件数ごとの結果パターン 業務観点のパターンが必要なら 責務混在の懸念あり DataAccess Repository H2 / Testcontainers テスト @DataJpaTest/@JdbcTest JdbcClient 26

26.

テスト戦略︓DataAccessレイヤー サンプルコード @DataJpaTest class OrderRepositoryTest { @Autowired OrderRepository orderRepository; @Autowired JdbcClient jdbcClient; @Test void IDで注⽂を検索できる() { // データのセットアップ // @Sqlを使うのもおすすめ jdbcClient.sql("INSERT INTO ...").update(); // メソッドを実⾏ var found = orderRepository.findById(1); } } // 結果の確認 assertThat(found).hasValue(new Order(...)); 27

27.

補⾜︓H2かTestcontainersか RepositoryがシンプルIOに特化できれば、H2で⾜りるはず DB固有のSQLが必要な場合のみTestcontainersを使うのがおすすめ H2 おすすめ Testcontainers 実⾏速度 ◎ 速い(インメモリ) △ 遅い(コンテナ起動) SQL互換性 △ ⽅⾔⾮対応のことあり ◎ 本番DBと同じ CI環境 ◎ 追加設定不要 △ Docker必須 28

28.

テスト戦略︓Applicationレイヤー • • • Springは使わずにテスト インメモリRepositoryを使う 主なテスト観点 • • • • 処理フロー データの加⼯・計算 整合性チェック 業務ロジック ⾃動テストの主戦場 テスト量も最も多いはず Application DataAccess Service Repository インメモリ Repository テスト SpringExtension 29

29.
[beta]
テスト戦略︓Applicationレイヤー サンプルコード
class OrderServiceTest {
private InMemoryOrderRepository repository = new InMemoryOrderRepository();
private OrderCreationPolicy policy = new OrderCreationPolicy();
private OrderService sut = new OrderService(repository, policy);
@Test
void 注⽂を作成すると保存される() {
var request = new OrderRequest(...);
var orderId = sut.createOrder(request);

}

}

var order = repository.findById(orderId);
assertThat(order).hasValue(new Order(...));

@Test
void ⾦額が0以下の場合は注⽂を作成できない() {
assertThatThrownBy(() -> sut.createOrder(new OrderRequest(...)))
.isInstanceOf(IllegalArgumentException.class);
}

30

30.

補⾜︓Spring Testは使わず早いテストを⽬指すべし Applicationレイヤーは、Springの⼒を借りずにテストが動かせる唯⼀のレイヤー →あえて責務を集約することで、軽量なテストの分量を増やす Large テストピラミッド Small > Medium > Large のバランスが良い Medium ・・・ Presentation / DataAccessレイヤーのテスト 少 Small ・・・ Applicationレイヤーのテスト 多 31

31.

補⾜︓インメモリRepositoryがおすすめ • Repositoryをモックしてしまうと、テストの保守性が下がる • インメモリRepositoryを使えば、モックの⾟みを軽減できる モック Repository Repository findById(long id) findByName(String name) 整合性・定義もれ 振る舞い1 振る舞い2 代替 インメモリ Repository ずれない・漏れない Map 32

32.
[beta]
補⾜︓インメモリRepositoryのサンプル
class InMemoryOrderRepository
implements OrderRepository {
private final Map<Long, Order> store = new HashMap<>();
private long nextId = 1L;
@Override
public Order save(Order order) {
var saved = order.withId(nextId++);
store.put(saved.getId(), saved);
return saved;
}
@Override
public Optional<Order> findById(long orderId) {
var order = this.store.get(orderId);
return Optional.ofNullable(order);
}
}

33

33.

外部接続の分離まとめ Presentation Application DataAccess 役割 外部接続 分ける その他 分ける 外部接続 責務 薄い 寄せる 厚い 寄せる 薄い 処理内容 シンプル 複雑 シンプル テスト戦略 Springありき Appはモック 早いテスト インメモリRepo Springありき 実DB 34

34.

“変化に強いテスト” を⽬指す責務分離 ① 外部接続の分離 ② レイヤー内の分離 ③ 参照系の分離 35

35.

意図的に厚くしたレイヤーとどう戦うか Presentation Application DataAccess 役割 外部接続 その他 外部接続 責務 薄い 厚い 薄い 処理内容 シンプル 複雑 シンプル 36

36.

⼤きい塊のままだと、テストも⼤きくなる BigService 構造を反映 BigServiceTest 保守コストが⾼い 変化に弱い 37

37.

⼩さく分ければ、テストも⼩さくなる SmallService SmallClass SmallClass 構造を反映 SmallServiceTest SmallClassTest SmallClassTest ⼩さいテストは扱いやすい 変化に強い︖ 38

38.

分け⽅次第で変化への強さは変わる 変化に弱い 変化に強い 適当に区切る 責務の境界で区切る ⼊⼒チェック 処理フロー データの加⼯ 永続化 トランザクション 整合性チェック ルール 計算 判断 処理フロー ルール 判断 計算 通知 構築 39

39.

よく使う責務の分離パターン ① 判断 と 処理 の分離 ② 処理フロー と 実処理 の分離 ③ 構築 と 実⾏ の分離 40

40.

分離パターン① 判断と処理の分離 業務知識としての判断はドメインモデルへ、判断を使う処理はServiceへ OrderService 処理フロー 判断 OrderService 参照 Order 情報 Order 処理フロー 判断 情報 参照 41

41.

分離パターン① 修正前 public class Order { private OrderStatus status; // getter / setter のみ } public class OrderService { public void cancel(Long orderId) { 判断 var order = orderRepository.findById(orderId).orElseThrow(); if (order.getStatus() != OrderStatus.PENDING && order.getStatus() != OrderStatus.CONFIRMED) { throw new IllegalStateException("キャンセルできません"); } order.setStatus(OrderStatus.CANCELLED); orderRepository.save(order); } } 42

42.

分離パターン① 判断を移動 public class Order { private OrderStatus status; // getter / setter public boolean isCancellable() { return status == OrderStatus.PENDING || status == OrderStatus.CONFIRMED } } 判断 public class OrderService { public void cancel(Long orderId) { var order = orderRepository.findById(orderId).orElseThrow(); if (!order.isCancellable()) { throw new IllegalStateException("キャンセルできません"); } order.setStatus(OrderStatus.CANCELLED); orderRepository.save(order); } } 43

43.

分離パターン① さらに、判断を伴う処理ごと移動 public class Order { 判断を伴う private OrderStatus status; public boolean isCancellable() { ... } 処理 public void cancel() { if (!isCancellable()) { throw new IllegalStateException("キャンセルできません"); } setStatus(OrderStatus.CANCELLED); } } public class OrderService { public void cancel(Long orderId) { var order = orderRepository.findById(orderId).orElseThrow(); order.cancel(); orderRepository.save(order); } } 44

44.

分離パターン① テストに対するメリット モデルがロジックを持つと、独⽴性が⾼くテストしやすくなる Service側のテストパターンも減らせる テスト内容 OrderService Test 処理フロー 判断内容 テスト内容 OrderService Test 処理フロー OrderTest 判断内容 テスト しやすい ロジックが 安定 45

45.

よく使う責務の分離パターン ① 判断 と 処理 の分離 ② 処理フロー と 実処理 の分離 ③ 構築 と 実⾏ の分離 46

46.

分離パターン② 処理フローと実処理の分離 処理の塊を別クラスに逃し、処理フローを独⽴させる OrderService 処理フロー データ更新 イベント通知 OrderUseCase 処理フロー OrderRecord Service データ更新 イベント通知 47

47.

分離パターン② 修正前 OrderService // バリデーション validate(req); var order = buildOrder(req); // 在庫チェックと分岐 if (inventoryService.isAvailable(order)) { // データ更新 orderRepository.save(order); inventoryService.decrease(order); // イベント通知 eventPublisher.publish(new OrderPlaced(order)); notificationService.notify(order); } else { // 在庫不⾜ orderRepository.saveAsPending(order); notificationService.notifyOutOfStock(order); } 処理の塊 48

48.

分離パターン② 実処理を移動 OrderUseCase OrderRecordService // バリデーション validate(req); var order = buildOrder(req); // 在庫チェックと分岐 if (inventoryService.isAvailable(order)) { orderRecordService.record(order); } else { // 在庫不⾜ orderRecordService .recordAsPending(order); } void record(Order order) { // データ更新 orderRepository.save(order); inventoryService.decrease(order); // イベント通知 eventPublisher.publish( new OrderPlaced(order)); notificationService.notify(order); } 分離 void recordAsPending(Order order) { // 在庫不⾜時 orderRepository.saveAsPending(order); notificationService .notifyOutOfStock(order); } 49

49.

分離パターン② テストに対するメリット テストパターンが “掛け合わせ” から “⾜し算” になる テスト内容 テスト内容 OrderService Test 分岐 処理内容 OrderUseCase Test 分岐 OrderRecord ServiceTest 処理内容 50

50.

よく使う責務の分離パターン ① 判断 と 処理 の分離 ② 処理フロー と 実処理 の分離 ③ 構築 と 実⾏ の分離 51

51.

分離パターン③ 構築と実⾏の分離 外部APIアクセス (インフラレイヤー) でも使えるパターン 複雑な情報構築と、愚直な実⾏役に分離する Notification Service メッセージ構築 通知送信 Notification MessgeBuilder メッセージ構築 Notification Sender 通知送信 52

52.

分離パターン③ 修正前 NotificationService 構築 public void notify(Order order) { // 複数ソースからメッセージ構築 var user = userRepository.findById(order.getUserId()); var product = productRepository.findById(order.getProductId()); // 通知内容の構築 var subject = ... var body = ... 実⾏ // 送信実⾏ var mail = new SimpleMailMessage(); mail.setTo(user.getEmail()); mail.setSubject(subject); mail.setText(body); sender.send(mail); } 53

53.

分離パターン③ 情報の構築処理を抽出 NotificationService public void notify(Order order) { // メッセージを構築 var message = messageBuilder.build(order); // メッセージを送信 sender.send(message); } NotificationMessageBuilder public NotificationMessage build(...) { // 複数ソースからメッセージ構築 var user = ... var product = ... // 通知内容の構築 var subject = ... var body = ... NotificationSender public void send( NotificationMessage message) { var mail = new SimpleMailMessage(); mail.setTo(message.getEmail()); mail.setSubject(mesage.getSubject()); mail.setText(mesage.getBody()); sender.send(mail); } return new NotificationMessage( user.getEmail(), subject, body); } 54

54.

分離パターン③ テストに対するメリット 複雑なデータ構築パターンや、外部アクセスを切り出して重点的にテスト可能 NotificationMessgeBuilderTest NotificationSenderTest • ⼊⼒データバリエーション • テンプレート置換 • メッセージパターン • 送信先・フォーマット 55

55.

補⾜︓テストの分離しすぎに注意 テストを分離すると、プロダクションコードの構造の変更に弱くなる →境界が安定するまでは、あえてテストは分離しない作戦もアリ OrderUseCase use OrderRecord Service OrderUseCase Test まとめてテストすれば クラス間で処理を移動しても壊れない 56

56.

よく使う責務の分離パターン まとめ ① 判断 と 処理 の分離 ② 処理フロー と 実処理 の分離 ③ 構築 と 実⾏ の分離 57

57.

“変化に強いテスト” を⽬指す責務分離 ① 外部接続の分離 ② レイヤー内の分離 ③ 参照系の分離 58

58.

“更新系” と “参照系” アプリ内の処理は、 “更新系” と “参照系” に分けて考えることができる 更新系 データ更新 画⾯ DB 参照系 データ取得 59

59.

共通モデルパターン シンプルなアプリでは、更新系も参照系も、⼀つのモデルを共⽤する事が多い 更新系 データ更新 共通 Order 画⾯ 参照系 データ取得 60

60.

共通モデルが引き起こす問題 更新系・参照系、相互に影響しあってテストが壊れる 更新系 参照系 Order 制約条件追加 テストが壊れる +id: Long +customerId: Long +items: List<OrderItem> +status: OrderStatus テストが壊れる フィールド追加 61

61.

そもそも要求が違う 様々な要求の違いがあるため、⼀つのモデルで扱うと問題が起きる 観点 更新系 参照系 関⼼事 正確性・整合性 ⾒やすさ・速さ 処理件数 1件 〜 少数 1件 〜 ⼤量 ビジネスルール検証 必要 不要 トランザクション 必要 基本不要 副作⽤ あり(DB書き込み・イベント) なし 変更要因 ビジネスルール UI / UX 62

62.

参照系をまるっと分離するという解決策 更新系とは別に参照系の経路を作り、モデルも処理も完全分離する (いわゆるCQRS) → 要求の違いをそのままモデルに反映することで、モデル汚染をなくす 更新系 Presentation Application DataAccess 参照系 Query 63

63.

更新系と参照系でモデルを分ける 更新系 Order +id: Long +customerId: Long +items: List<OrderItem> +status: OrderStatus 参照系 OrderView +id: Long +customer: CustomerView +items: List<OrderItemView> +status: OrderStatus +shipping: ShippingView OrderRepository +findById() OrderQueryRepository +findByShippingStatus() 64

64.

分ければテストは安定する 更新系 参照系 Order OrderView 影響なし OrderService OrderService Test OrderQuery Service OrderQuery ServiceTest 65

65.

補⾜︓参照系の分離タイミング・範囲に注意 • アプリがシンプルな状態で分離すると、開発コストが上がる • 全体に適⽤すると、シンプルな機能まで開発コストが上がる • 分離メリットがタイミング・範囲を⾒極めて適⽤すべし 同じでいいなら コストがかかるだけ 更新系 参照系 Order OrderView +id: Long +customerId: Long +items: List<OrderItem> +status: OrderStatus +id: Long +customerId: Long +items: List<OrderItemView> +status: OrderStatus 66

66.
[beta]
参照系のテストサンプル
• Serviceの責務が薄いため、DataAccessレイヤーと⼀緒にテストもあり
• DB固有機能を使⽤する割合が多いため、Testcontainersを使う場⾯が増える
@JdbcTest
@Import({ OrderQueryService.class, OrderQueryRepository.class })
@AutoConfigureTestDatabase(replace = Replace.NONE)
@Testcontainers
class OrderQueryServiceTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired
OrderQueryService sut;
...
}

67

67.

補⾜︓参照系のみGraphQL化する選択肢 • 参照系のみGraphQL化すると、 データツリー構築のコスト削減に有利 • フロントエンド側でGraphQLのメリットが 活かせれば、特に効果が⼤きい • 通常のREST APIとは設計の考え⽅が 違うため注意 query GetOrder($id: ID!) { order(id: $id) { id status customer { id name address { prefecture city street } } items { quantity unitPrice product { id name category { id name } } } } } 68

68.

まとめ 69

69.

まとめ • 変化に強いテスト = 継続開発を⽀える品質ガード • 変化に強いテストには、良い設計 が必要 • 変化に強いテストには、責務の分離 が重要 • 外部接続の分離、レイヤー内の分離、参照系の分離 70

70.

変化に強いテストで、 プロダクトの進化を 加速 させよう 71

71.

ご静聴ありがとうございました 72

72.

APPENDIX 73

73.

テストの偽陽性・偽陰性 プロダクトコードが ! " # 結 果 & 正しい 誤っている 成功 期待通り 偽陰性 失敗 偽陽性 期待通り サバンナ便り 〜ソフトウェア開発の荒野を⽣き抜く 第2回 偽陽性と偽陰性 https://gihyo.jp/dev/serial/01/savanna-letter/0002 74

74.

テストサイズとテストピラミッド 75

75.

集約設計とRepository • 集約とは、「データを⼊出⼒する情報の単位」のこと • ドメイン駆動設計における重要概念 • 基本は集約ごとにRepositoryを作ることが重要 集約ごとにRepositoryを作るメリット • ApplicationレイヤーとDataAccessレイヤーのやりとりを単純化できる • • テーブル単位だと、必要データの組み⽴てで複数回Repositoryアクセスが発⽣する アクセス数を減らす⽬的で、業務知識がRepositoryに漏えいしやすくなる • インメモリRepositoryが作りやすくなる • • テーブル単位だと、Repository間のデータ整合性が壊れやすい テーブル単位だと、作るインメモリRepositoryが増えがち 76

76.

CQRS (Command Query Responsibility Segregation) 77

77.

Spring BootでのTestcontainersの使い⽅ 78

78.

Spring GraphQLの実装⽅法 79

79.

GraphQLのスキーマ設計ガイド 80

80.

参考リンク • https://spring.pleiades.io/spring-boot/reference/testing/spring-bootapplications.html • https://spring.pleiades.io/spring-boot/reference/testing/testcontainers.html • https://spring.pleiades.io/springframework/reference/testing/annotations/integration-spring/annotationsql.html • https://spring.pleiades.io/spring-framework/docs/current/javadocapi/org/springframework/test/context/jdbc/Sql.html • https://spring.pleiades.io/spring-framework/docs/current/javadocapi/org/springframework/test/web/client/MockRestServiceServer.html 81