新卒1年目で初めて出会ったテスト駆動開発

こんにちは!2024年4月に新卒で入社し、ギリギリまだ社会人1年目のにっしーです。
普段はアドプラットフォーム事業でアプリケーションエンジニアとして開発運用業務を行っています。

研修後に私が配属されたモダナイズチームでは、弊社が運用しているアフィリエイトサービスのトラッキングシステムを刷新するプロジェクトに取り組んでいます。
本プロジェクトに携わった社員による記事が公開されていますので、ぜひ読んでみてください。

blog.engineer.adways.net

この1年さまざまなことを学び実践してきた中で、今回はテスト駆動開発(TDD: test-driven development)についてお話ししようと思います!

What's TDD?

TDDは、Extreme Programming(XP)というアジャイル開発に属するフレームワークを構成するプラクティスの1つです。
XPは、私がチームに配属される前に実施されたTanzuLabs様との協働から取り入れられた手法です。

XPの各プラクティスについては以下のスライドで紹介されています。

Extreme Programming (XP) 概要

このスライドでは、TDDは次のように説明されています。

テストを先に記述し、そのテストにパスする少量のコードを書くことを繰り返しながら開発する。シンプルでわかりやすい設計が可能となる。テストは自動化が推奨

必要な機能を少しずつ実装していくと、ユニットテストが完備されていて、後から見てもわかりやすい設計で、目的のものが完成するといった手法です。

TDDとの出会い

7月中旬、チームに配属されてすぐに、チームでの開発の流れを教わる一環としてTDDワークショップを実施していただきました。
入社するまでソフトウェア開発でテストを書いたことがなく、ここで初めてTDDを知ることになります。
昨年の夏をなんとか思い出しながら、どんなことをしたのかご紹介します。

ワークショップではまず、簡単な座学でTDDでの開発の流れを教わりました。

  • TDDでは、「RED -> GREEN -> REFACTOR」のサイクルを繰り返す
  • REDとは、テストを書いて想定通りの失敗をすることを確認することである
  • GREENとは、テストが通る最小限の実装をすることである
  • REFACTORとは、挙動を変えることなくコードを改善することである

座学の後、「じゃんけんの勝敗を判定する関数」をお題に、実際にTDDを体験してみることになりました。
オンラインホワイトボード上での体験なのでプログラム実行はできませんが、TDDの流れを把握が目的です。(あくまで意図が伝わればOKなので、ワークショップではTypeScriptで書きました)

お題
じゃんけんの勝敗を判定する関数を実装する。 関数は2つの引数を受け取り、勝敗を判定し、結果を返す。

  • 1つ目の引数はプレイヤー1の出した手で、Rock(グー)、Scissor(チョキ)、Paper(パー)のいずれか
  • 2つ目の引数はプレイヤー2の出した手で、Rock(グー)、Scissor(チョキ)、Paper(パー)のいずれか
  • 返り値は勝敗の判定結果で、Player1、Player2、Draw(あいこ)のいずれか

ワークショップでは、用意されたテストを順番に1つずつ満たすように少しずつ実装していきます。

まず、1つ目のテスト「プレイヤー1がグー、プレイヤー2がチョキを出したとき、プレイヤー1の勝ちと判定する」を満たすよう実装します。
テストが通る最小限の実装という部分が強調されていたことを思い出し、次のように書きました。

export function play(p1: Throw, p2: Throw): Result {
  return Result.P1Win
}

まだテストは1つしかないので、どんな場合でもPlayer1が勝つと判定すれば、テストが通ります。

つぎに、2つ目のテスト「プレイヤー1がチョキ、プレイヤー2がグーを出したとき、プレイヤー2の勝ちと判定する」を満たすように実装します。
ここで初めて、条件分岐の実装が必要になります。

export function play(p1: Throw, p2: Throw): Result {
  if (p1 === Throw.Rock && p2 === Throw.Scissors) return Result.P1Win

  return Result.P2Win
}

これで2つのテストが両方通る実装になりました。

3つ目のテストは「プレイヤー1とプレイヤー2が同じ手を出したとき、あいこと判定する」でした。

export function play(p1: Throw, p2: Throw): Result {
  if (p1 === p2) return Result.Draw

  if (p1 === Throw.Rock && p2 === Throw.Scissors) return Result.P1Win

  return Result.P2Win
}

これであいこの判定もできるようになりました。

ここで時間切れとなり最後まで実装できませんでしたが、「テストが通る最小限の実装をする」というGREENの考え方がどういうものか学びました。

REDとREFACTORについては実際の業務で実践しながら学ぶことになります。

実践

ワークショップで概念を学んだら、次は業務での実践です。
配属後すぐは業務に慣れるため運用業務が主でしたが、8月からはGo言語を扱うメインプロジェクトの開発業務に関わり始めました。

