"Service層"が持つ危険性について考える

「Service層とは何か」を一言で説明できる人は少ないのではないだろうか

Service層を定義しているプロジェクトは多いが、Model層のようにガッチリと責務が定義されている訳ではなく企業やプロジェクトによって、その責務はバラバラである事が多い。

今まで自分が見てきた"Service層"を紹介する

ApplicationService的な使われ方をしているService層

  • オブジェクトのやりとりを結合させる部分として利用されていた
  • コンストラクタにオブジェクトのインスタンスを持つ
  • Controllerから呼び出されれる事が多い

DomainService的な使われ方をしているService層

  • ドメイン知識がService層に実装されているケース
  • MVC構成なのにも関わらず、DomainServiceが存在し、Model層がドメインモデル貧血症に陥るケースを過去に見た

lib的な使われ方をしているService層

  • ドメインもModelも関係していないファイル操作のような処理がService層に定義されているケース

置き場がないロジックをとりあえず置いておく為のService層

  • 何でも屋さん
  • Controllerに出てきてしまったファットな処理を、とりあえず退けておくのに使われる

課題はなんなのか

  • 上で挙げた例が全て"Service層"と名前がつけられていた事である
  • "Service層"の責務が複数存在することになる
  • しっかりと責務が定義されていない場合、新規メンバーのキャッチアップは難しくなる
  • コードの依存関係がめちゃくちゃになり保守は困難を極めるだろう

どうすればいいのか

生成AIが書く自動テストは複雑なコードに耐えられるのか

最近、生成AIネタばっかり書いてる気がする。
ふと、どれだけ複雑なコードに生成AIが耐えられるのか実験したくなりやってみた。

何をするのか

  • 複雑なコードを書いて自動テストを生成AIに実装させる
  • 自動生成されたテストケース数とテスト通過数を観察する

モデル

Gpt-4o

実験に使うコード

今回は事前に用意した下の複雑なコードを使う
可能な限りパスケースを用意した中々にヤバめのコードである

def get_diff_hash(a_value, b_value, a_name, b_name, flg)

  if a_name.nil?
    raise ArgumentError, 'a_name cannot be nil'
  end

  if b_name.nil?
    raise ArgumentError, 'b_name cannot be nil'
  end

  unless ["after", "before"].include?(flg)
    raise ArgumentError, 'flg must be either "after" or "before":' + flg
  end

  if flg == 'after'

    if (a_name == 'specific_a_name' && b_name == 'specific_b_name') || (b_name == "fuga" && a_name == 'hoge')
      if a_value && b_value
        return { diff: has_diff?(a_value, b_value), value: a_value - b_value, name: 'specific_after_name_1' }
      end
      return { diff: has_diff?(a_value, b_value), value: a_value, name: 'specific_after_name_2' } if a_value && !b_value
      return { diff: has_diff?(a_value, b_value), value: nil, name: 'specific_after_name_3' } if !a_value && b_value
      return { diff: has_diff?(a_value, b_value), value: nil, name: 'specific_after_name_4' } if !a_value && !b_value
    end

    if a_value && b_value
      return { diff: has_diff?(a_value, b_value), value: a_value - b_value, name: a_name }
    end

    return { diff: has_diff?(a_value, b_value), value: a_value, name: a_name } if a_value && !b_value

    return { diff: has_diff?(a_value, b_value), value: nil, name: a_name } if !a_value && b_value

    return { diff: has_diff?(a_value, b_value), value: nil, name: a_name } if !a_value && !b_value
  end

  if flg == 'before'

    if (a_name == 'specific_a_name_2' && b_name == 'specific_b_name_2')  || (b_name == "hoge" && a_name == 'fuga')
        if a_value && b_value
          return { diff: has_diff?(a_value, b_value), value: b_value - a_value, name: 'specific_before_name_1' }
        end
        return { diff: has_diff?(a_value, b_value), value: b_value, name: 'specific_before_name_2' } if a_value && !b_value
        return { diff: has_diff?(a_value, b_value), value: nil, name: 'specific_before_name_3' } if !a_value && b_value
        return { diff: has_diff?(a_value, b_value), value: nil, name: 'specific_before_name_4' } if !a_value && !b_value
      end
      
    if a_value && b_value
      return { diff: has_diff?(a_value, b_value), value: b_value - a_value, name: a_name }
    end

    return { diff: has_diff?(a_value, b_value), value: b_value, name: a_name } if a_value && !b_value

    return { diff: has_diff?(a_value, b_value), value: nil, name: a_name } if !a_value && b_value

    return { diff: has_diff?(a_value, b_value), value: nil, name: a_name } if !a_value && !b_value
  end
