🌱

テストがないコードへのテストの育て方

に公開2

はじめに

ユニットテストが浸透してきています。それでも、テストがないプロダクトのコードをメンテナンスするケースもあります。そうすると絶望的な気持ちになることもあると思います。

今回、そのようなコードにテストを追加するための道筋を示したいと思います。

場合によっては年単位の長い時間が掛かる可能性がありますが、ステップ毎にだんだん不安が減っていくようにしてあります。最後のステップではリファクタリング済みのコードを示しています。

仕様が不明なコードに絶望する必要がないと感じられたら嬉しいです。

サンプルコード

本来、ここで想定しているのは次のようなコードです。

  • 500ステップを超える関数、しかも同様の長さの関数をいくつか呼び出している
  • APIやファイルアクセスも含むことがあります
  • ネストが多く、変数の関係性も複雑です
  • 引数も型や用途が不明だったりします

基本的にはこれらの場合であっても有効な方法と考えています。
今回はAPI呼出に関しては説明しません。環境や状況によって対応が違うからです。一般的にはモックを使いますのでそちらを使うとヒントになるかもしれません。

ただ、本当にそんなコードで説明することにそれほど意味は無いので、簡単なコードで解説します。下記にユーザーの名前を変更する updateUserName 関数を定義しました。

async function updateUserName(id, newName) {
  const user = await db.users.findById(id);
  if (!user) {
    throw new Error('user not found');
  }

  if (newName) {
    newName = newName.trim();
  }
  if (!newName) {
    newName = '不明';
  }
  user.name = newName;
  return await db.users.save(user);
}

ステップ1:テストケースの収集

最初のステップは、実際にどのような呼出がなされているかの調査です。

デバッグプリントを関数の入出力の前後に入れていきます。

  • 対象関数の最初に引数を表示する
  • 対象関数のreturnの手前
  • 関数呼出の前後
  • DBやファイルへのアクセスの前後
  • その他、必要と思われる変数など

基本的にはローカルや検証環境に仕込んで、出来る限りいろいろなパターンでこの関数を呼び出します。デバッグプリントは既存ロジックに影響を出さないように気を配るべきですが、一時的なコードなので変更しても問題ありません。

運用がわからないなどのケースでは、本番にログ出力を仕込むことになります。この場合、ログの件数などを考慮する必要があります。ロジックへの影響はローカルへ仕組む場合とくらべてかなり慎重に行なうようにする必要があります。

デバッグプリントを追加したコードが下記になります。

async function updateUserName(id, newName) {
  console.log('--- INPUT ---');
  console.log('id:', id);
  console.log('newName:', newName);

  const user = await db.users.findById(id);
  if (!user) {
    console.log('--- ERROR ---');
    console.log('user not found');
    throw new Error('user not found');
  }
  console.log('--- BEFORE ---');
  console.log('user:', JSON.stringify(user));

  if (newName) {
    console.log('--- TRIM ---');
    newName = newName.trim();
  }
  if (!newName) {
    console.log('--- SET DEFAULT VALUE ---');
    newName = '不明';
  }
  user.name = newName;
  await db.users.save(user);

  console.log('--- AFTER ---');
  console.log('user:', JSON.stringify(user));

  return user;
}

出力形式はテスト化しやすいように、色々工夫したほうがよいと思います。例えば、DBの項目などは全部出力しなくてもあとで手動でレコードを取り出すなどです。親テーブルの取得などが必要だったり、テストデータとして不要な項目があったりとするからです。

コーディングエージェントに例を与えて書いてもらうと楽かもしれません。(このコードもClaude Codeに書いてもらいました。)

元のコードでは return に関数が書いてありましたが一旦変数に代入しています。このように既存コードに手を加えることがあります。

収集したログは例えば次のようなものになります。

--- INPUT ---
id: 1
newName: 佐藤
--- BEFORE ---
user: {"id":1,"name":"田中"}
--- AFTER ---
user: {"id":1,"name":"佐藤"}

ステップ2:テストケースの作成と実行

ステップ1で収集したデータからテストケースを作成します。

ここでは、ひとかたまりのテストとして定義して良いです。この時点で名前付きのきれいなテストケースが書けるようなコードは、この記事の対象ではありません。

テストを書いて、実行した結果ができる限り収集した結果と一致するようにしてください。一致しなかった場合は、なにか設定を間違っているか取得してない情報があるかもしれないので、デバッグプリントを追加して、調査してください。

テストで評価する項目は、不必要なものを含んでいて問題ありません。迷ったら検査しましょう。

そのようにして、作成したテストが下記のようなものになります。