チームではXPのプラクティスの1つであるペアプログラミングを採用しており、TDDの進め方もそれに合わせたものとなっています。
開発は基本的に以下の流れで行います。

  1. 作成する機能の枠を作る
  2. テストしたい内容を列挙する
  3. 列挙したテストのうち1つを書く
  4. 書いたテストを満たす最小限の実装をする
  5. テストと実装のリファクタリングを行う
  6. 3.〜5.を繰り返す

それぞれについて説明しながら、例として絶対値を求める関数を作ってみます。

作成する機能の枠を作る

枠というのは、フィールドのない構造体や何もしない関数のことです。
テストコードから呼び出す対象がないとテストが書けないため、まずはテスト対象となる関数を定義します。
ただし、TDDではテストを先に書くことが原則ですから、中身の実装は何もしません。

// 枠だけ作る
type MyMath struct{}

func (m MyMath) Abs(x int) int {
  return 0
}

テストしたい内容を列挙する

コード内のコメントとして、日本語でテストしたい内容を列挙します。
例えば「SQSからメッセージを受け取る」「hogeの場合はfugaテーブルにデータを保存する」「fooの場合はbarテーブルにデータを保存する」「ログを出力する」等、作成する機能に必要な動作を日本語で書いていきます。
「データの保存に失敗したら、ログを出力してエラーを返す」といった異常系も必要に応じて書いておきます。

// テスト候補リスト
// - 引数が正の数のとき、そのまま返す
// - 引数が負の数のとき、-1をかけて返す

列挙したテストのうち1つを書く

ペアのうちの1人が、列挙したテストのうち簡単そうなものを1つ選んでテストを書きます。
テストが書けたら、実行します。
この時点では、まだ実装されていない内容のテストなので当然ですが、書いたテストは失敗します。
次にテストの実行結果を見て、想定通りの失敗をしているかを確認します。
想定通りなら良いですが、想定外の失敗をしている場合は、テストの書き方が間違っていたり、そもそもテストすべき内容が間違っている可能性があるため、修正します。

これが「テストを書いて想定通りの失敗をすることを確認する」REDの工程です。

type myMathTestSuite struct {
  suite.Suite
  myMath MyMath
}

func TestMyMathTestSuite(t *testing.T) {
  suite.Run(t, new(myMathTestSuite))
}

func (s *myMathTestSuite) TestMyMath() {
  s.Run("引数が正の数のとき、そのまま返す", func() {
    expected := 1
    actual := s.myMath.Abs(expected)
    s.Equal(expected, actual) // まだ実装していないので actual=0 のはず
  })
}
# 実行結果
=== RUN   TestMyMathTestSuite/TestMyMath/引数が正の数のとき、そのまま返す
          Error:  Not equal:
                  expected: 1
                  actual  : 0

書いたテストを満たす最小限の実装をする

コーディングする人を交代し、テストを書いていない方の人が、さきほど書いたテストを満たすように実装します。
このとき、最小限の実装で十分です。今までに書いたテストが通れば良いのです。
この後に実装予定の動作や機能が頭にちらついても、無視して今満たしたいテストだけを考えます。
実装ができたらテストを実行し、成功することを確認します。

これが「テストが通る最小限の実装をする」GREENの工程です。

func (m MyMath) Abs(x int) int {
  return x  // 負の場合のことはまだ考えない
}
# 実行結果
=== RUN   TestMyMathTestSuite/TestMyMath/引数が正の数のとき、そのまま返す
--- PASS: TestMyMathTestSuite/TestMyMath/引数が正の数のとき、そのまま返す

テストと実装のリファクタリングを行う

2人でリファクタリングを行います。
これまでの実装やテストで共通化できる部分を共通化したり、より良い実装方法があればそちらに書き換えたりします。
コードを編集したらこまめにテストを実行して成功することを確認し、動作に影響がないように気をつけながら行います。

これが「挙動を変えることなくコードを改善する」REFACTORの工程です。

// 十分シンプルなのでこのまま
func (m MyMath) Abs(x int) int {
  return x
}

3.〜5.を繰り返す

RED, GREEN, REFACTORを繰り返します。
この途中に追加で書きたいテストを思いついたら、コメントアウトに列挙したところへ追記しておきます。

列挙したテスト候補を全て消化したとき、目的の作りたかった機能が完成しているはずです。
しかも、ユニットテストが完備された状態で。最高ですね!

2周目 RED