end

def has_diff?(a_value, b_value)
  a_value.to_s == b_value.to_s
end

プロンプト

今回の実験で生成AIに投げるプロンプト

sample_code.rbのテストを書いてください。 
以前、実装したテストケースは忘れて1から考えてください。 
可能な限り条件を網羅してください。 
日本語で記述をお願いします。 
実装したテストコードは、直接反映させるのではなくマウスからコピペできる形で出力してください。

期待するテストケースの数

自分ならこれくらい書くだろうというテストケースの数を期待値として設定

合計29ケース

  • 異常系 x 3
  • afterのパス x 13
    • (a_name == 'specific_a_name' && b_name == 'specific_b_name')のパターン
    • (b_name == "fuga" && a_name == 'hoge')のパターン
    • a_nameとb_nameが上記以外のパターン
    • a_nameとb_nameが存在するが差分なしのパターン
  • beforeのパス x 13
    • (a_name == 'specific_a_name_2' && b_name == 'specific_b_name_2')のパターン
    • (b_name == "hoge" && a_name == 'fuga')のパターン
    • a_nameとb_nameが上記以外のパターン
    • a_nameとb_nameが存在するが差分なしのパターン

結果

  • 異常系は拾ってくれるんだけど、afterのパスとbeforeのパスが漏れていた
回数 テストケースの数 テスト失敗数 漏れテストケース数
1回目 10 2 19
2回目 11 2 18
3回目 13 4 16
4回目 11 4 18
5回目 13 4 16
6回目 13 4 16
7回目 13 4 16
8回目 13 4 16
9回目 13 4 16
10回目 13 4 16

FactoryBotを使ったrspecのテストをCopilotに書かせてみた

表題の通り、FactoryBotを使ったrspecのテストをCopilotに書かせてみた

使ったモデルは、GPT-4oである

コンテキストが少ないコードに対してなら基本的にテストコードは生成しやすい。

しかし、コンテキストの量が増えてくると、Copilotが活躍しやすいコードを考えるで記載したように、それなりのものを出力させるのが途端に面倒になる。

今回は、しっかりと時間をとって、コンテキストの量が多い結合レイヤーのrspecをCopilotに書かせてみた

その結果や便利だったプロンプトの書き方を備忘録としてまとめる

対象

似てはいるが振る舞いが違うメソッド5つに対してテストコードを書かせてみた
メソッドに干渉するコンテストとしては、DBキャッシュメタプロである
コードの行数は少ないが、内在性負荷と外在性負荷、共に高いコードを選んだ

結果

1時間ほどかけて75点くらいのテストコードを吐き出させることが出来た
テストケースは網羅されているが一部のテストが落ちているような状態のテストコードだった

感想

単体テストと比較して結合テストのプロンプトを書くのは難易度が高かった。
現状結合レイヤーのテストに関しては、プロダクトコードの振る舞いを自身が知っている前提であれば、copilotに命令を出力させる事ができる。
つまり、アーキテクチャやDB設計などのプロダクトコードに関わるコンテキストを理解した上でならツールとして使える。

しかし、知らない状態ではcopilotから実際に使えるレベルのテストコードを吐き出させるのは難しいと感じた。

0からコードを書き始めるようなフェーズで、Copilotを使ってテストコードを生成させる場合、学習データが足りなくてもっと難しくなりそう。

プロンプトについて

ここからは、出力が改善したプロンプトをまとめていく

実装箇所を固定させる

プロンプト:

xxx_spec.rbの以下のdescribe内にテストコードを1つずつ実装してください
###
xxx_spec.rbのdescribe 'method1'
xxx_spec.rbのdescribe 'method2'
xxx_spec.rbのdescribe 'method3'
xxx_spec.rbのdescribe 'method4'
xxx_spec.rbのdescribe 'method5'
###

実装箇所を固定させないと、require "test_helper"を上書きしたり、他のテストケースを破壊したりで大変なことになった。
他のテストケースを破壊するな・上書きするなと命令しても意味を成さず破壊の限りを尽くされ仕方なく実装箇所を固定したところ破壊されなくなった。
このプロンプトは、テストコードのみならずプロダクトコードにも使えるような気がしている。

他の_spec.rbを学習させる

プロンプト:

参考となる_spec.rbを添付しました。その中で使われているFactoryBotのテストデータを参考にして実装してください。重要なのは可能な限り実装済みのテストデータと今回実装するテストデータの表現を統一することです。

既に実装済みのspecを学習させた
これにより、ゴミみたいなテストデータの出力は減った
チームが暗黙的に持ってるテストコードの作法みたいなのも真似て書いてくれるので、そういった細かいところも反映してくれた

プライベートメソッドに注目させる

プロンプト:

特に`_.rb`の`Class XXX`のプライベートメソッドの振る舞いに注目してください。
実装しようとしているテストコードの期待値に関係してきます。

かなり曖昧なプロンプトだけど、プライベートメソッドの振る舞いを汲み取ってくれた事によってテストデータの生成が改善した

確認過程を入れる

プロンプト:

実装が完了したら以下の観点を1つずつ確認してください。
###
生成したテストコードの変数に未定義のものはないか。もし、該当するのであれば、`let`や変数代入を使って定義すること。
生成したテストコードのデータの型は他の`_spec.rb`に定義されている表現を可能な限り使っているのか。
生成したテストコードの変数は共通化されているものではないのか。もし該当するのであれば、新たに定義するのではなく、共通化されたものを使用すること。
###

そして、最後に確認した内容を出力してください

実装が終わった後に、確認を実行させるプロンプトを入力
このプロンプトが実行される前に、未定義の変数がテストコードに生成されていたが、定義されるようになった
また、確認した内容を出力させる事によって、Copilotを通じた修正がやりやすくなった

日本語出力

プロンプト:

日本語でテストケースを書いてください

この命令がなければ、テストケースを英語で記述される

読み込ませたファイルについて

コンテキストが多い結合レイヤーということもあり、今回、10~15ファイルほどCopilotに食わせて出力させた
そのファイルの内訳としては、大体1/3がテストコードで、2/3がプロダクトコードがである
FactoryBotのテストデータを改善するためにschema.rbを使ったりもした
ファイルの選定という作業はコードの理解がかなり求められた
全のプロダクトコードのファイルを一瞬で読み取ってくれるような生成AIが出てくると、負担が減ってくる気はした

Copilotが活躍しやすいコードを考える

最近、Copilotを使ってコードを書くことが多くなってきた。
自分が気づいたポイントについて書きたい。

単体テストの実装について

単体テストを自分で1ケース書いて、「こんな感じで他のテストケースも実装してくれ」ってCopilotに命令して使うことが多い
なんで1ケース書くかというと生成結果が良くなるからである
具体的な例を示してやることが大事らしく、文章だけの場合より望んだコードが手に入る確率が上がる

最初の1歩目を人間が担当して、後の部分をAIにやってもらう
最近、これを意識して開発しているが生産性が上がった気がする
人間とAIが合体したケンタウロスエンジニアを目指そう

コンテキストが増えると面倒臭い

単体テストの実装だと指示が容易で楽なのだが、Copilotに食わせるコンテキストの量が増えると面倒になり自分で書いた方が早い状態になってしまう

ここでいうコンテキストとは、ドメイン知識とか、共有依存(DB、ファイル)とか、アプリケーションアーキテクチャとか、抱えている技術負債とか色々である
この現象は、泥団子状態のコードに改修を加える時や結合テストのコードを書くときに発生した