test('case1', async () => {
  // テーブルをクリア
  await db.users.clear();

  // 一気にデータをセット
  await db.users.insert({ id: 1, name: '田中' });
  await db.users.insert({ id: 2, name: '山田' });
  await db.users.insert({ id: 3, name: '鈴木' });
  await db.users.insert({ id: 4, name: '伊藤' });
  await db.users.insert({ id: 5, name: '渡辺' });

  // 収集した操作を再現
  await updateUserName(1, '佐藤');
  await updateUserName(2, '鈴木');
  await updateUserName(3, null);  // 名前なし → '不明'になる
  await updateUserName(4, '  木村  ');  // 前後に空白 → トリムされる
  await updateUserName(5, '');  // 空文字 → '不明'になる
  await updateUserName(1, '高橋'); // id:1を再度更新(重複)

  // 結果を一気に検証
  const user1 = await db.users.findById(1);
  const user2 = await db.users.findById(2);
  const user3 = await db.users.findById(3);
  const user4 = await db.users.findById(4);
  const user5 = await db.users.findById(5);

  expect(user1.name).toBe('高橋');
  expect(user2.name).toBe('鈴木');
  expect(user3.name).toBe('不明');
  expect(user4.name).toBe('木村');
  expect(user5.name).toBe('不明');
});

かなり雑なテストで、IDで検索に失敗したときのケースがもれています。しかし、これまでテストがなかった状態からするとかなりの安心感があります。

テスト用のデータを一気にセットする方法は共有フィクスチャ(Shared Fixture)と呼ばれるパターンです。管理が難しくなるのでおすすめしませんが、この段階ではそれでも構いません。とにかくテストケースを増やしましょう。

また、テストケースを1つにまとめている理由は、内容として重複するケースがあるのを許容しているためです。

ステップ3:テストケースを充実させる

手順としては、ステップ1でやったようにデバッグプリントを仕込みます。大事なのは狙ったパスを通るデータを探すことです。

カバレッジが40%を超えたあたりから、意図しない影響を検知出来るようになります。

例えば、次のようなログが見つかったとします。

--- INPUT ---
id: 999
newName: 存在しない
--- ERROR ---
user not found

サンプルコードではIDが見つからないケースについてのコードが足されました。

test('case1', async () => {
  // テーブルをクリア
  await db.users.clear();

  await db.users.insert({ id: 1, name: '田中' });
  await db.users.insert({ id: 2, name: '山田' });
  await db.users.insert({ id: 3, name: '鈴木' });
  await db.users.insert({ id: 4, name: '伊藤' });
  await db.users.insert({ id: 5, name: '渡辺' });

  await updateUserName(1, '佐藤');
  await updateUserName(2, '鈴木');
  await updateUserName(3, null);
  await updateUserName(4, '  木村  ');
  await updateUserName(5, '');
  await updateUserName(1, '高橋');

  // IDが見つからないケースを追加
  await expect(updateUserName(999, '存在しない')).rejects.toThrow('user not found');

  const user1 = await db.users.findById(1);
  const user2 = await db.users.findById(2);
  const user3 = await db.users.findById(3);
  const user4 = await db.users.findById(4);
  const user5 = await db.users.findById(5);

  expect(user1.name).toBe('高橋');
  expect(user2.name).toBe('鈴木');
  expect(user3.name).toBe('不明');
  expect(user4.name).toBe('木村');
  expect(user5.name).toBe('不明');
});

ステップ4:テストのリファクタリング

テストケースが充実して60%を超えると、テストの信頼性が増して、これまでのようにリリース後にビクビクしないで済むようになってきます。60%はよくテストの最低ラインとして示される数値です。

同時に、テストケースが肥大化してテストのメンテナンスが難しくなります。リファクタリングで理想的なテストに近づけます。

このステップは、下記のようなリファクタリングを行います。効果が高そうな順にやれば良いと思います。

  • テストデータに名前を付けて独立させる
  • ファクトリを用いて、標準的なテストデータを定義
  • チェック項目を整理、不要なチェックを削除

サンプルコードを見てください。通常のユニットテストになっているのが見て取れます。ただし実際は巨大な関数のため、かなりの数のテストケースになっていると予想されます。

// 標準的なユーザーを作成するファクトリ
async function createUser(overrides = {}) {
  const user = {
    id: 1,
    name: '田中',
    ...overrides,
  };
  await db.users.insert(user);
  return user;
}

// 各テストの前にテーブルをクリアする
// これによりテストケース間でデータが干渉しなくなる
beforeEach(async () => {
  await db.users.clear();
});

test('名前を更新できる', async () => {
  await createUser();

  await updateUserName(1, '佐藤');

  const user = await db.users.findById(1);
  expect(user.name).toBe('佐藤');
});

test('名前がnullの場合は不明になる', async () => {
  await createUser();

  await updateUserName(1, null);

  const user = await db.users.findById(1);
  expect(user.name).toBe('不明');
});

test('名前が空文字の場合は不明になる', async () => {
  await createUser();

  await updateUserName(1, '');

  const user = await db.users.findById(1);
  expect(user.name).toBe('不明');
});

test('名前の前後の空白はトリムされる', async () => {
  await createUser();

  await updateUserName(1, '  佐藤  ');

  const user = await db.users.findById(1);
  expect(user.name).toBe('佐藤');
});

test('IDが存在しない場合はエラー', async () => {
  await expect(updateUserName(999, '存在しない')).rejects.toThrow('user not found');
});

// 削除: id:1を2回更新するケース(重複テスト)
// 削除: id:2の更新ケース(名前を更新できるテストと重複)

