0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIに直させたら「別の場所」が壊れた、の正体 — リファクタリングを壊さない安全網(仕様化テスト)実践ガイド

0
Posted at

はじめに:AIに「ここ直して」って頼んだだけなのに

AIコーディング支援を使っていて、こんなこと、ありませんか。

「この関数だけちょっと整理して」とお願いしただけなのに、なぜか 全然関係ないところ が動かなくなった。テストを流したら赤くなって、原因を追っていったら、頼んでもいない別の場所までAIが"ついでに"書き換えていた。

正直に言うと、僕も最初これで何度かやらかしました。見た目はきれいに直っている。コードも動いているように見える。でも、よく見ると振る舞いが微妙にズレている。「動くこと」と「壊れていないこと」は、全然違う。 これ、地味にしんどいんですよね。

面白いのが、これは僕らの気のせいでも、AIの出来が悪いわけでもないということ。2026年に出たAIエージェントのリファクタリング実態調査(arXiv 2511.04824「Agentic Refactoring: An Empirical Study of AI Coding Agents」)を読むと、こんな数字が出てきます。

  • AIエージェントがコードを書いたコミットの 約26% で、何らかのリファクタリングが行われていた
  • そのリファクタリングのうち 53.9% は、「リファクタする意図のないコミット」に混ざり込んでいた

つまり、AIに「機能を足して」とか「バグを直して」と頼んだだけなのに、半分以上のケースで、頼んでいない"ついでの書き換え"が一緒に混ざってくる ということなんです。論文ではこれを「tangled(もつれた)変更」と呼んでいます。さらにこの論文、テストがどれくらい関わっていたか、振る舞いがどれだけズレたか、という肝心の数字は「データが足りなくて測れていない」と正直に書いています。要するに、そこは人間が見張るしかない領域 として、まだ残っているんですね。

だから今日は、「AIに任せると壊れる」を「AIに任せても壊れない」に変えるための、いちばん地味で、いちばん効く土台の話をしたいと思います。キーワードは 「直す前に、安全網を張る」 です。

この記事は、こんな人に向けて書いています。

  • Claude CodeやCursorみたいなAIコーディング支援を使い始めた、または毎日使っている人
  • テストがほとんど無いコードを抱えていて、それでもAIに安全に手を入れたい個人開発者・小さなチーム
  • 「リファクタリングって結局なに?」というところから知りたい、まだAI開発に不慣れな人

専門用語は出てくるたびに噛み砕いて説明していくので、知らない言葉があっても置いていきません。安心して読み進めてください。


そもそもリファクタリングって何? なぜAIは静かに壊すの?

まず言葉から。リファクタリング(refactoring) というのは、ざっくり言うと 「外から見た振る舞いは変えずに、コードの内側だけを整理すること」 です。

イメージとしては 部屋の模様替え に近いです。家具の配置を変えたり、ごちゃごちゃした配線をまとめたりはする。でも、「その部屋に住んだときの住み心地」は変えない。コードで言うと、入力に対する出力(=振る舞い)は1ミリも変えずに、読みやすさ・直しやすさだけを上げる のがリファクタリングです。

ここが大事なポイントで、リファクタリングは本来 「振る舞いを変えない」ことが定義に含まれている んです。だから、リファクタしたつもりで振る舞いが変わってしまったら、それはもう「リファクタリングの失敗」なんですね。

では、なぜAIはこれを静かに壊してしまうのか。理由は大きく3つあると思っています。

理由1:そもそも「どこまで」が曖昧なまま渡してしまう
僕らは「この関数、読みやすくして」と頼みます。でもAIから見ると、「読みやすくする」の範囲はどこまでなのかが分からない。隣の関数も気になるし、変数名も気になるし、ついでに型も整えたくなる。だから、頼んでいない範囲まで手が伸びる。さっきの53.9%の正体は、ここにあります。