現状では、Copilotを使うときは場所を選んで使った方がいい
自分が使いこなせていないだけな気もするが、使う場所によっては逆に効率が落ちる気がした

Copilotが活躍できる範囲を増やす

Copilotが活躍できる範囲をリファクタリングによって広げていくことが大事だと考える
例えば、メタプロが入ったコードや泥団子化したレイヤーでCopilotを使うのは難しい Copilotに対する指示が簡単になるようなアーキテクチャを普段から維持すべきなのである
各レイヤーの責務がハッキリとしており且つ疎結合アーキテクチャを目指そう
このようなアーキテクチャは、単体テストが容易に実装できるようになるのでCopilotが活躍する

指示の容易性

指標といえば、テスト容易性とか、保守性とか、凝集度とか、色々ある訳だが、ここにCopilotに対する指示の容易性があったら面白いかもなと思った

指示の容易性が低ければ、コード内に説明がされていない多くのコンテキストが含まれていると仮定して、下みたいなリファクタリングフローはどうだろうか

Copilotを使ってコードを拡張する指示を出してみる

指示を出すのが難しい
Copilotに対する指示の容易性が悪い

設計の観点でコンテキストを可能な限り取り除いたり、コンテキストに対して、コメントで補足を入れる

もう一度、Copilotを使ってコードを拡張する指示を出してみて 指示が簡単に出せるようになったのかを確認する

今までやってきたリファクタリングに、コメントという観点はなかった 指示の容易性であれば、コメントという観点を捉えることが出来る

AIを開発フローに組み込む新しいアプローチとして良いのではなかろうか

テストピラミッドにおける結合テストについて

テストピラミッドにおける結合テスト部分について

結合テストの種類

テストピラミッド内では、結合テストと一括りにされているが、結合テストの中にも種類があり異なる性質を持つ

  • DBやファイルへのアクセス等の共有依存がない結合レイヤーに対する自動テストであれば、テストは高速に稼働する
  • 共有依存があるレイヤーに対する自動テストであれば、テストは遅くなる
    • ※ mockやstubを使わないケースを想定

何が言いたいのかというと、共通依存がない結合レイヤーのテストは、単体テストと同じ性質を持つ為、テストピラミッドの真ん中ではなく一番下に位置するのでは?という事

具体例を交えつつ考えてみる

  • 共有依存が3つあるケース
    • Workerを起動した後、DBを見にいき、抽出したデータをCSVに書き込むコードのテスト

どこをmockするんだよ、みたいない意思決定も必要だし、まあ面倒くさい。多分もっさりしてるだろうね。

  • 共有依存がないケース
    • 差分を計算して、差分を整形して、差分の結果をデコレートするコードのテスト

コード自体複雑かもしれないが、共有依存がない分、何も考えなくてもテストコードが書けるし高速に稼働する

共有依存の数に注目する

下みたいにテスト対象コードが持つ共有依存の数に注目したらバランス良くテストを実装できないだろうか

共有依存が2つあるレイヤーのテストコードの数 <  共有依存が1つだけのレイヤーのテストコードの数 < 共有依存が0のレイヤーのテストコードの数

共有依存の数を指標に置いてあるので、E2Eも一番上にくるし、ある程度はテストピラミッド通りになる

このモデルに従うメリットを紹介する

共有依存が少なくなるようにコードを書く為には、各コードの責務を細かく分割して実装する必要がある

責務毎にまとめられたコード群は凝集度が高くメンテナンスしやすい
つまり、負債になりにくく可読性も高いコードになる

このようなコードが増えると、開発生産が向上し、より多くの機能をリリースできる体制が手に入る

テストコードを入り口としたリファクタリング

以下、登壇内容について書きたい.

www.slideshare.net

テストコードは、プロダクトコードの負債の影響をもろに受けるので、その性質を利用してやれないだろうかと考えているわけだ.

プロダクトコードから負債の影響を受けるということは、アプリケーションアーキテクチャ全体からも影響を受ける事になる.
責務が細かく分割されていなくてFatControllerのような症状を持ったプロダクトである場合、結合テストの数が増える. この事から、「各レイヤー毎におけるテストケースの実装数」がカバレッジの一つになるのでは無いかと考える

