Cursor、テストコードでログ出力しまくって上手くいった話
AI自動開発の難点
AI自動開発の課題は、「思った方向にいかない」という1点に尽きます。
指示した通りに、いい感じにやってくれれば問題ないのですが、ハイコンテクストのコード解釈をさせ、解釈がズレると、誤って必要なコードを削除したり、破壊したりします。
この問題を解消するために、テストコードが担保してくれれば良いのですが、テストコードと実装の両方を変えられてしまうと、何が仕上がったのか説明できない状態となり、どこまで戻れば良いかも分からなくなります。
結論、ログ出力を入れると上手くいく
あらゆるブレークポイントとなる場所でログ出力を行い、
開発中あるいはテスト実行時にのみ、ログが出力される箇所を増やすことで、処理の解釈が進みます。
特に、このログ出力を テストコードで行い、テストを正しくする作業を早めると効果的だった、というのが結論になります。
例えば
LOG_LEVEL=debug deno test
のようにし、テスト実行時のみ、非常に詳細なテストのログを出力します。このためのログ出力をテストコードに含めます。
ログ出力の頻度は、例えば Claude 3.7 sonnet に入れてもらったものは、以下のようになります。「1つ処理したら1つ出力」くらいの頻度ですね。
指示は、
問題の発生した箇所へ、詳細をdebug_loggerでログに出力する処理を加え、詳細把握が可能なようにして。
くらいの、ざっくりしたものです。(今読むと、この日本語は変ですね。)
その後、
効果があったため、 Cursor Rules にて、 *_test.ts
の場合にログ出力を入れる指示を加えています。
どれくらい違うのか
「10時間くらい格闘したのち、git branch を全部捨てた作業」が、1時間でうまく通るくらい違います。
コツがいるんでしょう?
いくつかの前提が必要です。
1.テストが仕様を守っていること
まず、テストコードを記載するための、仕様書などのドキュメントが必要です。
正しいテストであり、仕様実装を再現可能であることが重要です。
つまり、
AIが、テストコードを、仕様書に基づいて書き起こせることが、第一条件になります。
これは、テストが正しい状態でない場合、テストをデバッグしても意味がなくなるためです。
2.テストが分割されていること
この点については、多くのケースで問題ないと思われます。
1つのテストの責務が明確であるほうが良いですし、おそらくすでにそうなっているであろうと思います。
1点、テストの方針がどうあるべきかは、 AIに最適化して考える必要がある と感じました。
特に、基本的なテストと、エッジケースのテストで別れている方が良いように思います。
変数によって挙動が変わる場合も、分けた方が理解しやすそうに思います。
今回のケースでは、Claude 3.7 sonnet からもレベル分け(レイヤー分け)を推奨されました。仕様の根幹部分と、表層部分までをレイヤーで分ける提案でした。(採用しました)
提案内容は、
一般的なソフトウェアテストの階層的アプローチと同じではあるもの、「単体から結合、E2Eへ」という流れよりも、「仕様の根幹から変更可能性の高いレイヤー」への流れというほうがしっくりきます。
同じ機能のテストでも基底部分の一貫性の担保ができていることを前提とし、表層的なテストへと進むことが良さそうに感じています。
3.出力の一貫性を保つこと
ログを加える指示は、
問題の発生した箇所へ、詳細をdebug_loggerでログに出力する処理を加え、詳細把握が可能なようにして。
くらいの、ざっくりしたもので行っていました。(全てのコードに入れるならば、都度の指示ではなく、rulesに入れても良いでしょう。)
この指示にある debug_logger
は、Cursorが自主的に作成したツールです。
このツールを入れる前は、ログの記述を散発的に依頼していたため、記法がばらついていました。その段階では、効果があるとは感じつつも劇的に良い感覚はありませんでした。
一方、 debug_logger
を用いるようになってからは、修正速度があがりました。テストコードのログ出力が安定し、一貫したものとなりました。そのため、おそらくですが、出力の一貫性を保つことが重要だと考えています。
debug_logger
とは?
構築するアプリケーションにも寄ると思いますが、私の場合は、小さいアプリを細かく進めていたので、
エラーが起きた箇所について、
問題の詳細把握ができるよう、チェックポイントごとにログを書き出すようにして。
チェックポイントは、関数の返り値や、引数へ渡す直前の値、受け取った直後の値、加工処理の前後などが該当します。
という類の指示を何度か Composerに投げかけていました。
何度か同じ指示をしていた結果、 Cursor が debug_logger
を作り始めました。
以下は、作成を指示していない debug_logger
が上手いことやってくれたので、後から「何を作ったのか」をChatへ聞いた結果です。
散発的なログの出力ではなく、 debug_logger
がハマったようでした。これ以降は、非常にスムーズにテストの構築が進み、実装がテストに合わせて修正されていきました。
意図して debug_logger
の作成を指示したわけではないため憶測になりますが、
おそらく処理の一貫性と解釈のしやすいログ出力になっているのだろうと思います。 上記画像の「主な機能」の log
と checkpoint
の違いから、見て取れます。
4. タスク化する
すでに実施している方が多いと思いますが、進捗は確認させた方が良いです。
@task.md に、テストエラーが出た一覧が記載されている。新エラーは一覧へ追記し、解消したエラーは完了をマークする。
実行回数、失敗回数、pass回数を同時に記録する。すでに存在する場合は件数カウントを加える。
新しいテストが生じた場合と、すでに解消した場合、その問題の中身を把握してもらうことが、ログ出力の目的です。
Cursor Composerも過去のやり取りを保持しているものの、テストの状態がどのように変化したか差分を確認できるファイルは作った方が良いと思います。
5. テスト前に、実装を粗く作ってしまう
動かないコードをコミットしながら進めるのは意に反するのですが、仕様書に基づいて、動かないコードを先に作ります。
そのうえでテストコードを作ります。
テスト駆動開発などの手法(詳しくはありませんけれど)とは反するかもしれませんが、気にせずにいきたいと思います。
はじめは、小さな機能を細かく1つずつ実装していました。
しかし、進みが遅いので、「がさーっ」と構築してくれないかな・・・それがAIの醍醐味じゃん・・・という気持ちになり、勝てませんでした。
まとめると
以下のような流れです。
- 仕様書を作る(といっても、定義レベルで行数も少ない)
- 実装を粗々やってもらう
- 仕様書と実装をもとに、テストを作ってもらう
- 足りないテストを探しだして、追加で作ってもらう
- 大量のテストが大量に通らないので、ログ出力を加えながら、テストを修正してもらう
- 必ず仕様書と照らして、テストを仕様書に合わせてもらう
- テストが通るように実装を直してもらう(仕様書に合わせてもらう)
これにより、まとまった仕様書をもとにして、そこそこ一気にテスト完成へ持っていくことが出来るでしょう。
なお、留意点としては、テストのカバレッジです。
テストがカバーしない範囲は、実装エラーが解消するわけではないため、テストカバレッジを増やしていく必要があります。引き続き、よい方法を模索していきたいと思います。
Discussion