理由2:AIは「低レベルな編集」を好みがち
同じ調査では、AIエージェントは変数名の変更(Rename Variable)、引数名の変更(Rename Parameter)、型の変更(Change Variable Type)みたいな 細かい編集を、人間よりも高い割合でやる という結果が出ています。一個一個は小さい。小さいから見逃しやすい。でも、その「ついでのリネーム」が、どこか別の場所の参照とズレると、静かにバグが生まれる。

理由3:出てくるコードが"正しそうに見える"
これがいちばん怖いところで、AIが出すコードは、たいてい綺麗で、それっぽい。レビューしていても「うん、合ってそう」と流してしまう。でも"正しそう"と"正しい"は違う。見た目の説得力と、振る舞いの正しさは、別物 なんです。

じゃあどうするか。「AIを信じない」でも「全部自分で書く」でもなくて、「AIが何をしようと、振る舞いが変わったら必ず気づけるようにしておく」 という発想に切り替えるのが、いちばん現実的だと思うんです。それが次の「安全網」の話です。


発想の転換:直す前に「安全網」を張る

ここで主役の登場です。Characterization Test(キャラクタライゼーション・テスト)、日本語だと 「仕様化テスト」 と呼ばれるものです。聞き慣れない言葉だと思うので、ゆっくりいきますね。

普通のテストって、「こうあるべき」という 正しい仕様 を先に決めて、それを満たすか確認しますよね。期待値が先にある。

仕様化テストは、発想が逆です。「今このコードが、実際にどう動いているか」を、正しいかどうかは一旦置いといて、そのまま記録する テストなんです。バグも含めて、今の振る舞いを丸ごと写し取る。

たとえるなら、家を改装する前に、今の部屋の写真を全部撮っておく ようなものです。「この壁のシミも、この床のきしみも、ぜんぶ今こうなってる」と記録しておく。そうすれば、改装が終わったあとに写真と見比べて、「あれ、頼んでない壁まで動いてる」 に気づける。これが安全網(セーフティネット)の正体です。落ちてから慌てるんじゃなくて、落ちる前に網を張っておく。

普通のテストと仕様化テストの違いを表にすると、こんな感じです。

観点 普通のテスト(仕様ベース) 仕様化テスト(characterization)
何を基準にするか 「こうあるべき」正しい仕様 「今こう動いている」現状の振る舞い
いつ書くか 機能を作る前/作りながら 既存コードに手を入れる前
バグの扱い バグは直す対象 バグも一旦"今の仕様"として記録する
主な目的 正しさの保証 変化の検知(壊れたら気づく)
AI開発での役割 仕様の番人 AIのリファクタの安全網

ここで一つだけ、大事な注意。仕様化テストは「今の振る舞い」を固定するので、今あるバグまで"正しい"として固定してしまう ことがあります。だから「テストさえ書けば安全」ではない。固定した振る舞いが"守るべき仕様"なのか、"本当は直すべきバグ"なのかは、人間が判断する。 ここはAIに丸投げできない部分です。あとでもう一度触れます。


テストゼロから安全網を張る4ステップ

「言いたいことは分かった。でも、うちのコードほぼテスト無いんだけど」という声が聞こえてきそうです。大丈夫です。むしろテストが無いコードほど、この順番が効きます。テストゼロから始める4ステップを紹介します。

Step 0:理解する(AIに説明させて、自分の理解と突き合わせる)
いきなり直さない。まず、対象のコードが「今、何をしているのか」をAIに説明させます。そして、その説明を 自分の業務理解と突き合わせる。ここでAIの説明が自分の理解とズレていたら、それは黄信号。コードかAIの理解か、どちらかが間違っているサインなので、先に潰します。

  • 人間:業務的にこの関数が何を保証すべきかを知っている
  • AI:コードを読んで現状の振る舞いを言語化する

Step 1:固定する(仕様化テストを書いて"今"を写し取る)
次に、今の振る舞いを仕様化テストとして固定します。代表的な入力をいくつか用意して、「今の出力」をそのまま期待値にする。正しいかどうかはこの段階では問いません。まず"今"を凍結する のが目的です。テスト草案はAIに作らせてOK。ただし入力の網羅性(境界値・異常系が入っているか)は人間が見ます。