例えば、(結合テストのケース実装数 / 全体のテストケース実装数) * 100 が 50なんて帰ってきたら、アプリケーションアーキテクチャそのものが負債になっている可能性が高く、コードベースのリファクタリングを頑張ってもどうしようもない可能性がある.

マクロな視点での負債をキャッチするのに、この指標は有効だと今は思っている. 今後、この指標を実務で使う機会があれば、都度更新していこうと思う

RailsのModelからレポジトリ層とドメイン層の責務を分割する設計を考える

前提

  • RailsMVC構成になっている
  • Model部分にDBを使ったデータ操作ドメインロジックの実装が入ってくるのでレポジトリ層とドメイン層の責務が両方入ってくる
  • この状況だとテストをDBが依存する部分と依存しない部分に分離して実装できなくなる
  • 集中的にテストを実装したいドメインロジック部分のコードにDBが絡む事になる。つまり、テストデータの生成にDBの制約が絡んでくるので、テストが重たくなったり、テストデータの生成が時に難しくなり保守性が下がる
  • この状況を改善しテストを実装しやすくするために、レポジトリ層とドメイン層の責務を分割する設計を考えたい

設計一覧

どんな設計をしたら問題を解消できるのかを考えてみる

① RepositoryとEntity or Dtoを登場させてみる

説明

  • DBを使ったデータ操作に関する責務をRepositoryとModelに、ドメインロジックをEntityに分割する
  • DIして渡されたEntityをRepositoryが使う
  • Repositoryに対して、DB関連のテストを実装する
  • Entityに対して、ドメインロジックのテストを実装する
  • 業務システムで、Entityに対して名前をつけるのが難しい場合はDto(data transfer object)でも良い
  • テスト実装数は、 Entity > Repositoryにする

メリット

  • ないかもしれん

デメリット

  • ロジック操作にEntityが入ってくるので、他モデルから辿って使う場合ドメインロジックに触れにくくなる
    • 例えば、member.school.xxxみたいな使い方ができなくなる
  • データ操作に関する責務がModelとRepositoryに散る
  • Entityの実装コストが高そう

② ModelとServiceに分離してみる

説明

  • DBを使ったデータ操作に関する責務をModelに、ドメインロジックをServiceに分断する
  • DIして渡されたModelをServiceが呼び出す
  • Modelに対して、DB関連のテストを実装する
  • Serviceに対して、ドメインロジックのテストを実装する
  • テスト実装数は、 Service > Modelにする

メリット

  • DBを使うデータ操作 = Model, ドメインロジック = Serviceってなるから統制しやすい

デメリット

  • ①と同じように他モデルから辿って使う場合ドメインロジックに触れにくくなる
  • ドメインモデル貧血症
  • Serviceは色んな意味があってチームによってやる事が変わるような事が多いので名前が微妙かも

③ Model/Domain層を作ってみる

説明

  • Modelと対の関係にあるModel/Domain層を作る
  • Model/DomainをModelが呼び出す
  • Model/Domainは、複雑なドメインロジックを実装する責務を持つ
    • 複雑度が高かったり、テストケースが膨れ上がりそうなドメインロジックにフォーカスする
  • Modelに対して、DB関連のテスト+ドメインロジックのテストを実装する
  • Model/Domainに対して、複雑なドメインロジックのテストを実装する
  • テスト実装数は、 Model > Model/Domainになる
    • Model/Domainは複雑なドメインロジックのみ切り出すという制約があるのでModelの方が多くなるはず

メリット

  • 他モデルから辿って使う場合ドメインロジックに触れられる

デメリット

  • レポジトリとドメインの完全な分断ができない
    • Railsを使ってたら仕方ない気はする
  • 複雑な」の基準が人によるので循環的複雑度等で計測して判定する必要がある

まとめ

  • Railsの思想に少しでも乗っかっている③の方向性が一番マシな気がする
  • Railsの思想に出来るだけ逆らわず、良い感じの設計を提案していきたい
  • 今後も何か新しい設計案を思いついたら更新していく