Go言語をTDDで学んでみた話

テスト駆動開発を使ったプログラミング言語学習の実例

Eiji Ienaga
時を超えたプログラミングの道

--

今日はTDDのリズムを使ってGo言語でのプログラミングの学習していくログをとって、ゆっくり進めて解説していきます。みなさんも実際に手で動かしながら試してみて下さい。

セットアップ準備

Goのインストールは次を参照して下さい。

私の最近お気に入りのエディタはVS Codeです。インストールは次を確認してください。

Goのための拡張も入れましょう。次を参照して下さい。

テスト実行や定義の移動や戻るを操作しやすいようにキーボードショートカット設定しておくと便利です。Code > 基本設定 > キーボードショートカット >から設定できます。設定結果のkeybinds.jsonは例えば次です。本当は、保存したら即テスト実行の仕方を知りたかったのですが後回しにします。

TDDの基本リズムはRed Green Refactorです。Red(テスト失敗)、Green(テスト成功)、Refactorのリズムを繰り返しながら学習をすすめてきます。

今日のお題は「アラビア数字からローマ数字への変換」を選択しました。本来なら1-3999までの範囲がローマ数字に変換できるのですが、今日は問題を縮小して、「1→I」,「2→II」の変換から、試行錯誤してみます。

TDDのコツ(1):問題の分割と中間ゴールの設定

問題が難しく道のりが長いと感じたときは、問題を縮小したり、小さく分割してサブコールを設定して試行錯誤して知見を得ながら進めるのも、プログラミングを進めるコツの1つです。TDDを開始する前にゴールに到達にするための、タスクリスト(テストパターンやリファクタしたいことや調べモノしたいことをリスト化したもの)を用意して問題を分割します。タスクリストは紙や付箋やテキストエディタに書き出します。

#TODO
[DOING] 1 => Iの変換ができること。(テストの書き方を調べる)
2 => IIの変換ができること。
....(後でTODO追加)

ステップ1:テストで失敗することを確認する

テストが失敗し、メッセージ内容を確認

テストの書き方は、golangのテストコードを真似ながら学習します。次を参考にしました。

Goの学び:関数の定義の仕方。引数やリターンの型を変数の後に書くことに注意。型を後ろに書くスタイルのプログラミング言語は増えてますね。Kotlin, Rustなど。

Goの学び:暗黙的な型宣言 「:=」。varとは違って :=であれば型宣言無しに変数への代入ができます。

備考:Go言語の文法を最低限抑えるなら、A Tour of Go を一通り試してみることをお勧めします。

Goの学び:テストの書き方その1。噂には聞いていましたが、標準のライブラリのテストコードを覗くと本当に他のテスティングフレームワークでよく見かけるassertEquals(expected, actual)を使っていませんね。期待通りの結果かをifで評価します。もし期待と実際が異なる場合、t.Errorf で失敗時のエラーレポートを自分で明記します。Goのテストの語彙は、xUnit系とは違って、expectedの代わりにwant、 actualの代わりにgotを使う習慣があるようなので、そちらを採用します。

func Test<ここにテスト対象を明記>(t *testing.T) {
if got, want := <評価したい関数やメソッド>, <期待結果>; got != want {
t.Errorf("<ここに失敗時のメッセージ>: got %v want %v", got, want)
}
}

基本ライブラリのテストを覗いてみると、データ駆動型のテストがよく使われています。あとで置き換えてみましょう。

