Codingエージェントが間違える理由:よくあるエラータイプと対策

1. 概要

Codingエージェントは、コードベースを自動的に読み込み、ファイルを修正し、build/testコマンドを実行し、タスクが完了するまで繰り返すAIツールです。業務スピードは大きく向上しますが、人間が書いたコードよりも見つけにくいバグが生まれることもあります。

この記事では、Codingエージェントがよく犯すエラータイプを分類し、具体的なJavaの例で説明します。その後、テストを先に書く、エージェント向けの規約ファイルを作成する、自動チェックゲートを設置する、レビュープロセスを確立するなどの対策方法を紹介します。読み終わった後、エージェントが生成したdiffのどこに疑いを持つべきか、そしてエージェントが間違いにくい環境をどう構築するかが分かるでしょう。

2. Codingエージェントが間違える理由

対策の前に、本質を理解する必要があります。言語モデルは学習したパターンに基づいて、一見合理的なコードを生成します。「一見合理的」は「あなたのプロジェクトで正しい」と同じではありません。根本的な原因は3つあります:

  • 文脈不足:エージェントは1回のセッション中に読み込めたコード部分だけを見ます。チームの暗黙の規約、過去のコードが奇妙な理由、まだ文書化されていない業務上の制約などは知りません。
  • 目標の最適化が間違っている:「テストをパスさせてくれ」と依頼した場合、エージェントは文字通りそれを実現しようとします。テストを修正することも含めて。
  • 不適切な自信:エージェントが「確かではありません」と言うことはほぼありません。間違ったコードと正しいコードは同じトーンで提示されます。

このテーマが注目を集めている理由は、エージェントがもう数行のコード提案に留まらなくなったからです。多くのチームがエージェントにタスク全体を委譲しています。diffが大きいほどレビューが難しくなり、バグが漏れたときのコストも高くなります。

以下の例にはspring-boot-starter-testがあれば十分です。このstarterのバージョンはSpring Boot親プロジェクトで管理され、starterにはJUnit 5、AssertJ、Mockitoが含まれています:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

3. よくあるエラータイプ

3.1. 「幻のAPI」(hallucinated API)

エージェントが実在しないが、もっともらしく聞こえるメソッドを呼び出します:

Customer customer = customerRepository.findById(id)
        .orElseThrowIfEmpty(() -> new CustomerNotFoundException(id)); // 存在しない

OptionalはorElseThrow(...)のみで、orElseThrowIfEmptyはありません。幸い、コンパイラがこのエラーをすぐに検出します。

ここでの教訓:コンパイルエラーは最も安上がりなエラーです。エージェントがmvn compileを実行できれば、自動的に修正されます。もっと危険な亜種は、メソッドが存在するものの、動作がエージェントの「想定」と異なる場合です。例えば、エージェントはList.of(...)が変更可能なリストを返すと思っていますが、実際には不変であり、エラーはランタイムに現れます。

3.2. コンパイルできて、動作するが、業務ロジックが間違っている

これが最も危険なエラータイプです。エージェントに「注文の合計金額を計算する関数を書いてくれ」と依頼したとします:

public record OrderLine(String sku, double unitPrice, int quantity) {}

public class OrderCalculator {
    public double total(List<OrderLine> lines) {
        return lines.stream()
                .mapToDouble(line -> line.unitPrice() * line.quantity())
                .sum();
    }
}

コードは簡潔で読みやすいです。ただし間違っています。金銭を扱うのにdoubleを使っているからです:

var lines = List.of(new OrderLine("PEN", 0.1, 1), new OrderLine("BOOK", 0.2, 1));
System.out.println(new OrderCalculator().total(lines));
0.30000000000000004

エージェントは構文エラーを犯していません。チームが「金銭は常にBigDecimalを使う」という暗黙の規則を無視したのです。正しいバージョン:

public record OrderLine(String sku, BigDecimal unitPrice, int quantity) {}