Step 2:変換する(小さく、1関数ずつ)
ここで初めてリファクタします。コツは 小さく刻むこと。「このファイル全部きれいにして」ではなく、「この1関数だけ、振る舞いを変えずに整理して」と頼む。範囲を狭めるほど、ついでの書き換え(あの53.9%)が混ざる余地が減ります。

Step 3:検証する(差分+テスト+人間判断の3点セット)
変換したら、3つを必ず確認します。

  1. テスト:Step 1の仕様化テストが全部グリーンか(=振る舞いが変わっていないか)
  2. 差分(diff):変更が「頼んだ範囲」に収まっているか。隣の関数まで触っていないか
  3. 人間判断:その変更が、本当に「整理」だけで「仕様変更」になっていないか

この3点セットを通って初めて、「壊していない」と言える。テストが緑なだけでは足りないし、差分を見ただけでも足りない。3つそろって、やっと安心 なんです。

順番をまとめると、理解 → 固定 → 変換 → 検証。直すのは3番目です。多くの事故は、いきなり3番目から始めてしまうことで起きる気がします。


そのまま使えるプロンプト3本

ここからは、明日からそのまま使えるプロンプトです。固有名詞や社内情報は入れず、汎用の形にしてあります。お使いの環境のコードに置き換えて使ってください。

プロンプト①:現状コードの振る舞いを棚卸しする(Step 0用)

あなたはリファクタリングの安全性を担保するレビュアーです。
これから渡す関数について、「現状の振る舞い」だけを、評価せずに棚卸ししてください。

# 出力フォーマット
1. この関数の入力(引数・前提となる状態)
2. この関数の出力・副作用(戻り値・例外・外部への書き込み等)
3. 入力ごとの振る舞い(正常系・境界値・異常系に分けて箇条書き)
4. 「仕様か、それともバグか判断がつかない」挙動のリスト

# 制約
- コードを改善する提案はしないでください(今回は現状把握だけが目的です)
- 想像で補わず、コードから読み取れる事実だけを書いてください
- 4番は特に丁寧に。後で人間が仕様かバグか判断します

# 対象コード
<ここに関数を貼る>

ポイントは 「改善提案はするな」と明示する こと。これを書かないと、AIは親切心で勝手にリファクタを始めてしまいます。今は"今を知る"フェーズだと釘を刺すのが大事。

プロンプト②:仕様化テストの草案を作らせる(Step 1用)

次の関数の「現状の振る舞い」を固定するための仕様化テスト(characterization test)を作ってください。
目的は、正しさの検証ではなく「今の振る舞いを記録し、後のリファクタで変化を検知すること」です。

# 要件
- 正常系・境界値・異常系を最低1つずつ含める
- 期待値は「現状コードが返すであろう実際の値」にする(理想の仕様ではなく現状)
- テストフレームワークは {pytest / Jest など} を使う
- 各テストに「何の振る舞いを固定しているか」を1行コメントで添える

# 注意
- このテストには現状のバグも含まれる可能性があります。その疑いがあるケースには
  「# 要確認: バグの可能性」とコメントを付けてください

# 対象コード
<ここに関数を貼る>

プロンプト③:振る舞いを変えずにリファクタし、差分を説明させる(Step 2-3用)

次の関数を、外から見た振る舞いを一切変えずにリファクタリングしてください。

# 厳守ルール
- 入力に対する出力・例外・副作用を変えないこと(仕様変更は禁止)
- 変更範囲はこの関数の内部だけに限定すること(他の関数・他ファイルは触らない)
- 変数名の変更や型の変更を行う場合は、その理由を1つずつ列挙すること

# 出力
1. リファクタ後のコード
2. 変更点の一覧(何を・なぜ変えたか)
3. 「振る舞いが変わっていないと考える根拠」
4. 既存の仕様化テストが全て通るかの自己チェック結果

# 対象コード / 既存テスト
<ここにコードとテストを貼る>

①②③をこの順で回すと、「理解→固定→変換」の流れがそのままプロンプトの流れになる ので、頭の中とAIの作業が揃って、ブレが減ります。


