GoでCIで動かせるE2Eテストコードを書くための下調べをしたのでメモしておく。
TL;DR
- CIで動かせるE2Eテストとして、
ListenAndServe
しているサーバに対してHTTP通信するテストコードを書きたくなった。 ListenAndServe
は通信の開始待ちができないので、Flakyなテストになってしまう。net.Listen
して取得したnet.Listener
を使うことで、待機無しでテスト対象のサーバと通信できる。
CIで実行できるE2EレベルのテストをGoのテストコードとして書きたい
柴田さんや@t_wadaさんの話を読んで、もっとE2Eテストに近いHTTPテストについてちゃんと考えたいなと思い始めた。
- マイクロサービスの開発とテストファースト/テスト駆動開発 | GDG Dev Fest Tokyo 2019
モック多用の自作自演テストやレイヤ間のテスト範囲重複を好まないので(個人の意見)、テストを内側と外側に寄せます。Railsの場合はRequest spec(HTTPレベルのテスト、E2Eより安定していてかつ粗粒度) とModel spec(ドメインロジックのテストを厚く書く)を基本的に書き、Controllerは例外系のみ。
— Takuto Wada (@t_wada) March 10, 2020
今まで、httptest.ResponseRecorder
などを利用したハンドラーレベルのテストは実施していた。
handler := func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "<html><body>Hello World!</body></html>")
}
// Arrange
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
w := httptest.NewRecorder()
// Act
handler(w, req)
// Assert
が、サーバを起動してそのサーバに対してHTTPリクエストを飛ばして挙動を確認するところまでやりたいなと思った。
もう少し具体的に書くと、自身が作っているWebアプリに対して、次のようなテストシナリオをGoのテストコードとして書きたい。
func TestAPIServer(t *testing.T) {
// テスト対象のWebアプリサーバが依存する各種サービスのフェイクサーバを起動する(このブログ記事では触れない)
// テスト対象のWebアプリサーバを起動する
// テスト対象のサーバに対してHTTPリクエストを送信する
// レスポンス内容を検証する
// 各種サーバを適切に終了する
}
HTTPテストを書くときの問題
上記のようなテストコードを書こうとしたとき、テスト対象のWebアプリサーバの起動部分にListenAndServe
メソッドを使っていると問題が発生する。
func TestStartServer(t *testing.T) {
want := "Hello, world!\n"
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
io.WriteString(w, want)
})
s := &http.Server{
Addr: ":8080",
Handler: mux,
}
go func() {
t.Fatal(s.ListenAndServe())
}()
time.Sleep(0 * time.Second) // 1秒待機すれば成功する
res, err := http.Get("http://127.0.0.1:8080/")
if err != nil {
// Get http://127.0.0.1:8080/: dial tcp 127.0.0.1:8080: connect: Connection refused
t.Fatal(err)
}
b, err := ioutil.ReadAll(res.Body)
res.Body.Close()
if err != nil {
t.Fatal(err)
}
if string(b) != want {
t.Fatalf("want %q, but %q", want, b)
}
}
http.Server#ListenAndServe
メソッド(http.ListenAndServe
関数)は呼び出すと通信終了まで完了しない。
そのためテストコード中で、ListenAndServe
メソッドを実行してテストを開始するには、別goroutineで実行する必要がある。
そうなると、当然サーバが起動するまでテストコードから通信するのを待つ必要がある。
数秒待てばサーバは起動しているが、タイミングを合わせるためにテストの実行時間をむやみに伸ばしたくはない。
かと言って、ギリギリの時間まで待機時間を削ると、Flakyなテストになるのでそれも良くない。
- 【用語解説】John Micco氏の言う"Flaky"なテストとは?
net.Listen関数を使って、待ち無しでListen状態にしておく
この問題を解決するには、net.Listen
を明示的に宣言して、http/Server.Serve
メソッド(http.Serve
関数)からサーバを起動すればよい。
テスト対象のサーバを起動して、待ち無しでリクエストを送信、レスポンスを検証するエンドポイントテストが以下だ。
package main
import (
"context"
"io"
"io/ioutil"
"log"
"net"
"net/http"
"testing"
)
func buildServer(body string) *http.Server {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
io.WriteString(w, body)
})
return &http.Server{
Handler: mux,
}
}
func TestStartServer(t *testing.T) {
want := "Hello, world!\n"
// Arrange
srv := buildServer(want)
// 動的にポートを選択するので並行テストが可能。
l, err := net.Listen("tcp", ":0")
if err != nil {
log.Fatal(err)
}
idleConnsClosed := make(chan struct{})
go func() {
if err := srv.Serve(l); err != http.ErrServerClosed {
t.Fatalf("HTTP server ListenAndServe: %v", err)
}
// サーバが終了したことを通知。
close(idleConnsClosed)
}()
// Act
res, err := http.Get("http://" + l.Addr().String())
if err != nil {
t.Fatal(err)
}
b, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
// Assert
if string(b) != want {
t.Fatalf("want %q, but %q", want, b)
}
// Cleanup
if err := srv.Shutdown(context.Background()); err != nil {
t.Fatalf("HTTP server Shutdown: %v", err)
}
// サーバの終了を確認してからテストコードを終了する。
<-idleConnsClosed
}
net.Listen
関数は待ちを発生させずに完了し、関数呼び出し終了時点でLISTEN
が開始される。
そのため、別goroutineを呼ばずにサーバ用の通信を開始することができる。
あとは、このnet.Listener
オブジェクトを使ってテスト対象のサーバを別goroutineで起動する。
こうすると、テストコードは何も待たずに通信を始めることができる。
テストコードの実行が終了したら、http.Server#Shutdown
メソッドを実行することでグレースフルにサーバを終了することができる。
テスト対象のアプリサーバの起動処理を、このような呼び出し方に対応するように設計する必要があるが、これでHTTP通信を利用したテストコードが書けそうだ。
おわりに
テスト対象のサーバの起動インターフェイスに条件はあるものの、やりたいことができるのは確認した。
最初どうやってサーバの起動を待機しようかと考えていた。
httptest.NewServer
は待機処理をせずとも通信が成功するので、そのコードを参考にした。
(httptest.NewServer
をそのまま使えばいいのかもしれないが…)
net.Listen
を明示的に始めることで、その時点からTCP通信を待機状態にするのがコツだった。
このあたりはGoならわかるシステムプログラミングのTCPソケットの章を読み直して復習できた。
途中、Webアプリのユニットテスト書こうとしたのに、システムプログラミングに行き着いた。すべてがつながってる感がある。
今回は省略したが、やりたいテストコードを書くには、テスト対象が依存するサービスのフェイクサーバも起動しないといけない。
本文中の書きなぐりのコードでは利用が難しいので、起動部分を再利用可能なカタチにまとめて、フェイクサーバを起動したり、複数のテストで利用できるパッケージにしておきたいなと思う。