GoのWebアプリをテストするノウハウ
メディアエンジン株式会社の田中です!
Webアプリに限った話ではないものの、アプリケーションを安定して稼働させるためにはテストの自動化が重要だと思います。
この記事では、Goで書いたWebアプリをテストする際に弊社で行っている方法やパターンなどについて紹介します。
APIのテスト
実装したAPIをテストする方法について紹介します。
ここではフレームワークとしてEchoを例に解説しますが、他のWebフレームワークでもある程度流用はできると思います。
Echoハンドラのテスト
Echoのハンドラ関数をテストする方法について説明します。
ここでは下記2つの方法を紹介します。
関数として直接実行する方法
Echoハンドラの実態はただの関数なので、echo.Context
を用意すれば直接テストすることが可能です。
func Healthcheck(c echo.Context) error {
return c.JSON(http.StatusOK, true)
}
echo.Context
は次のようにして作成できます。
echoServer := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(""))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c := echoServer.NewContext(req, rec)
こうすることで、例えば、以下のようにしてテストができます。
func TestHealthcheck(t *testing.T) {
echoServer := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(""))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c := echoServer.NewContext(req, rec)
c.SetPath("/api/healthcheck")
if err := Healthcheck(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Errorf("status code: %d expected, but got %d", http.StatusOK, rec.Code)
}
var response bool
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatal(err)
}
if !response {
t.Error("response: `true` expected, but got `false`")
}
}
HTTPサーバを立ててリクエストを送信する方法
別の方法としては、以下のようにしてテストコード中でHTTPサーバを立てて直接HTTPリクエストを送信するという方法もあります。
その際には、net/http/httptestパッケージを使うと便利です。
func TestAPI(t *testing.T) {
e := NewEchoServer() // echo.Echoインスタンスを作成する
testServer := httptest.NewServer(e.Server.Handler) // サーバを立てる
t.Cleanup(func() {
testServer.Close()
})
// サーバに向けて直接リクエストを送信する
var user User
restyClient := resty.New().SetBaseURL(testServer.URL)
restyClient.R().
SetHeaders(map[string]string{
"Content-Type": "application/json",
})
res, err := c.newRequest().
SetResult(&user).
Get(fmt.Sprintf("/api/users/%d", userID))
// アサーションなどを実施 (省略...)
}
どちらの方法でテストすべきか?
状況にもよりますが、2つ目の方法の方が信頼性という観点ではよいと思います。
ミドルウェアなども含めて実行されるため、本番のアプリケーションと近い状態でテストができるためです。
しかし、
- 1つ目の方法に比べてオーバーヘッドが大きい
- 1つ目の方法と比べると細かな依存性の制御などがやりにくい
などの欠点もあると思います。
そのため、今のところ弊社では以下のような方針でテストしてます:
- 基本は1つ目のEchoハンドラを直接実行する方法でテストする
- 重要な部分などについては2つ目の方法でテストする
Echoハンドラに依存性の注入をしたい
いくつか方法があると思いますが、ここでは構造体を利用した方法を紹介します。
例えば、次のような構造体を用意します。
type userHandlers struct {
repository user.Repository
logger logging.Logger
}
func NewUserHandlers(repository user.Repository, logger logging.Logger) *userHandlers {
return &userHandlers{repository, logger}
}
次のようにメソッドを定義します
func (h *userHandlers) Get(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest)
}
u, err := h.repository.Get(id)
if err != nil {
h.logger.Error(err)
return echo.NewHTTPError(http.StatusNotFound)
}
return c.JSON(http.StatusOK, u)
}
func (h *userHandlers) Create(c echo.Context) error {
// ... 省略 ...
}
あとは次のようにしてハンドラを登録します
e := echo.New()
users := NewUserHandlers(userRepository, logger)
e.GET("/users/:id", users.Get)
e.POST("/users", users.Create)
このようにすることで、Echoのハンドラに対して依存性の注入ができます。
Echoミドルウェアのテスト
Echoミドルウェアの実体はただの関数なので、echo.Context
を作り引数として渡すことでテストができます。
例えば、次のようなミドルウェアがあったとします。
func ResponseTime(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
s := time.Now()
err := next(c)
e := time.Now()
d := e.UnixMilli() - s.UnixMilli()
c.Response().Header().Set("X-Response-Time", fmt.Sprintf("%dms", d))
return err
}
}
次のようにecho.Context
を作成し、それをResponseTime
に渡せばテストができます。
func TestResponseTime(t *testing.T) {
e := echo.New()
req := httptest.NewRequest("GET", "/users/1", strings.NewReader(""))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
callCount := 0
next := func(c echo.Context) error {
callCount++
time.Sleep(50 * time.Millisecond)
return nil
}
if err := ResponseTime(next)(c); err != nil {
t.Fatal(err)
}
res := rec.Result()
rt := res.Header.Get("X-Response-Time")
pattern := "^\\d+ms$"
ok, err := regexp.MatchString(pattern, rt)
if err != nil {
t.Fatal(err)
} else if !ok {
t.Errorf("X-Response-Time: \"%s\" didn't match the pattern: `/%s/`", rt, pattern)
}
if callCount != 1 {
t.Errorf("callCount: `1` expected, but got `%d`", callCount)
}
}
永続化が絡む機能のテスト
DBへの保存や検索などが絡む機能のテストについて解説します。
ここではGORMを例に説明しますが、ある程度他のORMなどでも流用はできるのではないかと思います。
DBについて
テストを実行する際に、開発用と同じデータベース(CREATE DATABASE
で作成する論理的なものを指しています)を利用してしまうと、テストの結果が不安定になる原因となります。
それを避けるため、開発用に利用するデータベースとは別にテスト用のデータベースを用意してテストしています。
具体的には、Makefile
でtest
というタスクを用意し、テスト用データベースのセットアップやマイグレーションなどを実施してからテストを実行しています。
test:
sql-migrate up --env="test"
APP_ENV=test go test ./...
DBの掃除方法
あるテストケースの終了後、そのテストケース中に挿入されたデータなどがテーブルに残っていると、テストの実行結果が不安定になる原因となります。
そのため、テストケースの終了時などのタイミングでテーブルのお掃除処理をしておく必要があります。
これについては、testutil
というパッケージに以下のようなヘルパー関数を用意し、テストケース終了時に自動でロールバックを行うようにすることで対処しています。
func RunInTransaction(db *gorm.DB, fn func(db *gorm.DB)) error {
if err := db.Transaction(func(tx *gorm.DB) error {
defer func() {
tx.Rollback()
}()
fn(tx)
return nil
}); err == nil {
return errors.New("ロールバックが適切に行われていません")
}
return nil
}
例えば、*gorm.DB
を引数として受け取るDoSomethingWithDB
という関数があったとします
これを次のようにしてテストします。
func TestDoSomethingWithDB(t *testing.T) {
testingDB, err := testutil.PrepareTestingDB()
if err != nil {
t.Fatal(err)
}
if err := testutil.RunInTransaction(testingDB, func(tx *gorm.DB) {
if err := DoSomethingWithDB(tx); err != nil {
t.Fatal(err)
}
// 検証
var count int64
if err := tx.Model(&models.SomeModel{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("`1` expected, but got `%d`", count)
}
}); err != nil {
t.Fatal(err)
}
}
こうすることでDoSomethingWithDB
の実行中にDBに加わった変更が自動でロールバックされます。
競合問題
プロダクトのテストコードが徐々に増えてきた頃に、ローカルではテストがパスするけど、CIでは度々テストが失敗するという現象に遭遇するようになりました。
調べてみたところ、go test
は各パッケージのテストを並行で実行するようで、それによる競合が問題の原因でした。
解決策としては、各パッケージごとに専用のデータベース(CREATE DATABASE
で作る論理的なもの)やスキーマを用意し、並行実行しても競合が起こらないようにすることが理想的だと思います。(たしかRailsとかも同じようなことをやっていたはず。。。🤔)
ただし、これについては正直まだ実現できておらず、go test -p 1
でテストの実行を直列化することで問題を回避しています。。。
外部APIに依存する機能のテスト
Webアプリの開発では外部・内部問わずAPIを叩くことがあると思います。
そういった場合におけるテスト方法について3パターンほど解説します。
http.Client
をスタブする
net/httpのClient
を作成する際に、RoundTripperインターフェースの実装を指定することでスタブすることができます。
この方法は、利用しているサードパーティのAPIクライアントライブラリが引数などでhttp.Client
の設定をサポートしており、それらを含めたインテグレーションテストを書く際にスタブを利用したいケースなどで利用するとよいのではないかと思います。
type RoundTripFn func(req *http.Request) *http.Response
func (f RoundTripFn) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func NewStubHttpClient(fn RoundTripFn) *http.Client {
return &http.Client{
Transport: RoundTripFn(fn),
}
}
具体的には、例えばtestdata/sample.json
にテストデータを用意しておき、それをレスポンスとして返却することができます。
testdata, err := ioutil.ReadFile("testdata/sample.json")
if err != nil {
t.Fatal(err)
}
client := NewStubHttpClient(func(req *http.Request) *http.Response {
return &http.Response{
StatusCode: 200,
Body: ioutil.NopCloser(bytes.NewBufferString(string(testdata))),
Header: make(http.Header),
}
})
依存注入するパターン
このパターンでは、外部のAPIと通信する責務をインターフェースで表現し、アプリケーションコードではそのインターフェースを利用して機能を実装します。
この方法は、特定のメソッドや関数などのユニットテストなどを書く際に利用するとよいと思います。
具体的には、まず次のようなインターフェースを用意します。
package api
type APIClient interface {
GetUser(userID int) (*User, error)
CreateUser(payload *CreateUserInput) (*User, error)
}
次にこのインターフェースの実装を用意します。
// ... 省略 ...
func NewAPIClient(baseURL string) api.APIClient {
restyClient := resty.New().SetBaseURL(baseURL)
return &apiClient{restyClient}
}
type apiClient struct {
client *resty.Client
}
func (c *apiClient) GetUser(userID int) (*User, error) {
var response User
res, err := c.client.R().
SetResult(&response).
Post(fmt.Sprintf("/users/%d", userID))
if err != nil {
return nil, err
}
return &response, nil
}
func (c *apiClient) CreateUser(input *CreateUserInput) (*User, error) {
var user User
res, err := c.client.R().
SetBody(input).
SetResult(&user).
Post("/users")
if err != nil {
return nil, err
}
return &user, nil
}
アプリケーションコードでは実装である*apiClient
型ではなくインターフェースのAPIClient
型を参照させます。
package foo
func DoSomethingWithAPI(c api.APIClient) error {
// ... 省略 ...
}
このようにすることで、DoSomethingWithAPI
を呼ぶ際に柔軟にAPIClient
の実装を切り替えることができます。
例えば、以下のようなスタブ実装を用意します。
type inMemoryAPIClient struct {
users map[int]*User
}
func NewInMemoryAPIClient() api.APIClient {
return &inMemoryAPIClient{map[int]*User{}}
}
func (c *inMemoryAPIClient) GetUser(userID int) (*User, error) {
user, ok := c.users[userID]
if !ok {
return nil, errors.New("Not found")
}
return user, nil
}
func (c *inMemoryAPIClient) CreateUser(input *CreateUserInput) (*User, error) {
user := createUserFromInput(input)
c.users[user.ID] = user
return user, nil
}
テスト時などは次のようにスタブ実装の方を利用してテストをすることができます。
c := api.NewInMemoryAPIClient()
DoSomethingWithAPI(c)
スタブサーバを立てるパターン
外部のAPIサーバなどのスタブを用意し、テスト時はそのスタブを参照させることでテストするパターンです。
こちらのパターンも1つ目のパターンと同様に、インテーグレーションテストを書く際に外部のAPIサーバのスタブを用意したい場合に利用するとよいのではないかと思います。
先ほど例として紹介したAPIClient
の通信先サーバのスタブを用意してみます。
func StartTestingAPIServer() *httptest.Server {
s := testingAPIServer{}
return s.Listen()
}
type testingAPIServer struct {
sync.RWMutex
users map[int]*api.User
}
func (s *testingAPIServer) Listen() *httptest.Server {
app := echo.New()
app.GET("/users/:id", func(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
s.RLock()
defer s.RUnlock()
user, ok := s.users[id]
if !ok {
return echo.NewHTTPError(http.StatusNotFound)
}
return c.JSON(http.StatusOK, user)
})
app.POST("/users", func(c echo.Context) error {
user := new(api.User)
if err := c.Bind(user); err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
s.Lock()
defer s.Unlock()
user.ID = generateID()
s.users[user.ID] = user
return c.JSON(http.StatusOK, user)
})
return httptest.NewServer(app.Server.Handler)
}
あとは、テスト時にこのスタブAPIサーバと通信をするように設定して利用します。
func TestDoSomething(t *testing.T) {
apiServer := StartTestingAPIServer()
t.Cleanup(func() {
apiServer.Close()
})
apiClient := api.NewAPIClient(apiServer.URL)
if err := doSomething(apiClient); err != nil {
t.Fatal(err)
}
}
その他
AWSに依存する機能のテスト
外部APIに依存する機能のテストにも関連しますが、別途セクションを設けることにしました。
AWSに依存する機能のテストを自動化したい場合は、LocalStackなどの使用を検討するとよいと思います。
LocalStackはDockerで動かせるAWSのエミュレータです。
CIなどでAWSに依存した機能のテストを自動化したいときなどに便利だと思います。
また、LocalStack程複雑なものは必要ない場合は、下記の使用を個別に検討してもよいかもしれません。
ただし、ECSなどの一部のサービスではLocalStackの無料版では動きません。
弊社では、そういった場合の回避策として、以下のようなアプローチを用いています。
まずECS
というインターフェースを用意します。
import (
"context"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ecs"
"github.com/aws/aws-sdk-go/aws"
)
type ECS interface {
UpdateService(ctx context.Context, params *ecs.UpdateServiceInput, optFns ...func(*ecs.Options)) (*ecs.UpdateServiceOutput, error)
DescribeServices(ctx context.Context, params *ecs.DescribeServicesInput, optFns ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error)
}
このインターフェースはaws-sdk-go
の*ecs.Client
にシグネチャを合わせて定義しているため、以下のように*ecs.Client
を代入することができます。
var client ECS = ecs.NewFromConfig(config)
そして、アプリケーションコード中ではaws-sdk-go
の*ecs.Client
ではなく上記ECS
インターフェースを利用します。
func DoSomething(client ECS) error {
// ...
}
このようにすることで、テスト時はスタブ実装などに切り替えることができます。
type fakeECS struct {
servicesByCluster FakeECSClusters
}
func newFakeECS(servicesByCluster FakeECSClusters) ecs.ECS {
return &fakeECS{servicesByCluster}
}
func (c *fakeECS) UpdateService(ctx context.Context, input *awsecs.UpdateServiceInput, optFns ...func(opts *awsecs.Options)) (*awsecs.UpdateServiceOutput, error) {
// ...
}
// ...
log
パッケージ
logパッケージで出力された内容をテストしたいようなケースが発生したときは、log.SetOutput
が利用できます。
以下のようにbytes.Buffer
へのポインタをSetOutput
に指定することで、テストコード中で実際にログに出力された内容を検証することができます。
out := bytes.Buffer{}
log.SetOutput(&out)
defer log.SetOutput(os.Stderr)
// ...
actual := out.String()
expected := "..."
if actual != expected {
t.Errorf("actual: \"%s\", expected: \"%s\"", actual, expected)
}
CIのセットアップ
テストコードを書いたとしてもそれが頻繁に実行されないと、テストコードのメリットが損なわれてしまいます。
CircleCIやGitHub Actionsなどを使ってCIをセットアップし、リポジトリに変更をpushしたタイミングでテストなどを実行するとよいでしょう。
具体的には、以下の実行を自動化しています。
- テストコードの実行 (
go test
) - Linterの実行 (
golangci-lint
など) - テストカバレッジの生成 (
go tool cover
)
CIをセットアップしておくことで、本番環境などにバグが含まれる機能がリリースされてしまう頻度などをかなり軽減できるため、セットアップを推奨します。
終わりに
この記事ではGoで書いたWebアプリをテストする際に、弊社で利用しているパターンなどについて解説いたしました。
少しでも参考になりましたら幸いです!
最後に、弊社ではエンジニアやデザイナーなどの職種で積極的に採用中です!
弊社チームの紹介ページがあるのでぜひ、もし興味がありましたらぜひご覧ください!
Discussion