コード例:安全網を実装する

抽象論で終わらせたくないので、実際の形を2つ出します。題材は、どこにでもありそうな カートの合計金額を計算する関数 にします(汎用ダミーです)。

例1:仕様化テスト(pytest)で"今"を固定する

まず、リファクタしたい既存コード。ちょっと読みにくい、よくあるやつです。

# cart.py(リファクタ前の既存コード)
def calc_total(items, coupon=None):
    t = 0
    for i in items:
        t += i["price"] * i["qty"]
    if t > 10000:
        t = t - t * 0.05  # 1万円超で5%オフ
    if coupon == "WELCOME":
        t = t - 500
    if t < 0:
        t = 0
    return round(t)

このコードに手を入れる前に、今の振る舞いを丸ごと写し取る 仕様化テストを書きます。

# test_cart_characterization.py
import pytest
from cart import calc_total

# 「今こう動いている」を記録するテスト。正しさではなく現状を固定する。
@pytest.mark.parametrize("items, coupon, expected", [
    # 正常系:割引なしの素直な合計
    ([{"price": 500, "qty": 2}], None, 1000),
    # 境界値:ちょうど10000円("超"えていないので割引は入らない、が現状仕様)
    ([{"price": 10000, "qty": 1}], None, 10000),
    # 境界値の外側:10001円で5%オフが効く現状の振る舞い
    ([{"price": 10001, "qty": 1}], None, 9501),
    # クーポン併用:5%オフ後にさらに500円引き
    ([{"price": 20000, "qty": 1}], "WELCOME", 18500),
    # 異常系:マイナスにならず0で止まる現状の振る舞い
    ([{"price": 100, "qty": 1}], "WELCOME", 0),
])
def test_calc_total_current_behavior(items, coupon, expected):
    assert calc_total(items, coupon) == expected

ここで注目してほしいのが、2番目のケース。「ちょうど10000円のときは割引が入らない」というのは、仕様としてわざとそうなのか、それとも >>= にし忘れたバグなのか、コードだけ見ても分からない んです。だからコメントで「現状仕様」と明記して、判断は人間に残す。仕様化テストは答えを出す道具ではなく、論点を可視化する道具なんですね。

例2:差分ガードで「変更範囲」を閉じ込める

仕様化テストが緑でも、まだ油断は禁物です。頼んだ関数以外に変更が漏れていないか を確認する、差分のガードを入れます。これはCIにも組み込めます。

#!/usr/bin/env bash
# guard_diff.sh : 変更が許可した範囲(cart.py だけ)に収まっているか確認する
set -euo pipefail

ALLOWED="cart.py"

# 今回の変更で触れたファイル一覧を取得
changed=$(git diff --name-only HEAD)

# 許可リスト以外のファイルが変更されていたら止める
unexpected=$(echo "$changed" | grep -v -x "$ALLOWED" || true)

if [ -n "$unexpected" ]; then
  echo "想定外のファイルが変更されています(リファクタ範囲の逸脱の疑い):"
  echo "$unexpected"
  exit 1
fi

echo "変更範囲OK: $ALLOWED の中だけに収まっています"

やっていることは単純で、「cart.py 以外が変わっていたら赤信号で止める」だけ。でも、これがあの 53.9%の"ついでの書き換え"を機械的に弾く 一次フィルタになります。人間がdiffを目で追う前に、機械が範囲逸脱を検知してくれる。安全網は、人の注意力だけに頼らない仕組みにしておくほど強いです。

この2つを組み合わせると、検証は 「仕様化テストが緑」かつ「差分が範囲内」かつ「人間が仕様変更でないと確認」 の3点セットになります。テストだけ、diffだけ、では片手落ち。3つでようやく一枚の網になる、というイメージです。


よくある落とし穴と、人間/AIの役割分担

最後に、実際にやってみると踏みやすい落とし穴を、先回りして共有しておきます。

落とし穴1:バグを"正しい仕様"として固定してしまう
仕様化テストは現状を写すので、バグも固定されます。固定したあと、「これは守る仕様か/直すバグか」を人間が仕分ける工程を必ず挟んでください。仕分けないと、バグが未来永劫テストで守られる、という悲しいことになります。

