mybook

コードレビューと3R — チームで品質を上げる

コードレビューの目的を再確認する

「ユイさん、今日から僕のPRレビューに入ってもらえますか?」

後輩のケンタから声がかかった。入社半年のケンタは意欲的だが、コードレビューを受けるのがまだ少し怖そうだった。

ユイはPRを開いて、しばらく沈黙した。コードは動きそうだが、何かが引っかかる。でも「何かが引っかかる」だけではレビューコメントにならない。どうすれば良いレビューができるのか?

田中さんに聞くと、「コードレビューの目的は何だと思う?」と聞き返された。

  • バグを見つけること?
  • コーディング規約の確認?
  • 知識の共有?

「全部正しい。でも根本は——このコードが将来変更できるかどうかを確認することだ。動くコードと、変更できるコードは別物。3Rのレンズでレビューするんだ」

INFO

コードレビューはコードへの批評であって、人への批評ではありません。「あなたのコード」ではなく「このコード」と表現するだけで、受け取りやすさが大きく変わります。目標は「良いコードにすること」であり、「この人を批判すること」ではありません。

3Rレビューチェックリスト

ユイはチームのPRテンプレートに、3Rチェックリストを追加した。

# コードレビューチェックリスト(.github/PULL_REQUEST_TEMPLATE.md)
 
## 変更の概要
<!-- この PR は何をしているか 1〜2文で -->
 
## レビュワー向けチェックリスト
 
### R1: Readability(可読性)
- [ ] 変数名・メソッド名を読むだけで意図が伝わるか
- [ ] 1つのメソッドが1つのことだけをしているか(目安: 15行以内)
- [ ] 「なぜ」が不明な箇所にコメントがあるか
- [ ] マジックナンバーが定数化されているか
- [ ] Railsの規約に従っているか(scope, callback の使い方)
 
### R2: Reusability(再利用性)
- [ ] 同じロジックが複数箇所にコピーされていないか
- [ ] 共通化できる処理は既存の抽象に乗れているか
- [ ] 新しいService/Concern/Gemとして切り出す価値があるか
 
### R3: Refactorability(リファクタリング可能性)
- [ ] 新しいユニットテストが書かれているか
- [ ] 変更の影響範囲が限定されているか(疎結合か)
- [ ] 将来の変更を予測した過度な設計になっていないか(YAGNI)
- [ ] テストが依存する外部サービスはモックされているか
 
### テスト
- [ ] テストがグリーンになっているか(CIの確認)
- [ ] カバレッジが下がっていないか
- [ ] エッジケース(空の配列、nil、0など)がテストされているか

良いレビューコメントの書き方

レビューコメントは「問題を指摘する」だけでなく「改善の方向を示す」ことが大切だ。

# NG例(批判だけで改善案がない)
「このメソッドは読みにくい」

# NG例(感情的)
「なぜこんなコードを書いたの?」

# NG例(漠然としている)
「もっとRailsらしく書いてほしい」
# OK例(問題と改善案を一緒に、コードで示す)
「このメソッドが20行あり、3つの処理が混在しています。
apply_discount, calculate_tax, build_receipt に分けると
各処理が独立してテストしやすくなりませんか?

例えば:
def process_payment(order)
  apply_discount(order)
  calculate_tax(order)
  build_receipt(order)
end
」

# OK例(質問形式で相手の意図を確認してから改善提案)
「このcaseの分岐は将来また増える可能性がありますか?
もしそうなら、Strategyパターンにしておくと追加が楽になりますが
いかがでしょうか?」

# OK例(賞賛も伝える——良いコードは積極的に認める)
「このParameter Objectの導入、素晴らしいですね!
引数が5つから1つになって、呼び出し側が格段に読みやすくなりました。」

コメントの重み付け(prefix を使う)

すべてのコメントが同じ重要度ではない。prefix を使って優先度を伝える。

# 必須の変更(マージ前に直してほしい)
[MUST] 変数 u が何を指すかわかりません。order_user など意図がわかる名前にしてください

# 推奨(直した方が良いが、任意)
[SHOULD] このif文の入れ子が深いので、ガード節で整理すると読みやすくなります

# 提案(議論の余地がある)
[NIT] 個人的にはServiceクラスより Concern の方が適切かもと思いますが、どうでしょうか

# 疑問(理解できていない、情報が欲しい)
[?] このメソッドを呼ぶケースはどのくらいの頻度を想定していますか?

# 称賛(良い実装を認める)
[NICE] このパターンは再利用性が高くて良いですね!

実際のレビュー: ケンタのPRをレビューする

実際にケンタのPRをレビューしてみよう。

# ケンタのコード(PRの内容)
def process_refund(order_id, reason)
  o = Order.find(order_id)
  if o.status == 'completed' && o.created_at > 30.days.ago
    o.status = 'refunded'
    o.refund_reason = reason
    o.save
    r = Refund.create(order_id: o.id, amount: o.total, reason: reason)
    UserMailer.refund_notification(o.user.email, o.id, r.id).deliver_now
    PaymentGateway.refund(o.payment_id, o.total)
    true
  else
    false
  end