テストケースの分割は一気にはできないかもしれません。まずは2つに分割するとか、メンテついでに1つずつケースを追加するなどの工夫を検討すると良いかもしれません。

ステップ5:関数のリファクタリング

テストが充分に育つと関数の方のリファクタリングが自在に出来るようになります。

大きな関数を想定していますので、テストの方をリファクタリングしても限界があります。関数の抽出を行い、それぞれの関数に対してテストを書きましょう。元の関数でのテストケースを減らすことができます。

テストケースをどこに書くかは議論があると思いますが、メンテナンスが容易で充実したテストを考えると対象のロジックがある関数を直接呼び出す方が断然良いと考えています。

サンプルコードではわかりづらいですが、実際の関数ではレイヤーに分けたりしてファイルが分割されるのでテストの見通しは格段に良くなります。

// 関数を抽出してそれぞれ単一の責務にする

// IDでユーザーを取得する
async function getUserById(id) {
  const user = await db.users.findById(id);
  if (!user) {
    throw new Error('user not found');
  }
  return user;
}

// 名前を正規化する(トリム、空値→不明)
function resolveUserName(newName) {
  if (newName) {
    newName = newName.trim();
  }
  if (!newName) {
    return '不明';
  }
  return newName;
}

// ユーザーを保存する
async function saveUser(user) {
  await db.users.save(user);
}

// ユーザー名を更新する
async function updateUserName(id, newName) {
  const user = await getUserById(id);
  user.name = resolveUserName(newName);
  await saveUser(user);
  return user;
}

メインの updateUserName テストケースは2件になりました。細かいテストを他のメソッドのテストに移行したからです。

(テストをドキュメントとして使う場合は、あえて名前の正規化のテストやIDが存在しないテストを重複して書く場合もあります。)

describe('updateUserName', () => {
  test('名前を更新できる', async () => {
    await createUser();

    await updateUserName(1, '佐藤');

    const user = await db.users.findById(1);
    expect(user.name).toBe('佐藤');
  });

  test('名前が正規化される', async () => {
    await createUser();

    await updateUserName(1, null);

    const user = await db.users.findById(1);
    expect(user.name).toBe('不明');
  });

  // 削除: 'IDが存在しない場合はエラー'
  // → getUserByIdのテストでカバーされるため

  // 削除: '名前が空文字の場合は不明になる'
  // 削除: '名前の前後の空白はトリムされる'
  // → resolveUserNameのテストでカバーされるため
});

getUserByIdのテストも記述が簡単になり、テストの意図がわかりやすくなります。

// ====== 抽出したgetUserByIdのテスト ======
describe('getUserById', () => {
  test('ユーザーを取得できる', async () => {
    await createUser();
    const user = await getUserById(1);
    expect(user.name).toBe('田中');
  });

  test('存在しない場合はエラー', async () => {
    await expect(getUserById(999)).rejects.toThrow('user not found');
  });
});

saveUserはgetUserByIdと対をなすメソッドです。実際ほとんど同じテストですが、仕様が拡張されたときのために分割されている意味はあります。

// ====== 抽出したsaveUserのテスト ======
describe('saveUser', () => {
  test('ユーザーを保存できる', async () => {
    await createUser();
    const user = await db.users.findById(1);
    user.name = '佐藤';
    await saveUser(user);

    const saved = await db.users.findById(1);
    expect(saved.name).toBe('佐藤');
  });
});

resolveUserNameに関しては、テストの簡単さが際立っています。
この手の副作用がない関数は特に、テストケースを配列で定義するなどができます。

// ====== 抽出したresolveUserNameのテスト ======
describe('resolveUserName', () => {
  test('名前があればそのまま返す', () => {
    expect(resolveUserName('佐藤')).toBe('佐藤');
  });

  test('前後の空白はトリムされる', () => {
    expect(resolveUserName('  佐藤  ')).toBe('佐藤');
  });

  test('nullの場合は不明を返す', () => {
    expect(resolveUserName(null)).toBe('不明');
  });

  test('空文字の場合は不明を返す', () => {
    expect(resolveUserName('')).toBe('不明');
  });

  test('空白のみの場合は不明を返す', () => {
    expect(resolveUserName('   ')).toBe('不明');
  });
});

ここまでリファクタリングが進むと元の状態など誰も想像できないと思います。

おわりに

この方法は私が新卒でCOBOLを書いていたときに、私がやっていたテスト技法を現代風にブラッシュアップしたものになります。

実際はここに書かれたものより遥かに泥臭いことがたくさんあるはずですが、原理的には多くの場合に適用が可能です。

このやり方は、特にバッチ処理と相性が良いやり方だと考えています。入力と前後状態の取得が比較的に簡単で大量に取得できます。

Discussion

xiaosenxiaosen

これは割役独立ということですか?

いわもとたかあきいわもとたかあき

想定しているのは大きなメソッドなので、メソッドの分割は必須です。とはいえ、今回の記事はそうじゃなくてテストを追加するところまでです。

ステップ5でメソッドの抽出を行っていますが、これはテストを追加した先にどんなことが出来るようになるかの例で目的はこれでは無いと考えていただければありがたいです。