テストコードとスピードのトレードオフ。加えてGoのAPIのテストについて。
永遠のテーマですね!タイトルを書いている段階でなんだか地雷原に足を踏み入れた気分になってきました!
つまり?
多分なんだけど、どうあれE2Eないしインテグレーションテストは書いた方がいい。
テストに関する宗教戦争はなぜ勃発するか
ちょっと前ですが以下の記事を読んでました。
あまりにバズってしまったので、前書きを追加 ここまでバズってしまって正直すまんかった。 この記事はもともと愚痴記事をマイルドにして投稿しただけなので「テストを勧める」とか「テストを信奉する」とかそこまで強い意図は特にありません...qiita.com
あと少し前はこんな記事も話題でしたね。
世の中は一周まわってエンジニアリングの手法に溢れている。 テストを書け、ドキュメントを書いて 冗長化 しろ、コミットはわかりやすく、コーディング規約が、安定性が─── でも、それって本質なんだろうか?…mosa-siru.hatenablog.com
「テスト 書くべき」って検索すると玉石混交な記事がわんさか出てくるのですが、そもそもなんでこういった議論は常に紛糾するのでしょうか?
僕個人としては、テストコードというものへの捉え方はその現場の思想に密に依存しており、その前提を明示しないまま議論を進めると、「スピード感」「技術者の習熟度」「自社開発か否か」などの様々な変数の違いによって意見が食い違い、容易に銃弾飛び交う戦場と化す、と考えています。
そのため、この議論を始めるのは下手をするとパンドラの箱をパカっと開けて、収集つかないことになるのかなーと思っています。
僕の置かれている前提
ということで、流れ弾で死にたくないのでまず僕の前提を明らかにします。
個人的な趣味趣向の話
まず個人的な立場を表明しておきますが、僕は書くまでは、億劫なんだけど書き始めたら割と好きで黙々と書いていたくなるタイプです。かといって、仕様がピョンピョン変わる現場でのTDDとか、「本当にいるのそれ」ってユニットテストを書いたりする事に関しては、人一倍に敏感であった。
一方で、サービスとして機能を早くリリースしていかなければならない & いきたいという考えから、なかなか環境整備できずにいたりすることが多いって感じです。加えて僕個人としてもテスト書き始めるまでは生き急いで開発しちゃうタイプなので、コントロールしないとなあと常に問題意識を持っています。
お仕事の話
僕は仕事だと、これまでの所属の特性もありますが、スピード感を持ってすぐリリースすることが事業の最優先事項になってます。お金が稼げないとやっぱ話にならないですからね。
あと自社サービスの開発の経験がほとんどなので、最初から100%の動作を担保できてないとクライアントさんにめっちゃ詰められるとかではないです。あと業種も金融とかじゃないので厳密じゃないです。FinTech関連企業の方はめっちゃみんな頑張っててすごい。
でも自社サービス開発と純粋に言えないような場面ではちゃんとテストごりごり書いてました。マジで!
スピード感の話
いやお前テスト書かないとかそんなん甘えじゃろって思う人も多いと思うので大体のスピード感ですが、
- 「実務ほぼ未経験の言語・インフラでだいたい2ヶ月弱で基盤構築からAPI作成、クライアント開発全体をやる。なお左記は2~3名で行う」
- 「お気に入り機能、クライアントとサーバー両方含めてRelease Ready(とは)なものを明日までに」
- 「検索API(ElasticSearchベース)の素地を明後日までに」
とかが割とどこの開発初期でもあったりします。良くも悪くもまあまあ速いんではないでしょうか。
あと、ざっくりとした仕様はあるけど、A/Bテストしてダメっぽかったから再来週消します、とかあったりしますね。
テストを書かない事によって得られるメリット
- 仕様変更の足枷が一個減る
- テスト設計で時間を取らずに済む
- 「テストしやすいコードとは〜」と考えないから赴くままにコードを書ける
- テストがfailしてリリースブロックとかがない
- 言語・フレームワークごとのテストに関する学習コストが減る
とかでしょうか?
なお上記がメリットとして享受できるのは半年〜1年くらいです。その後確実に死にます。
なお、このメリットを享受した結果問題が生じた際に、自分のケツを自分で拭かないと色々と問題になるのでその点も考慮の必要があります。
テストを書かない事によって襲いかかってくるデメリット
大半が上記の逆です。
- 仕様変更時に動作を保証できない機能が積み重なる
- ごった煮の状態に合わせたテスト設計を考えるのに時間がかかる
- 急激に工数が増える(別にそれが普通の状態なのだが、それまでを省みて急激に増えた感覚を覚える)
- failしない実装ではなく、failしないようなテストを書き出したりする
- 言語・フレームワークごとのテストに関する学習コストを担う事になる
なお、1個目が予想以上にやばくて、「後からテスト書けばいい!」と当初意気揚々とするものの、様々なサービス仮説検証を経てカオスと化した実装たちが「俺たちをテストしてみな」と襲いかかってくるわけですね。自分の首の締め方がすごい!
あとは、テストによって工数は増えると思いますが、その温度感を共有するのは大変難しいので、「今までよりちょっとだけ日数かかる程度で解決できないのか」という話になり、これまた銃弾飛び交います。
なぜスピード重視だとテストを書かないのか
- テストを書く事によるメリット(前述のデメリットを解消できる事)が理解できてない
- 当人の実力不足
- テスト書く気持ちになれないくらい余裕がない
- チームの優先順位の面で下位にきてしまい、合意が取れない
- スピード狂は元来、あんまテストに興味ない
- 「テストを書く」ってのがどの範囲までを指してるのか不明で、100%カバレッジを目指すとかなら事業の邪魔なのでやりたくない
とかが思い当たります。この辺運用経験は豊富だがサービスの0 → 1をやったことはないというタイプの人にはなかなか理解してもらえない部分かと思います。
個人的には2個目がめっちゃ大事なんですよね。 超優秀な人はテストコード込みでめっちゃ開発が早いですよ!?テスト書かないから早いとか甘えですよ!(ブーメラン)
言い訳はつらつら書けるんですが、多少整備された環境と能力があれば書けるんですよね。
スピード重視でテストを書かないことで苦労すること
苦労というか、現実的に実感するデメリットの話です。 主にコードを書いている最中の開発面と、それを取り巻く環境という意味で組織面の課題が発生すると思います。
開発面
主に2つですね。
- テストノウハウが積み上がらない
- 過去のコードに殴られる
1個目は言わずもがなです。良いテストノウハウ、というのはテストを書くことで生まれるのですね。このノウハウが積み上がらないまま後で応急処置でテスト書くとそれはそれで痛い目に合うかなと。
2個目は抽象的ですが、前述のように「様々なサービス仮説検証を経てカオスと化した実装」を、テスト可能な状態にしたり、そもそも分割して壊れるのが怖いから一時的にでもそのまま筋が通るテスト(後で消す)を書くことになる、とかです。
組織面
これはシンプルで、「スピード感を持って開発してた時期のメンバー以外と、背景を共有するのが非常に困難」という課題があります。
運用フェーズになって初めて、テスト書いた方がいいなーとなったりすると、「なんでテストがないんですか!?」という声も聞こえたりするのですが、「その歴史を語るにはこのSlackの余白は狭すぎる……」となります。
苦労するのは単純な開発の面だけではなく、これが結構困りどころです。当事者じゃないとなかなか伝わりづらい温度感だったりしますからね。コミュニケーションを円滑にする意味でもテストは大事ということです。
スピード重視でも書くべきテストとは
さて、メリットデメリットを僕の主観に基づいて整理しましたが、これらを解決するには、「スピードを維持してテストを書ける仕組みを用意する」ということが肝になってくるという反省を僕は持っています。
じゃあ何書けばいいんだよとなるんですが、100%カバレッジを求められない場合でも、正常系とちょいありそうな境界値や不正な値を投げるE2Eのテストとかインテグレーションテストくらいは書いておくと、最低限の保険になるから便利、という所感です。
ここのユニットテストを書くべきか、とか、レイヤーごとにテスト書いたり、結構モックしつつカバレッジ上げて行きましょう!という方針が良いかと言われると、それは逆に損失が大きいこともあるかなと思います。
ので、現実的には「全部ユニットテスト書かなくていいから、最低限の動作を保証できる範囲で上から下までチェックできるテスト書いた方がよい」という結論になります。
実践
方便はいいからコード書けということですね!
ちょうどGoのAPIのテストを調べてていまいち基準がわからなかったので、自分で書いてみる事にしました。 自分は普段家だとGAE/Goでサービス開発してたりするんですが、それを題材にどんなテストがスピードを維持しつつ書けるかなーと最近考えていました。
共通処理
テストコード書くときにだるいのが、共通の処理だったりモックをドバッと入れる処理を考えなきゃいけなかったりすることなんですよね。
ちなみに、Goのテスト共通処理でいうと、この記事が参考になります。
Goでテストを書くお話です。 基本的なところから、応用的なテストの書き方(パターン?)をまとめておくことにしました。 ポイントを先に列挙します: - テストのエラーメッセージは丁寧に書こう - テーブルテストを活用してパターンを...qiita.com
Goに限らない場合で共通でやるべきことは、いくつかありますが、
- DBのセットアップ、クリーンアップ
- テスト共通、あるいは個別のフィクスチャの用意
- セッション情報として仮の情報を詰める
- 実装のモックの初期化
- リクエストを効率よく作るためのutil的なやつ
とかでしょうか。
ちょっと自前の、しかもGAEのコードをそのまま持ってきてるのでわかりにくいところもあるかもですが、こんな感じでしょうか。
package testutil
// Setup ... Testのセットアップ
func Setup(t *testing.T) (context.Context, aetest.Instance, func()) {
instance, ctx, err := testerator.SpinUp()
if err != nil {
t.Error(err)
}
// 仮の認証tokenの差し込み
tm := infrastructure.NewTokenManager()
ctx = tm.NewContextWithAuthToken(ctx, &auth.Token{
UID: "1",
})
ctx = infrastructure.DatastoreWithContext(ctx)
// seedの差し込み
sps := getSeedPaths()
for _, sp := range sps {
SetupSeedData(ctx, sp)
}
return ctx, instance, func() { testerator.SpinDown() }
}
// SetupMockCtrl ... モック実行用のコントローラー作成
func SetupMockCtrl(t *testing.T) (*gomock.Controller, func()) {
ctrl := gomock.NewController(t)
return ctrl, func() { ctrl.Finish() }
}
func getSeedPaths() []string {
datadir, _ := filepath.Abs("../../../data/mock/default/")
return dirwalk(datadir)
}
// GetMockPathsFromFileNames ... モックのyamlファイルのpathを生成
func GetMockPathsFromFileNames(names []string) []string {
var paths []string
for _, k := range names {
path, _ := filepath.Abs(fmt.Sprintf("../../../data/mock/%s.yaml", k))
paths = append(paths, path)
}
return paths
}
func dirwalk(dir string) []string {
files, err := ioutil.ReadDir(dir)
if err != nil {
panic(err)
}
var paths []string
for _, file := range files {
if file.IsDir() {
paths = append(paths, dirwalk(filepath.Join(dir, file.Name()))...)
continue
}
paths = append(paths, filepath.Join(dir, file.Name()))
}
return paths
}
// NewRequest ... Requestの生成
func NewRequest(
ctx context.Context,
instance aetest.Instance,
method string,
path string,
payload string,
qparams map[string]string,
) (*http.Request, error) {
req, err := instance.NewRequest(method, path, strings.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
rctx := chi.NewRouteContext()
for key, val := range qparams {
rctx.URLParams.Add(key, val)
}
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
return req, nil
}
なお、テストデータを挿入する仕組みがdatastore界隈だとあんまない気がしていて、yamlで定義したデータをドカドカぶっこむ便利packageを今回作りました。
GAE/Goでdatastore使ってる人はぜひご活用ください。
dsmock is a fixture-injector for appengine datastore, based on YAML format fixturesgithub.com
逆にいうと、こういうのがないと共通処理がしっかり書けないので、やっぱ環境整備大変だなという感じです。
ハンドラごとのテスト
ユニットテストもちゃんとTable Driven Testで書いてるところあるんですが、それは別の記事を参照してください。 ハンドラの実装で、例えばニュース記事を取得する処理となると、こんな感じになるでしょうか。
package news_test
func TestGetNewsHandler(t *testing.T) {
ctx, instance, cleanup := testutils.Setup(t)
defer cleanup()
mockpaths := testutils.GetMockPathsFromFileNames([]string{
"news",
})
for _, mockpath := range mockpaths {
testutils.SetupMockData(ctx, mockpath)
}
// リクエストの作成
req, err := testutils.NewRequest(ctx, instance, http.MethodGet, "/api/news/1", "", map[string]string{
"id": fmt.Sprint(1),
})
if err != nil {
t.Error(err)
}
// レスポンスの作成
res := httptest.NewRecorder()
newsDependency := initDependency()
// リクエストの実行
newsDependency.GetNewsHandler(res, req)
// レスポンスのステータスコード検証
assert.Equal(t, http.StatusOK, res.Code, "[Success]")
var payload entity.News
err = json.NewDecoder(res.Body).Decode(&payload)
if err != nil {
t.Errorf("[FAIL] invalid json response: %s", res.Body.String())
}
assert.Equal(t, "記事タイトル", payload.Title, "[Success]")
}
ちょっとDIオブジェクトが書いてあったりして謎な部分もあるかもですが、やってることはモック作ってリクエストして検証してるって感じです。
(本当は純粋にE2Eテストを例として挙げたかったのですが、GAEだとどうしても事前に認知できない別portでテストサーバーが起動してしまい、E2Eのテストの書き方がわからなかったので、ハンドラ起点のインテグレーションテストを書きました。ご容赦ください。)
これでひとまずハンドラの正常系は書けました。めでたしめでたし。こっから異常系をバンバン書いていくと、コード量もドカドカ増えますが、テストは書いてるときに常に承認されてる気分になるので、あんま面倒でもないですね。
なお、Goに限った話ですが、APIのリクエストの検証処理をコールバックで定義して、テスト内で繰り返し呼ぶという手法もあるようで、使ってみると面白そうです。
googleのライブラリだったり、Go公式のpackageのテストは、異様にtestのutilが充実してるので見てみると面白いです。
まとめ
以上、入り口に過ぎませんが、実践してみました。
この環境を作るのに自作のパッケージ製作含め1日かかってないので、実際のところ当人の能力や気合不足か?死ぬか?と思いました。
あと、やっぱあるのとないとでは安心感が違います。
テストコードと開発スピードは常にそのバランスが問題となり、宗教戦争を産みやすいテーマの一つです。特に開発現場の畑が違うと、収束は困難です。 スピードを維持しつつ、テストを書き続けられる体制を作るために、最善を尽くしたいものですね。