Go言語で覚えるTDD(テスト駆動開発)

この記事はGo Advent Calendar 2015 18日目の記事です。


こんにちは、前職ではQAをやっていたpairs事業部の金子です。2015年も残り僅かとなり年末が近づいて来ましたね。

近年、開発のベースとしてTDD(テスト駆動開発)をすることは当たり前となっていますがTDDという用語だけが先行している印象を受けるため、TDDのベース知識を共有したいと思います。(Go言語…?)

はじめに

この記事は主に下記の方が対象になります。

  • TDD(テスト駆動開発)を知らない
  • TDDは知っているが、テストの書き方がわからない
  • TDDという用語は使うが、実は詳細をよく知らない
  • 「テストを書くことがTDDでしょ?」と思っている
  • 「R.I.P. TDD」と思っているが、理解はしていない
  • Go言語に釣られてやってきた

実際、「テストコードを書きながらコーディング」=「テスト駆動開発」と思っている方が大多数を占めていると思っていますが、本来は「プロダクションコードの品質を担保したテストコードを書く手法」として「テスト駆動開発」が存在します。

TDDによる単体テスト

TDD(テスト駆動開発)とは?

冒頭では「テストコードを書きながらコーディング」=「テスト駆動開発」と思われていると書きましたが、この考えは大体当たっています。
では、何が違うのかというとプロダクションコードの品質を向上させることが抜けています。

TDDのフローは下記の通りとなっていて、1〜3をループさせることでプロダクションコードとテストコードを生産していきます。

  • 1.RED: 単体テストを書いて失敗させる
  • 2.GREEN: 単体テストをパスする最小限のコードを書く
  • 3.REFACTOR: 単体テストをパスする状態を維持しながら可読性・保守性を向上させる

cycle

Go言語を使用した例

Go言語のAdventCalendarなので、Go言語も書いていきます。
単体テストを扱うために、矩形を例として上げます。矩形は下図のように、ある2点 p1, p2 が定義されていれば成り立つことができます。

rect

この矩形をGo言語で定義し、必要となる関数を記述していきます。

1. RED: 単体テストを書いて失敗させる

ある2点を定義したときに、矩形として成立させるには各2点の要素がお互いに同じ値をとってはなりません。

「矩形として成立していない=崩れている」ということで、 IsCollapsed という関数を定義することを考えます。そして、その関数の単体テストを記述します。

package rect