func (s *myMathTestSuite) TestMyMath() {
  s.Run("引数が正の数のとき、そのまま返す", func() {
    argument := 1
    expected := 1
    actual := s.myMath.Abs(argument)
    s.Equal(expected, actual)
  })

  // テスト追加
  s.Run("引数が負の数のとき、-1をかけて返す", func() {
    argument := -2
    expected := 2
    actual := s.myMath.Abs(argument)
    s.Equal(expected, actual) // 負の場合を考慮していないので actual=-2 のはず
  })
}
# 実行結果
=== RUN   TestMyMathTestSuite/TestMyMath/引数が正の数のとき、そのまま返す
--- PASS: TestMyMathTestSuite/TestMyMath/引数が正の数のとき、そのまま返す
=== RUN   TestMyMathTestSuite/TestMyMath/引数が負の数のとき、-1をかけて返す
          Error:  Not equal:
                  expected: 2
                  actual  : -2

2周目 GREEN

func (m MyMath) Abs(x int) int {
  if x < 0 {  // 負の場合の条件を追加
    x = x * -1
  }
  return x
}
# 実行結果
=== RUN   TestMyMathTestSuite/TestMyMath/引数が正の数のとき、そのまま返す
--- PASS: TestMyMathTestSuite/TestMyMath/引数が正の数のとき、そのまま返す
=== RUN   TestMyMathTestSuite/TestMyMath/引数が負の数のとき、-1をかけて返す
--- PASS: TestMyMathTestSuite/TestMyMath/引数が負の数のとき、-1をかけて返す

2周目 REFACTOR

func (m MyMath) Abs(x int) int {
  if x < 0 {
    return -x // シンプルにした
  }
  return x
}
# 実行結果
=== RUN   TestMyMathTestSuite/TestMyMath/引数が正の数のとき、そのまま返す
--- PASS: TestMyMathTestSuite/TestMyMath/引数が正の数のとき、そのまま返す
=== RUN   TestMyMathTestSuite/TestMyMath/引数が負の数のとき、-1をかけて返す
--- PASS: TestMyMathTestSuite/TestMyMath/引数が負の数のとき、-1をかけて返す

実践してみて気づいちゃった良いところ・悪いところ

TDDを実施してみると、良いところと悪いところが見えてきます。
私が業務で実際に感じたことをいくつか紹介します。

安心して実装やリファクタリングできる

テストがあると、テストが成功するように実装すればいいだけなので「テスト(仕様)を満たすために正しく実装する」という目的が達成しやすくなります。
仮に間違えたことをしても軌道修正もしやすいため、安心して実装できちゃいます

リファクタリングの際にも、テストが通ることを確認しながら行えば挙動が変わらない保証があるので、自信を持ってコードを書き換えられます。

綺麗でシンプルなコードになる

TDDのサイクルにはリファクタリングが含まれています。
そのため、綺麗でシンプルなコードを書けちゃいます

ただし、GREENの工程ではコードが綺麗である必要がなく、実際テストが通れば良いという考え方で実装するため汚いコードになりがちです。
綺麗でシンプルなコードを保つためには、その後のREFACTORは必須の工程です。

仕様が明確になる

TDDでは実装の前に必ずテストを書くので、テストを見ればどんな実装がなされているかわかります。
つまり、コードリーディングをしなくてもテストを見れば仕様がわかる状態になります。

「テストのための実装」が生まれてしまうことがある

テストは基本的にローカル環境で実行されます。
一方、完成したプログラムが実際に動くのはオンプレやクラウド上のサーバーです。

テストを書く際には、テストダブルを使い依存性注入を行うなどの方法で、この環境の差が影響することがないようにします。
しかし、ごく稀に簡単な方法では環境の差による影響を解消できない場合があります。

このような場合には、実装に「テストの場合はこうする」といった条件分岐を書かなければなりません。
この分岐は本来必要のないものです。

私がチームに配属されてから一度だけこのようなケースに直面したことがありました。
本番ではクラウドが勝手に設定してくれる項目を、テストではコード上で設定しないとエラーになってしまうというケースだったのですが、このときは泣く泣く「テストのための実装」を書きました。

短期的な開発速度が落ちる・長期的な開発速度が上がる

TDDではテストコードを書かなければいけない分、追加で工数がかかります。
しかし、品質担保がなされるためデグレが発生しにくく、仕様が明確であることも加えて、保守コストが下がります。

つまり、コーディングに必要な時間が増え短期的な開発速度は落ちますが、保守コストが下がるので長期的な開発速度はどんどん上がります

最後に

本稿では、入社して初めてTDDに出会った私の目線で、弊社でのTDDの取り組みについてご紹介いたしました。
テストもGo言語もクリーンアーキテクチャも経験がなかった私ですが、入社後の研修に加え、チーム配属後も先輩方を目標に多くのことを学びながら、業務に取り組んでいます。

TDDの概念をより理解し生産性を上げるため、9月から12月にかけてモダナイズチームでテスト駆動開発の読書会を実施しました。
読書会で得た学びは後日エンジニアブログで別エントリとして公開される予定です。お楽しみに!