テスト失敗時に他の環境ではRed色がつくのですが、VS Code & Go言語の場合、色がついてなくて少しわかりづらいですね。 :-( 残念。Red Green Refactorの代わりに、テスト失敗、テスト成功、リファクタリングのリズムですすめます。

TDDのコツ(2):テストを使って小さな問題を明瞭にする

テストを書くは解くべき問題を人にもコンピュータにも明瞭に理解できる効果がります。

TDDのコツ(3):テスト失敗結果のレポートを読み、失敗結果がわかりやすいように書くこと

プロダクションコードをリーダブルにするだけでなく、テスト失敗時のレポートもリーダブルにしておきます。エラーレポートも人が観るところです。あとで失敗したときに、未来の自分や別の人がひと目見て何のテストが落ちたか(次に何をすればよいか)、すぐに分かるようにしておきます。

テストが失敗したままなので、次は、パスさせてみましょう

ステップ2:仮実装でテストをパスさせる

仮実装でテストがパスすること確認

TDDのコツ(4):べた書きの仮実装でテストが期待通り動作すること確認

return “I” と べた書きの仮実装で完成ではないのです。が、べた書きによって、テストが期待する通りに動作するか否かを確認することができます。

TDDのコツ(5):STEP BY STEPで問題を解く

そのほか、べた書きによる仮実装は、問題を解くのが難しい場合に有効です。なぜなら、STEP BY STEPで問題を少しずつ理解しながら先にすすめることができるからです。実装の一部だけを、べた書きの仮実装で期待通り動作することを確認=>リファクタリング=>べた書きを本来の問題の解き方に置き換えと、ちょっとづづ解決案に近づいていきます。直ぐに問題が解ける簡単な内容であれば、ステップ幅を広げて一気に問題を解きます。

キリがいいのでタスクリスト(TODO)を更新します。先に進んでも良いのですが、今日はGo言語の勉强を兼ねて、別の書き方のオブジェクト指向風のメソッドに置き換えてみます。

#TODO
[DOING]APIの見直し(オブジェクト指向風のメソッドに置き換え)
Goでよく見かけるデータ駆動型のテストに置き換え
2 => IIの変換ができること。
# DONE
1 => Iの変換ができること。

ステップ3:APIの見直し、関数からメソッドへ

まずは引数の数値だったものを、独自型を定義してメソッドを追加してみます。

オブジェクト指向風APIに置き換え

テストもパスすることが確認できました。インプットを独自型Arabicを用意したのにアウトプットが汎用的な文字列と対象性がないので、アウトプットも独自型のRomanに置き換えます。

func ToRoman(in uint16) string {...

func (in Arabic) ToRoman() Roman {...

置き換えてテストも問題ないことが確認できました。

Goの学び:数値のメソッド拡張
Goには class 構文は存在しませんが、上記のスタイルでtype対してメソッドを追加することが出来ます。構造体(struct)に対してメソッドを付けることも可能です。
残念ながらGoでは、Rubyのオープンクラスのように、数値型(uint16)に直接メソッドを追加することを許していません。が、typeを使った独自定義の数値型(この例ではArabic)にメソッドを拡張できるようです。次を参考にしました。

TDDのコツ(6):テストコードでAPIの使い方の見直し
テストコードは提供するAPIの使い方のサンプルとなるコードです。どんな風にすると読み書きしやすいかをテストコードの箇所を実験場に使って、見直します。今回は、オブジェクト指向風が気になったので試してみました。

TDDのコツ(7):適宜タスクリスト(TODO)を入れ替え
プログラミングを試行錯誤していると、作業しているなかで必要なテストケースや設計の改善案、技術的に試したいこと、リファクタリング、既存バグを発見することがあります。それらをTODOに積んで、優先順位を付けて、順に倒してゴールに向かっていくがプログラミングのコツの1つです。一度にすべてを対応しようとすると混乱して前に進めなくなってしまいます。

切りが良いのでタスクを見直します

#TODO
[DOING]Goでよく見かけるデータ駆動型のテストに置き換え
2 => IIの変換ができること。
....(後でTODO追加)# DONE
1 => Iの変換ができること。
APIの見直し(オブジェクト指向風のメソッドに置き換え)

ステップ4:データ駆動型のテストに置き換える

Goでのテストの書き方をGithubで読んでると、データ駆動型のテストが多数発見できます。私も試してみます。

Goでよくあるデータ駆動型のスタイルでテスト記述。念のため失敗のときのメッセージを確認

念のためいったんテスト失敗でメッセージ確認してみましょう。メッセージの内容も少しかわりました。

Go言語の学び:データ駆動型のテスト
Goでテスト書く場合の定番です。スラスラ書けるようになっておきたいところです。

testCases := []struct {
<ここに属性一覧>...
}{
<ここにテスト例一覧>...
}
for _, test := range testCases {
if got := <ここにテストしたいメソッド>; got != test.want {
t.Errorf("<ここにメッセージ>: got %v want %v", got, test.want)
}
}

データ駆動型については、次も参考にして下さい。

データ駆動の典型的なテスティングフレームワークはFitです。表やコンマ区切りなどデータ構造でテストパターンを整理するのがデータ駆動型のテストの特徴です。

Goの学び:t.Run
t.Run(“ここに説明…”)を使えば失敗時の情報を追加することが可能だということがわかりました。データ駆動型のテストの場合、テスト失敗時のどのテストケースが失敗したかをエラーレポートを観てすぐに分かるようにしておくは、おすすめです。

t.Run("ここにテストの説明", func(t *testing.T) {
if got != want {
t.Errorf("xxxx: got %v want %v", got, want)
}
}
テストがパスすことを確認
#TODO
[DOING]2 => IIの変換ができること。
....(後でTODO追加)
# DONE
1 => Iの変換ができること。
APIの見直し(オブジェクト指向風のメソッドに置き換え)
[DOING]Goでよく見かけるデータ駆動型のテストに置き換え

それではテスト 2の場合にIIに変換できることを確認します。

ステップ5:テストを追加して失敗すること確認する

テスト追加し失敗することを確認

期待通りテストが失敗しました。テストが成功するように書き換えてみましょう。

ステップ6:べた書きの仮実装。(ただしこの先は見込みがない)

テストはパスしました。ただし、テストケースが1,2と少ないのであればこの実装でも対して問題ないのですが、ローマ数字は1–3999の範囲があることが解っています。全パターンをifで実現するはあまり良い実装方式とは言えませんので書き換えてみます。色々試行錯誤(goto文を使ってもテストをパスできるとわかったが不採用、データ構造のハッシュも試すがテストケースを増やしたところで順序性がなくて期待通りの動作がしないことがわかり不採用などなど)したのち、次になりました。

テストが成功する時はリファクタリングのチャンス。リファクタリングを試みます。

ステップ6:リファクタリング!

Go言語の学び:変数をメソッドの外側に切り出すと := は文法エラー。 varを使って 置き換えが必要が分かりました。

更にリファクタリングを試みます。Goではメソッド定義の返り値は型だけでなく変数名も定義できるようです。

ステップ7:& リファクタリング!

TDDのコツ(8):小さくリファクタリング!
テストがパスしている時はリファクタリングのチャンスタイムです。機械が理解できるだけでなく、人が読んで理解できるように、直近で予期される追加変更が簡単にできるようにコードを整理します。
今回は当てはまりませんが、メソッドの抽出、名前の変更などが典型的なリファクタリングステップです。小さく変更して、即テスト実行がリファクタリングのコツです。まだ試していないですが Go言語には リファクタリングを助けてくれる gorename というものがあるそうです。

1,2の場合が解けると、10,20の場合のケースも気になりますよね?。少し延長し、優先して試してみます。今度は、テストコードとプロダクションコードを纏めて修正して期待通り動作するか確認してみます。

おっ。うまく行ってますね。データ構造のみで、問題を定義し、解けると実に気持ちいいです。この先のテストケースもらくらくと解けそうな予感がします。ペアプロしているならここらでハイタッチ!をキメるところでしょう :-)

テストがきちんと機能しているか不安がある場合は、一部をコメントアウトして失敗することを確認します。

ペアプロのコツ:ハイタッチ!で成功の喜びを分かち合う

ペアで問題を解く際は、一度ハイタッチを試みて下さい。うまくテストがパスした時、セクシーなリファクタリングをキメたとき、タスクが完了した時などが、笑顔でハイタッチをキメるタイミングです。ハイタッチを一度も試したことがないのであれば、今まで感じたことのないプログラミング体験をお約束します。

だいぶかたちになってきました。Wikipediaで仕様を再確認し、タスクを見直して今日は閉じます。無効な数値の境界値、4の場合、9の場合が気になるところなので優先順を高くしておきます。名前の変更を支援するgorename も気になりますね。

#TODO
0の場合、4000の場合はエラー
4の場合はIV
9の場合はIX
5の場合はV
40の場合
50の場合
11の場合はXI
(1-3999で抜け漏れをあとで...考える)
gorename を試す
VSCodeで保存したら即テスト実行の方法を探す
# DONE
10,20の場合
1 => Iの変換ができること。
APIの見直し(オブジェクト指向風のメソッドに置き換え)
Goでよく見かけるデータ駆動型のテストに置き換え
2 => IIの変換ができること。

まとめ

TDDを使って Go言語に触れてみました。慣れてない言語なので、実際には、もっとつまづき、色々試しているのですが、省略しました(構造体を使う、途中まで解いたコードを捨ててタスク順序を変える、ライブラリのコードを覗く、テスティングフレームワークのコード覗く、本家のドキュメントを斜め読み、Goの設計思想についてQiitaの記事で調べる、リファクタリング案をやめて元に戻す、などなど)。お題を使って実際に手を動かしつつ、少し脱線して調べ物をしながらプログラミング言語を学んでみるのはオススメです。

TDDというと「テスト」の言葉に囚われがちですが、小さな実験を繰り返し、楽しみながら学び続けるのがTDDの哲学の肝です。問題が大きければ小さく分割する、テストを使ってAPI設計を見直す、テストを使って問題を明瞭に定義する、ステップ・バイ・ステップで解く、失敗時のレポートを読んで期待と実際のギャップを埋める方法を調べる、リファクタリング案を試して選択するなど、試行錯誤してみて下さい。

また、Go言語は、他の言語と違って文法が少なく、素直で習得しやすいプログラミング言語になっています。おしゃれな書き方ができるプログラミング言語好きの人にとっては少々物足りないものを感じるかもしれませんし、不便に感じる箇所もあるでしょう。
ただ、Goに入ればGoに従えでいったん、普段使い慣れたプログラミング言語で「よい」とされている価値判断基準を保留して、実際にGo言語を触り続けてみてください。GoでつくられるトレンドのライブラリのソースをGithubで覗いてみてください。あなたがこれまで知らなかった、ソフトウェア設計の価値判断基準が発見できるかもしれません。

--

--