クラウドサイン事業本部プロダクト部でバックエンドのエンジニアをしているmoritamorieです。
ユーザーの方々により便利にサービスを使って頂けるように近年外部サービスとの連携が増えており、連携性を担保するための自動テストの重要度が高まっていると感じています。
日々の開発の中で感じているWeb APIテストのメリットであったり、外部APIをモックするGo言語のサードパーティライブラリ(gock)の使い方を書いてみたいと思います。
2021年12月時点でのgockの最新バージョンはv1.1.2です。
Web APIモックテストのメリット・デメリット
gockでモックすることで実際にAPIエンドポイントにアクセスすることなくテストすることができ、以下の図のようにAPIエンドポイントの代わりに想定するレスポンスを返してくれます。
gockを使った自動テストを作成することで以下のようなメリットがあると感じています。
- S3のような課金されるAPI EndpointをCallしなくて済む
- 実際のリクエストを送らないのでテストの実行速度向上
- APIのRate Limitを超えてテストが失敗する懸念がなくなる
逆にデメリットとしては、テスト時に実際のAPIエンドポイントにアクセスしないので、レスポンスの再現性が低くなる懸念があります。
- コードを書く際やレビューの際には、実際のレスポンスに近いものを想定してテストが書けているかを確認する
- 最新のAPIとモックコードとの差異を発生しないようにするために最新のAPIの情報を随時収集する
- 差異が発生した際はテストコードをアップデートする
といったことを意識しながら開発を行っています。
gockの特徴
Readmeに以下のような記載があるだけあってNode.jsのHTTP Mockingライブラリであるnockの使用感に非常に近いと感じています。nockを使ったことがある方であればスムーズに導入できるかと思います。
Heavily inspired by nock. There is also its Python port, pook.
具体的には以下のような特徴があります。
- ①モックの設定をチェインして書ける(例:
gock.New("https://host-name.com").Delay(3).Reply(200)
) - ②関数名がほとんど一緒
- リクエスト回数をハンドリングするいくつかの関数のうちgockにはない関数(
once()
やtwice()
)があったり、intercept
が違う用途の関数名で使われていたりとnockと微妙な違いはあります。
- リクエスト回数をハンドリングするいくつかの関数のうちgockにはない関数(
- ③マッチしたリクエスト回数のハンドリング方法が同じ(Timesで回数を指定でき、Persistで回数が無制限になる)
nockと同様に様々なリクエストを送るケースのテストに対応できます。exampleディレクトリに様々なケースのテストコードサンプルがあるので、ここから書きたいテストコードに近いものを探すと時短できて楽です。
gockがどのようにモックを実現しているか
Go言語の標準net/http
パッケージにある DefaultTransport
を介入し、振る舞ってくれます。(参照)
http.DefaultTransportとは
標準パッケージのコメントを読むと以下のように書いてあります。
DefaultTransport is the default implementation of Transport and is
used by DefaultClient. It establishes network connections as needed
and caches them for reuse by subsequent calls. It uses HTTP proxies
as directed by the $HTTP_PROXY and $NO_PROXY (or $http_proxy and
$no_proxy) environment variables.
DefaultTransport
は Transport
のデフォルト実装で、http.Client
構造体の Transport
を指定せずにGETやPOSTといったHTTPリクエストを送る場合などに使われます。(http.Get
や http.Post
などからも暗黙的に使われています。)
注意
なので、同一ホストの最大コネクション数( MaxIdleConnsPerHost
)を指定して Transport
構造体を生成するなどの場合、テスト実行時にgockが介入せずに実際のリクエストを送ってしまうので注意が必要です。
このような場合gockでは gock.InterceptClient
を使って、引数で指定した client
のTransport
に介入します。
client := &http.Client{Transport: &http.Transport{}}
gock.InterceptClient(client)
gockを使ってWebサービスAPIのテストを書いてみる
Auth0のDiscovery Endpointから情報を取得するコードのテストを書いてみたいと思います。
以下のテスト対象コードの oidc.NewProvider
の箇所で外部API(ホスト: dev-xxxxxxxx.jp.auth0.com
) にHTTPリクエスト(GET)を送っています。
package main
import (
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"context"
)
func Endpoint() (*oauth2.Endpoint, error) {
provider, err := oidc.NewProvider(context.Background(),
"https://dev-xxxxxxxx.jp.auth0.com/")
if err != nil {
return nil, err
}
endpoint := provider.Endpoint()
return &endpoint, nil
}
以下のようなテストコードを書くことで実際に外部サービス(dev-xxxxxxxx.jp.auth0.com
)にリクエストを送らずに、mockがissuerやauthorization_endpointなどの情報を返してくれます。
テストコードの後ろの方で、テスト対象の関数がエラーを返していないか、モックが期待した値を返してくれているか検証しています。
package main
import (
"testing"
"gopkg.in/h2non/gock.v1"
"github.com/stretchr/testify/assert"
)
func TestEndpoint(t *testing.T) {
assert := assert.New(t)
defer gock.Off()
gock.New("https://dev-xxxxxxxx.jp.auth0.com").
Get("/.well-known/openid-configuration").
Reply(200).
JSON(map[string]interface{}{
"issuer":
"https://dev-xxxxxxxx.jp.auth0.com/",
"authorization_endpoint":
"https://dev-xxxxxxxx.jp.auth0.com/authorize",
"token_endpoint":
"https://dev-xxxxxxxx.jp.auth0.com/oauth/token",
"id_token_signing_alg_values_supported":
[]string{"RS256"},
}).
SetHeaders(map[string]string{"Content-Type": "application/json"})
endpoint, err := Endpoint()
assert.NoError(err)
assert.NotEmpty(endpoint)
assert.Equal(
"https://dev-xxxxxxxx.jp.auth0.com/authorize",
endpoint.AuthURL)
assert.Equal(
"https://dev-xxxxxxxx.jp.auth0.com/oauth/token",
endpoint.TokenURL)
}
トラブルシューティング
以下のようにgock.Observe
を使うとデバッグできるようになり、実際のHTTPリクエストのdumpが出力されるようになります。
func TestEndpoint(t *testing.T) {
assert := assert.New(t)
defer gock.Off()
// 以下の行を追加するとgo test実行時にHTTPリクエストのdumpを出力してくれるようになる
gock.Observe(gock.DumpRequest)
gock.New("https://dev-xxxxxxxx.jp.auth0.com").
Get("/.well-known/wrong-openid-configuration").
Reply(200).
JSON(map[string]interface{}{
"issuer":
"https://dev-xxxxxxxx.jp.auth0.com/",
"authorization_endpoint":
"https://dev-xxxxxxxx.jp.auth0.com/authorize",
"token_endpoint":
"https://dev-xxxxxxxx.jp.auth0.com/oauth/token",
"id_token_signing_alg_values_supported":
[]string{"RS256"},
}).
SetHeaders(map[string]string{"Content-Type": "application/json"})
出力された結果を見ると、ホストdev-xxxxxxxx.jp.auth0.com
へのGETリクエストがあり、gock
で作ったmock
の条件にマッチしなかったことがわかります。
GET /.well-known/openid-configuration HTTP/1.1
Host: dev-xxxxxxxx.jp.auth0.com
User-Agent: Go-http-client/1.1
Accept-Encoding: gzip
Matches: false
上記の mock
のコードではURLのパスが /.well-known/wrong-openid-configuration
になっているため実際のリクエストのパスと一致しておらず、マッチしなくなっています。
より便利にデバッグするために
gock.DumpRequest
は以下のようにGo標準 net/http/httputil
パッケージの DumpRequestOut
関数を使って実装されています。(参照)
// DumpRequest is a default implementation of ObserverFunc that dumps
// the HTTP/1.x wire representation of the http request
var DumpRequest ObserverFunc = func(request *http.Request, mock Mock) {
bytes, _ := httputil.DumpRequestOut(request, true)
fmt.Println(string(bytes))
fmt.Printf("\nMatches: %v\n---\n", mock != nil)
}
httputil
パッケージの DumpRequestOut
関数の2つ目の引数で dump
の内容にHTTPのボディ部を含むように指定しているため、ボディ部全体を dump
する必要がないくらいデータが大きい場合でも出力されてしまうという課題があります。
POST /upload HTTP/1.1
Host: example.com
User-Agent: Go-http-client/1.1
Content-Length: 181
Content-Type: application/json
Accept-Encoding: gzip
{"Param0":"Param0","Param1":"Param1","Param2":"Param2","Param3":"Param3",
"Param4":"Param4","Param5":"Param5","Param6":"Param6","Param7":"Param7",
"Param8":"Param8","Param9":"Param9"}
Matches: false
このような場合、独自で関数を定義して、デバッグに必要な情報だけを出力すれば見やすくなり、問題がある箇所を特定しやすくなると感じています。
試しにボディ部分だけを出力しなくなるようにしてみたいと思います。テストコードに以下の関数を追加します。
var DumpRequestWithoutBody ObserverFunc = func(request *http.Request, mock Mock) {
bytes, _ := httputil.DumpRequestOut(request,false) // ボディ部は表示されなくなる
fmt.Println(string(bytes))
fmt.Printf("\nmockの条件とマッチしたかどうか: %v\n---\n", mock != nil)
}
以下のようにテストコードを修正し、gock.Observe
の引数を作成した関数を渡すように修正します。
func TestEndpoint(t *testing.T) {
assert := assert.New(t)
defer gock.Off()
// 以下の行を追加するとgo test実行時にHTTPリクエストのdumpを出力してくれるようになる
gock.Observe(DumpRequestWithoutBody)
以下のような出力結果に変わります。
POST /upload HTTP/1.1
Host: example.com
User-Agent: Go-http-client/1.1
Content-Length: 181
Content-Type: application/json
Accept-Encoding: gzip
mockの条件とマッチしたかどうか: false
後書き
Go言語はテストツールも標準パッケージに含まれており、単体テストを書くハードルも低くしっかりとテストを書いているプロジェクトも多いのではないでしょうか。しかし、サービスの規模が大きくなると外部システムとの連携部分が増えより複雑なシステムになっていき、標準パッケージでは足りなくなる部分がでてくるかと思います。
そのような時にgockを思い出していただき、品質の高いシステムにするためにテストコードを書いてみてはいかがでしょうか。
今後も外部連携サービスはどんどん増えていくと思いますが、外部サービスとの連携機能のテストを自動化し、高い品質でクラウドサインをご利用頂けるように努めていきたいです。
明日は@ubonsaさんです!