import (
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestRectIsCollapsed(t *testing.T) {
	assert := assert.New(t)

	var r Rect
	assert.True(r.IsCollapsed())

	r = New(Point{1, 0}, Point{1, 0})
	assert.True(r.IsCollapsed())

	r = New(Point{0, 1}, Point{1, 0})
	assert.False(r.IsCollapsed())
}

テストの評価には田野の記事で紹介した stretchr/testify を使用しています。

まずは上記のテストコードを実行し、失敗することを確認します。
もちろん Rect, Point タイプや Rect.IsCollapsed() 関数が存在していないためコンパイルエラーは明らかです。

package rect

// Point is
type Point struct {
	x, y int
}

// Rect is
type Rect struct {
	p1, p2 Point
}

// New initializes Rect by points
func New(p1, p2 Point) Rect {
	return Rect{p1, p2}
}

// IsCollapsed verifies that the rect is collapsed
func (r Rect) IsCollapsed() bool {
	return false
}

IsCollapsed関数は適当な値を返却させています。この状態で最初のテストコードを処理すると下記のように2つのエラーが出現します。

 --- FAIL: TestRectIsCollapsed (0.00s)
         Error Trace:    rect_test.go:14
         Error:          Should be true
 
         Error Trace:    rect_test.go:17
         Error:          Should be true

 FAIL
 exit status 1
 FAIL    github.com/kaneshin/go-tdd/rect 0.010s

ここまでが 単体テストを書いて失敗させる (RED) です。

2. GREEN: 単体テストをパスする最小限のコードを書く

先ほどの IsCollapsed 関数を実装しますが、このステップではテストを通すことだけを目的としたプロダクションコードを記述します。
先ほど return false していた IsCollapsed 関数を作為的に実装します。

// IsCollapsed verifies that the rect is collapsed
func (r Rect) IsCollapsed() bool {
	if r.p1.x == 0 && r.p1.y == 1 && r.p2.x == 1 && r.p2.y == 0 {
		return false
	}
	return true
}

「これでいいの?」と思うかもしれませんが、このコードでこのステップを満たす条件は揃っています。実際にテストを動作させると下記のように PASS が返却されます。

 PASS
 ok      github.com/kaneshin/go-tdd/rect 0.010s

ここまでが 単体テストをパスする最小限のコードを書く (GREEN) です。

3. REFACTOR: 単体テストをパスする状態を維持しながら可読性・保守性を向上させる

ここからが本来、必要とされるプロダクションコードを書くステップとなります。
2の結果から、ソースコードのリファクタリングを行っていきます。

// IsCollapsed verifies that the rect is collapsed
func (r Rect) IsCollapsed() bool {
	return !(r.p1.x != r.p2.x && r.p1.y != r.p2.y)
}

// or below (De Morgan's laws)
// IsCollapsed verifies that the rect is collapsed
func (r Rect) IsCollapsed() bool {
	return r.p1.x == r.p2.x || r.p1.y == r.p2.y
}

矩形を満たす条件としては、最初に述べた通り

矩形として成立させるには、各2点の要素がお互いに同じ値をとってはなりません。

を満たすことで矩形として成り立ちます。もちろん、このコードにはテストコードが存在しているので、それを実行させると PASS の結果が返ってきます。

 PASS
 ok      github.com/kaneshin/go-tdd/rect 0.010s

このステップで、単体テストをパスする状態を維持しながら可読性・保守性を向上させる (REFACTOR)満たされ、TDDによる単体テストコードが出来上がりました。

高品質な単体テスト

さて「TDDは先ほどのフローを満たすだけで良いのか」と言いますとそうではありません。
先ほどのはただの簡単なアプローチの方法でしかなく、本来ならば高品質な単体テストを生み出さなければなりません。

そのため、このフェーズではTDDの仕組みはそのままとし、単体テストの品質を向上させてプロダクションコードの品質も保証することを説明します。

品質を保証するアプローチとしては下記の2点が目的として置かれます。

  • 網羅的な単体テストを準備し、バグの早期検出を可能にする
  • 実装における単体テストの工数削減

高品質にするためのアプローチ

  • 仕様・設計から考えるテスト
  • 品質担保のための網羅的な検証から考えるテスト

機能要件を満たしつつ、非機能要件の一部を並行して担保するというふうに考えることもできます。

仕様・設計から考えるテスト(機能要件)

TDDにおける「アサートファースト」によるプロダクションコードの実装を行っていきます。
具体的には「ある仕様・設計をブレークダウンしてテスト設計する」ことになります。

ここで初めてBDD(振る舞い駆動開発)が出てくるのが一般的ですが、今回はBDDの話をすることはありません。
Go言語のテストコード思想ではアサーションする関数が用意されていないため、「アサートファースト」を実現する簡潔なテストを書くには stretchr/testify を導入するのが一番現実的なアプローチです。

品質担保のための網羅的な検証から考えるテスト(非機能要件)

「検証から考えるテスト」は検証のためのテストとなります。バグ検出や品質向上のためのテストで、リファクタリングなどを行いリグレッションテストもこちらにあたります。


TDDの上辺しか理解していないエンジニアがテストコードを記述すると、上記の機能・非機能要件としての位置付けをせず、それらを混同させたテストコードを生産しがちなので気をつけて欲しい部分でもあります。

また、下記の2点の問題も発生させてしまう可能性もあります。

  • プロダクションコードとテストコードの流れが同じでなく、うまくドライブしない(駆動開発できていない)
  • 仕様と設計が曖昧なままテストコードを記述したせいで、仕様変更に追従できない

こうなると、メンテナンスに気を配ることが必要なテストコードとなってしまい、結果として無駄な工数を発生させることになってしまします。

このようにならないために、高品質な単体テストを得るための拡張されたTDDの方法を紹介します。

品質を上げるTDDの考え

  • 1.仕様・設計のテストで仕様を整理する
  • 2.TDDを使用して使用をプロダクションコードに落としこむ
  • 3.テスト手法(境界値・デシジョンテーブル・ドメイン分析など)からテストコードを作りこむ

3が非常に重要で、何を基準にテストコードを作り込むのかといったら、QAの方々が常日頃気にしているテスト手法よりアプローチを行います。
故に、エンジニアもQAの観点を知っておかなければ「何を基準にテストコードを書いたのか」が不明となって、本当に意味のあるテストが記述できているのか疑問となってしまうでしょう。

おまけ:Go言語でカバレッジ測定

go言語ではテストコードがどれだけカバレッジしているか測定することも可能です。

 $ go test -coverprofile=cover.out . && go tool cover -func=cover.out
 ok      github.com/kaneshin/elasticsearch      4.042s  coverage: 89.1% of statements

C0カバレッジ(命令網羅:ステートメント・カバレッジ)ですが、何の指標も無いよりかマシです。
ただ、カバレッジは品質担保というよりかモチベ維持でしかないと思っているので参考指標にすべきではないと思っています。
C0カバレッジを品質指標にしてしまうと、割と簡単に満たすことができてしまうためです。

おわりに

申し訳ないくらいにGo言語要素が非常に少なくなってしまいました。。Go言語でテストを書くのは当たり前のことですが、今一度自分のテストコードの書き方を意識してみてください。

  • このエントリーをはてなブックマークに追加

エウレカでは、一緒に働いていただける方を絶賛募集中です。募集中の職種はこちらからご確認ください!皆様のエントリーをお待ちしております!

Recommend

エンジニアの僕が絶対やるJIRAの4つの設定

Goで作ったAPIのドキュメントを自動で生成する