落とし穴2:網が薄いまま安心してしまう
入力が正常系だけだと、境界値や異常系のズレを見逃します。「テストが緑=安全」ではなく、「緑だけど、そもそも網に穴が空いてないか」を疑う癖を。

落とし穴3:AIの説明を鵜呑みにする
AIの「これは○○する関数です」は、それっぽいだけで間違っていることがあります。Step 0で必ず自分の業務理解と突き合わせる。合わなければ、そこが調査ポイント。

落とし穴4:一括で大きく変換させる
「全部きれいにして」は事故のもと。範囲が広いほど、ついでの書き換えが混ざります。1関数ずつ、小さく。

落とし穴5:AIにテスト自体を甘く書かせてしまう
「テスト通った」と言わせたいあまり、AIが当たり障りのないテストだけ書くことがあります。境界値・異常系が入っているかは人間がチェック。テストの質は、安全網の強度そのものです。

落とし穴6:撤退基準を決めていない
「2回直してもテストが安定しない」「差分が範囲を越え続ける」なら、いったんAIから手を引いて、自分で小さく書く。引き返す基準を先に決めておく と、沼にハマりません。

これらを踏まえて、「誰が何を担当するか」を一枚にまとめると、こうなります。

フェーズ 人間が設計・判断する AIに任せる
理解(Step 0) この関数が業務的に何を保証すべきか 現状コードの振る舞いを言語化する
固定(Step 1) どの入力を網羅すべきか/境界値・異常系の指定 仕様化テストの草案を書く
仕分け 固定した挙動が「仕様」か「バグ」かの判断 「バグの疑い」がある箇所を指摘する
変換(Step 2) 変更範囲(Non-goals)をどこまでに絞るか 振る舞いを変えない範囲で内部を整理する
検証(Step 3) 仕様変更になっていないかの最終判断 差分の説明・自己チェック結果の提示
撤退 引き返す基準の設定と発動 (判断はしない)

並べてみると分かるのですが、AIに任せているのは「手を動かす作業」で、"何を守るか・どこで止めるか"という判断は、ぜんぶ人間が握っている。ここが崩れると、速いけど壊れる開発に逆戻りします。逆にここさえ握っておけば、AIにどんどん手を動かしてもらっても怖くない。役割分担って、けっきょく「判断は手放さない」の一言なのかもしれません。


おわりに:安全網は、明日の自分へのプレゼント

ここまで読んでくださって、ありがとうございます。

AIに直させると壊れる、という話から始めましたが、たどり着いた結論はすごくシンプルでした。直す前に、今の振る舞いを写し取る網を張っておく。 それだけで、AIがどれだけ手を動かしても、壊れたら必ず気づけるようになる。

僕、いつも自分に一つの問いを置いているんです。「明日の自分が、今日の自分にあざっすって言ってくれるかな?」って。今日、面倒くさがって安全網を張らずにAIに丸投げしたコードは、たぶん明日の自分が泣きながらデバッグすることになる。でも、今日ちょっと手間をかけて仕様化テストを1本書いておいたら、明日の自分は安心して、そのコードに手を入れられる。安全網は、未来の自分へのプレゼント なんですよね。これは自分を責める話じゃなくて、明日の自分を思いやる話です。

それに、これは「速さ」を捨てる話でもないと思っています。長く使われるアプリやSaaSって、結局、何年も何回も改修され続けるものです。そのとき 「壊さずに変えられる」こと自体が、プロダクトの資産価値 になる。きれいに整って、安心して触れるコードは、それだけで価値が積み上がっていく。速さと安心は、トレードオフじゃなくて、安全網を挟むことで両立できる気がするんです。

最初から全部やろうとしなくて大丈夫です。まずは1関数だけ。 いちばん怖い、いちばん触りたくない関数に、仕様化テストを1本だけ張ってみる。それだけで、明日のあなたは「あざっす」と言ってくれると思います。

小さく、でも確実に。今日もおつかれさまでした。

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?