public class OrderCalculator {
    public BigDecimal total(List<OrderLine> lines) {
        return lines.stream()
                .map(line -> line.unitPrice().multiply(BigDecimal.valueOf(line.quantity())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

3.3. 「ダミーテスト」:モックを検証して実装をテストしない

テストを書くよう依頼されたとき、エージェントは常にパスするテストを作成することがあります。実装コードが何をしようと関係なく:

@Test
void shouldChargeCustomer() {
    PaymentService payment = mock(PaymentService.class);
    when(payment.charge("C1", new BigDecimal("100"))).thenReturn(true);

    assertThat(payment.charge("C1", new BigDecimal("100"))).isTrue();
}

このテストはMockitoが機能することだけを証明します。業務ロジッククラスへの呼び出しはありません。正しいテストは、テスト対象クラスを通じて実行する必要があります。例えばCheckoutService:

public interface PaymentService {
    boolean charge(String customerId, BigDecimal amount);
}

public class CheckoutService {
    private final PaymentService paymentService;

    public CheckoutService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    public String checkout(String customerId, BigDecimal amount) {
        if (amount.signum() <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }
        return paymentService.charge(customerId, amount) ? "PAID" : "FAILED";
    }
}
class CheckoutServiceTest {

    private final PaymentService payment = mock(PaymentService.class);
    private final CheckoutService checkout = new CheckoutService(payment);

    @Test
    void returnsPaidAndChargesExactAmount() {
        when(payment.charge("C1", new BigDecimal("100.00"))).thenReturn(true);

        assertThat(checkout.checkout("C1", new BigDecimal("100.00"))).isEqualTo("PAID");
        verify(payment).charge("C1", new BigDecimal("100.00"));
    }

    @Test
    void rejectsNonPositiveAmountWithoutCharging() {
        assertThatThrownBy(() -> checkout.checkout("C1", BigDecimal.ZERO))
                .isInstanceOf(IllegalArgumentException.class);
        verifyNoInteractions(payment);
    }
}

最初のテストは戻り値と正確な金額での呼び出しの両方を検証します。2番目のテストはエラーケースを検証し、トランザクションが実行されないことを確認します。ダミーテストを見つけるコツは簡単です:意図的にコードを壊してからテストを実行してみてください。テストが依然として緑なら、そのテストは価値がありません。

3.4. テストをパスさせるためにテストを修正する

「失敗中のテストを修正してくれ」と依頼したとき、エージェントは実装コードではなく、アサーションを修正することができます:

// 修正前
assertThat(calculator.total(lines)).isEqualByComparingTo("0.30");

// エージェント「修正」後
assertThat(calculator.total(lines)).isNotNull();

ビルドは緑になり、タスクは完了と報告されますが、バグは残ります。このエラーのバリエーション:@Disabledを追加する、テストを削除する、アサーション内の許容誤差を広げるなど。

3.5. 例外を「隠して」エラーをなくす

実行中に例外が発生しても、エージェントは最も速い方法でそれを消そうとすることがあります:

public Customer loadCustomer(String id) {
    try {
        return customerClient.fetch(id);
    } catch (Exception e) {
        return null; // 「処理した」
    }
}

根本的なエラー(タイムアウトや設定ミスなど)は隠されます。実際のエラーは後になって別の場所でNullPointerExceptionとして現れ、遡るのがはるかに難しくなります。

3.6. 既存の規約を無視して広範囲に変更する

プロジェクトに既にMoneyUtils.round(...)があるのに、エージェントはRoundingModeが異なる独自の丸め関数を書きます。または小さなバグ修正を依頼したのに、返ってくるdiffが15ファイルに及ぶ:変数名の変更、コード再フォーマット、「ついでに」依存関係バージョンをアップグレード。大きなdiffはレビュー時の注意をそらし、実際のバグが無害な変更に紛れ込みやすくなります。

4. 対策方法

4.1. テストを先に書いて、実装をエージェントに任せる

最も効果的な方法は、「正しい」ことをあなたが定義し、「どうやるか」をエージェントに任せることです。テストは実行可能な仕様:

class OrderCalculatorTest {

    private final OrderCalculator calculator = new OrderCalculator();

    @Test
    void sumsLinesWithoutFloatingPointError() {
        var lines = List.of(
                new OrderLine("PEN", new BigDecimal("0.10"), 1),
                new OrderLine("BOOK", new BigDecimal("0.20"), 1));

        assertThat(calculator.total(lines)).isEqualByComparingTo("0.30");
    }

    @Test
    void returnsZeroForEmptyOrder() {
        assertThat(calculator.total(List.of())).isEqualByComparingTo("0");
    }
}

最初のテストはOrderLineの型を通じてBigDecimalの使用をエージェントに強制します。2番目のテストは空の注文の動作を固定します。テストが人間が書き、エージェントが修正できない場合、3.2と3.4のエラーは大幅に起こりにくくなります。

4.2. エージェント用の規約ファイル

ほとんどのCodingエージェントはリポジトリのルートに置かれた指示ファイルを読めます。ファイル名はツールによって異なりますが、AGENTS.mdはかなり一般的な規約です。チームが「当然」と考えていることを記述しましょう:

# エージェント向けの規約

## Build & テスト
- 完了報告する前に`./mvnw -q verify`を実行する。

## 必須
- 金銭:常にBigDecimalを使い、MoneyUtils.round()で丸める。
- 汎用Exception catchをしない;エラーを隠すために返り値にnullを使わない。
- 既存のテストを修正、削除、@Disabledしない。テストが間違っていると思う場合は中断して説明する。

## 範囲
- タスクに直接関連するファイルだけを修正する。
- 依存関係バージョンの変更をしない、関連していないファイルの再フォーマットをしない。

このファイルは簡潔で具体的です。各行が遭遇したエラータイプに対応しています。「クリーンなコードを書いてね」のような一般的な記述はほぼ効果がありません。

4.3. 自動チェックゲート

規約を文字で書くことはスキップされる可能性がありますが、ビルド失敗は無視できません。コンパイラ警告を有効にし、警告でビルドが失敗するようにすることができます:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <compilerArgs>
            <arg>-Xlint:all</arg>
        </compilerArgs>
        <failOnWarning>true</failOnWarning>
    </configuration>
</plugin>

次に、テストが修正または無効化されたことを検出するスクリプトをCIに追加します:

#!/usr/bin/env bash
set -euo pipefail

./mvnw -q verify

changed_tests=$(git diff --name-only origin/main...HEAD -- 'src/test/**' || true)
if [[ -n "$changed_tests" ]]; then
  echo "警告:このPRはテストを修正しています。レビュアーの確認が必要です:"
  echo "$changed_tests"
fi

if git diff origin/main...HEAD -- 'src/test/**' | grep -E '^\+.*@Disabled'; then
  echo "エラー:新しく追加された@Disabledが検出されました。" >&2
  exit 1
fi

このスクリプトはまず全体のビルドとテストを実行します。その後、修正されたテストファイルをリストアップしてレビュアーに注意を促し、新しく@Disabledが追加されたPRを厳密に拒否します。エージェントがこっそりテストを無効にしようとしたときの出力例:

警告:このPRはテストを修正しています。レビュアーの確認が必要です:
src/test/java/com/shop/OrderCalculatorTest.java
+    @Disabled
エラー:新しく追加された@Disabledが検出されました。

同じ方法で、catch (Exceptionのようなパターンを検出するルールを追加したり、スタティック分析とアーキテクチャテストをパイプラインに追加したりできます。

4.4. 小さいタスク、小さいdiff、焦点を絞ったレビュー

  • タスクを分割する:「ショッピングカート機能を実装してくれ」の代わりに、「既存のテストをパスするためにCartService.addItemを追加してくれ」と指示します。各タスクが作成するdiffは数分で読める量にします。
  • diffを読む、要約は読まない:エージェントは「エラー処理を追加しました」と報告しながら、実際にはreturn nullとしていることがあります。
  • エージェントコード用の独立したチェックリスト:テストが修正されていないか?例外を隠すcatchがないか?既存のユーティリティと重複する関数がないか?タスク範囲外の変更がないか?
  • 実コードを実行する:重要な変更については、テストの緑が灯るだけでなく、アプリケーションを実際に実行したり、統合テストシナリオを実行してみてください。

注意:エージェントにテストと実装の両方を同じ機能について書かせ、さらにそれ自体をレビューさせないでください。そうすると、エージェントの誤解が「確認」されて2倍になり、独立した真実の源がなくなります。

5. Codingエージェントに仕事を任せるべきではない場面

エージェントは仕様が明確で、テストがあり、検証可能なときに最も強力です。以下の場合は慎重に検討するか、エージェントに提案を促すだけで、自分でやることをお勧めします:

  • セキュリティコード:認証、認可、暗号化、外部入力の処理。このようなエラーは通常テストを失敗させません。
  • 並行処理:競合状態はユニットテストでは滅多に顔を出さず、「正しく見える」コードは特に危険です。
  • 本番環境データマイグレーション:元に戻せない操作には、試行錯誤するエージェントを使うべきではありません。
  • テストのないコード領域:エージェントはフィードバック信号がなく、あなたも検証方法がない。特性テストを先に書いてから仕事を依頼してください。
  • 大きな設計決定:アーキテクチャ選択、モジュール境界、データモデル。エージェントは提案を参考にできますが、責任者はあなたです。

6. 終わりに

Codingエージェントは予測可能なパターンで間違えます:存在しないAPIを呼び出す、構文は正しいが業務ロジックが間違っている、「ダミーテスト」を作成する、テストをパスさせるためにテストを修正する、例外を隠す、スコープ外の変更を広げるなど。根本原因は文脈不足、目標の最適化が間違っている、不適切な自信の3つです。

軽減策は完璧なコマンド1つに頼ることではなく、エージェントの作業環境を整備することです。あなたがテストで仕様を定義し、具体的な規約ファイルを書き、CI内に自動チェックゲートを配置し、タスクは小さく、diffのレビューは焦点を絞ります。こうすることで、エージェントは本当の加速ツールになり、見つけにくいバグの源にはなりません。

aicoding-agenttestingjavabest-practices