end

ユイはチェックリストに沿ってレビューコメントを書いた。


[MUST] R1: Readability — 変数名

o より orderr より refund という変数名の方が次の行を読む前から意図が伝わります。

Before: o = Order.find(order_id) After: order = Order.find(order_id)


[MUST] R1: Readability — メソッドが複数の責任を持っています

このメソッドは「返金可能チェック」「ステータス更新」「返金レコード作成」「メール通知」「決済ゲートウェイへの返金」の5つをしています。Extract Methodを検討しませんか?


[MUST] R2: Reusability — ビジネスルールの定数化

30.days.ago はビジネスルール(30日間の返金可能期間)です。定数化すると意図が伝わりやすくなります。

Before: o.created_at > 30.days.ago After: REFUNDABLE_PERIOD = 30.daysorder.created_at > REFUNDABLE_PERIOD.ago


[SHOULD] R3: Refactorability — deliver_now は避けた方が良いです

deliver_now はテストでメール送信が実際に実行されます。deliver_later に変更するとJobとして非同期化でき、テストでは have_enqueued_mail で検証できます。


[MUST] R3: Refactorability — テストがありません

このメソッドのテストはありますか?特に:

  • 「30日以上前の注文は返金不可」のケース
  • 「返金成功時にメールが送られる」のケース
  • 「決済ゲートウェイへの返金」のケース

これらをカバーするテストがないと、次回変更するときに動作確認ができません。


[NIT] 例外ではなく ServiceResult でエラーを伝えた方がコントローラが扱いやすいです

# 現状: true/false
return false
 
# 提案: ServiceResult でエラー理由も伝える
ServiceResult.failure('返金期限が過ぎています')

レビュー後: 改善されたコード

ケンタはレビューコメントを受けて、コードを大幅に改善した。

# After: ユイのレビューを受けて改善したケンタのコード
class RefundService
  REFUNDABLE_PERIOD = 30.days
 
  def initialize(order)
    @order = order
  end
 
  def call(reason:)
    return ServiceResult.failure('注文が完了状態ではありません') unless @order.completed?
    return ServiceResult.failure('返金期限(30日)が過ぎています') unless within_refundable_period?
 
    ActiveRecord::Base.transaction do
      create_refund_record(reason)
      update_order_status(reason)
      notify_customer
      process_gateway_refund
    end
 
    ServiceResult.success(@refund)
  rescue PaymentGateway::Error => e
    ServiceResult.failure("決済ゲートウェイエラー: #{e.message}")
  end
 
  private
 
  def within_refundable_period?
    @order.created_at > REFUNDABLE_PERIOD.ago
  end
 
  def create_refund_record(reason)
    @refund = Refund.create!(
      order: @order,
      amount: @order.total,
      reason: reason
    )
  end
 
  def update_order_status(reason)
    @order.update!(status: :refunded, refund_reason: reason, refunded_at: Time.current)
  end
 
  def notify_customer
    RefundMailer.notification(@order.user, @order, @refund).deliver_later
  end
 
  def process_gateway_refund
    PaymentGateway.refund(@order.payment_id, @order.total)
  end
end
# テストも追加した
RSpec.describe RefundService do
  let(:order) { create(:order, :completed, created_at: 10.days.ago, total: 5_000) }
  subject(:service) { described_class.new(order) }
 
  describe '#call' do
    context '返金可能な注文の場合' do
      it '返金を成功させる' do
        result = service.call(reason: '商品の不具合')
        expect(result).to be_success
      end
 
      it '注文のステータスをrefundedに更新する' do
        service.call(reason: '商品の不具合')
        expect(order.reload.status).to eq('refunded')
      end
 
      it 'Refundレコードを作成する' do
        expect { service.call(reason: '商品の不具合') }.to change(Refund, :count).by(1)
      end
 
      it '顧客へ通知メールをキューに追加する' do
        expect { service.call(reason: '商品の不具合') }.to have_enqueued_mail(RefundMailer, :notification)
      end
    end
 
    context '30日を超えた注文の場合' do
      let(:order) { create(:order, :completed, created_at: 31.days.ago, total: 5_000) }
 
      it '失敗を返す' do
        result = service.call(reason: '返品希望')
        expect(result).not_to be_success
        expect(result.error).to include('返金期限')
      end
 
      it 'Refundレコードを作成しない' do
        expect { service.call(reason: '返品希望') }.not_to change(Refund, :count)
      end
    end
 
    context '完了していない注文の場合' do
      let(:order) { create(:order, :pending, total: 5_000) }
 
      it '失敗を返す' do
        result = service.call(reason: 'キャンセル希望')
        expect(result).not_to be_success
      end
    end
  end
end
Loading diagram...

「すごく良くなりました」ケンタが言った。「レビューを受けるのが怖かったんですが、具体的な改善提案があると学びになります」

