🐭

GoのWebアプリをテストするノウハウ

15 min read

メディアエンジン株式会社の田中です!

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で作成する論理的なものを指しています)を利用してしまうと、テストの結果が不安定になる原因となります。

それを避けるため、開発用に利用するデータベースとは別にテスト用のデータベースを用意してテストしています。

具体的には、Makefiletestというタスクを用意し、テスト用データベースのセットアップやマイグレーションなどを実施してからテストを実行しています。

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は各パッケージのテストを並行で実行するようで、それによる競合が問題の原因でした。

https://zenn.dev/koron/articles/ea783d5f202ef9fb68d7

解決策としては、各パッケージごとに専用のデータベース(CREATE DATABASEで作る論理的なもの)やスキーマを用意し、並行実行しても競合が起こらないようにすることが理想的だと思います。(たしかRailsとかも同じようなことをやっていたはず。。。🤔)

ただし、これについては正直まだ実現できておらず、go test -p 1でテストの実行を直列化することで問題を回避しています。。。

外部APIに依存する機能のテスト

Webアプリの開発では外部・内部問わずAPIを叩くことがあると思います。

そういった場合におけるテスト方法について3パターンほど解説します。

http.Clientをスタブする

net/httpClientを作成する際に、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程複雑なものは必要ない場合は、下記の使用を個別に検討してもよいかもしれません。

  • ElasticMQ (SQS互換のメッセージングシステム)
  • MinIO (S3互換のオブジェクトストレージ)

ただし、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アプリをテストする際に、弊社で利用しているパターンなどについて解説いたしました。

少しでも参考になりましたら幸いです!

最後に、弊社ではエンジニアやデザイナーなどの職種で積極的に採用中です!

弊社チームの紹介ページがあるのでぜひ、もし興味がありましたらぜひご覧ください!

https://mediaengine.notion.site/ba128c5708fc480198f5d8c9440a7062

参考情報

Discussion

ログインするとコメントできます