「コードレビューはチーム全体が3Rを学ぶ場所だ」田中さんが加えた。「ユイさんが示した改善のパターン——変数名、メソッド分割、テスト——これが次のPRでケンタさんの自然な書き方になる。それがチームの文化として積み重なっていく」

自動チェックで「機械的なレビュー」を排除する

人間のレビュワーは「本質的な設計の議論」に集中すべきだ。インデントや命名規則の指摘は機械に任せる。

# .github/workflows/code_quality.yml
name: Code Quality Check
 
on:
  pull_request:
    branches: [main, develop]
 
jobs:
  rubocop:
    name: RuboCop(静的解析)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - name: Run RuboCop
        run: bundle exec rubocop --format github
        # PRに直接インラインコメントが付く(--format github)
 
  test_and_coverage:
    name: テストとカバレッジチェック
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: password
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - name: Setup database
        run: bundle exec rails db:setup RAILS_ENV=test
      - name: Run RSpec
        run: bundle exec rspec --format progress
        env:
          COVERAGE: 'true'
      - name: カバレッジ閾値チェック
        run: |
          COVERAGE=$(cat coverage/.last_run.json | jq '.result.line')
          echo "Current coverage: ${COVERAGE}%"
          if (( $(echo "$COVERAGE < 80" | bc -l) )); then
            echo "❌ Coverage ${COVERAGE}% is below threshold 80%"
            exit 1
          fi
          echo "✅ Coverage ${COVERAGE}% meets threshold"
# .rubocop.yml — チームの合意事項を設定ファイルで管理
AllCops:
  NewCops: enable
  TargetRubyVersion: 3.2
  Exclude:
    - 'db/schema.rb'
    - 'db/migrate/**/*'
    - 'bin/**/*'
    - 'vendor/**/*'
 
# === Readability 関連 ===
Metrics/MethodLength:
  Max: 15
  Description: 'R1: メソッドは15行以内(単一責任の原則)'
 
Metrics/CyclomaticComplexity:
  Max: 8
  Description: 'R3: 条件分岐は8以下(テストケース数の目安)'
 
Metrics/AbcSize:
  Max: 20
 
# 命名規則
Naming/VariableNumber:
  EnforcedStyle: snake_case
 
# === Reusability 関連 ===
Rails/HasAndBelongsToMany:
  Enabled: true
  Description: 'has_many :through の使用を推奨'
 
Rails/InverseOf:
  Enabled: true
 
# === 安全性 ===
Rails/DynamicFindBy:
  Enabled: true
 
Security/YAMLLoad:
  Enabled: true
 
Rails/SkipsModelValidations:
  Enabled: true
  Description: 'update_column等はバリデーションをスキップするため要注意'

Danger: PRレビューの自動化をさらに進める

# Dangerfile — PRルールの自動チェック
# https://danger.systems/ruby/
 
# PRサイズチェック(大きすぎるPRを警告)
if git.lines_of_code > 500
  warn("このPRは#{git.lines_of_code}行の変更があります。分割を検討してください")
end
 
# テストファイルなしの変更を警告
has_app_changes = !git.modified_files.grep(%r{app/}).empty?
has_test_changes = !git.modified_files.grep(%r{spec/}).empty?
if has_app_changes && !has_test_changes
  warn("app/以下の変更がありますが、spec/に対応するテストがありません")
end
 
# PRテンプレートの3Rチェックリストが記入されているかチェック
if github.pr_body.length < 100
  warn("PRの説明が短すぎます。何を変更したか、なぜ変更したかを記載してください")
end
 
# CHANGELOGの更新チェック(外部公開Gemの場合)
if git.modified_files.include?('lib/discount_engine/version.rb')
  warn("version.rbが更新されましたが、CHANGELOG.mdが更新されていません") unless git.modified_files.include?('CHANGELOG.md')
end

チームへの3R文化の広め方

「3Rはひとりで実践しても限界がある」田中さんが言った。「チームの文化にする必要がある」

ユイはチームの3R普及のために、以下の活動を始めた。

段階的な普及ステップ:

Phase 1(1ヶ月目): 個人実践
- 自分のPRで3Rを実践する
- レビューで3Rの観点からコメントする

Phase 2(2ヶ月目): チームへの共有
- 週次の技術共有会で1つのパターンを紹介する
- 「今週の3R改善」を10分で話す

Phase 3(3ヶ月目): チームの標準化
- PRテンプレートに3Rチェックリストを追加
- .rubocop.ymlでルールを自動化
- レビューガイドラインをドキュメント化

Phase 4(4ヶ月目以降): 文化として定着
- 新入社員のオンボーディングに3Rを組み込む
- 四半期ごとに品質指標(カバレッジ、RuboCop違反数)を計測・共有

半年後、新入社員のヒロキが初めてのPRを出したとき、レビューコメントは自然と3Rの観点で書かれていた。「このメソッド名だと意図が伝わりにくいです(R1)」「この処理は既にDiscountServiceにあります(R2)」——チームの共通言語が生まれていた。

「最後に、インフラレベルの3Rを見ていこう」田中さんが言った。