【Go言語】gockで外部WebサービスAPI テストを書く

クラウドサイン事業本部プロダクト部でバックエンドのエンジニアをしているmoritamorieです。

ユーザーの方々により便利にサービスを使って頂けるように近年外部サービスとの連携が増えており、連携性を担保するための自動テストの重要度が高まっていると感じています。

日々の開発の中で感じているWeb APIテストのメリットであったり、外部APIをモックするGo言語のサードパーティライブラリ([gock](https://github.com/h2non/gock))の使い方を書いてみたいと思います。

 2021年12月時点でのgockの最新バージョンはv1.1.2です。

### Web APIモックテストのメリット・デメリット
gockでモックすることで実際にAPIエンドポイントにアクセスすることなくテストすることができ、以下の図のようにAPIエンドポイントの代わりに想定するレスポンスを返してくれます。

<img width="700" alt="mojikyo45_640-2.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75281/27c64a0e-c360-a698-010a-c0a6acb653cc.png">


gockを使った自動テストを作成することで以下のようなメリットがあると感じています。

- S3のような課金されるAPI EndpointをCallしなくて済む
- 実際のリクエストを送らないのでテストの実行速度向上
- APIのRate Limitを超えてテストが失敗する懸念がなくなる

逆にデメリットとしては、テスト時に実際のAPIエンドポイントにアクセスしないので、レスポンスの再現性が低くなる懸念があります。

- コードを書く際やレビューの際には、実際のレスポンスに近いものを想定してテストが書けているかを確認する
- 最新のAPIとモックコードとの差異を発生しないようにするために最新のAPIの情報を随時収集する
- 差異が発生した際はテストコードをアップデートする

といったことを意識しながら開発を行っています。

### gockの特徴
Readmeに以下のような記載があるだけあってNode.jsのHTTP Mockingライブラリである[nock](https://github.com/nock/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と微妙な違いはあります。
- ③マッチしたリクエスト回数のハンドリング方法が同じ（[Times](https://github.com/h2non/gock/blob/5b72870cdcba531fee7998aae31e18b1bcabf103/request.go#L264)で回数を指定でき、[Persist](https://github.com/h2non/gock/blob/5b72870cdcba531fee7998aae31e18b1bcabf103/request.go#L252)で回数が無制限になる）

nockと同様に様々なリクエストを送るケースのテストに対応できます。[example](https://github.com/h2non/gock/tree/5b72870cdcba531fee7998aae31e18b1bcabf103/_examples)ディレクトリに様々なケースのテストコードサンプルがあるので、ここから書きたいテストコードに近いものを探すと時短できて楽です。

### gockがどのようにモックを実現しているか
Go言語の標準`net/http`パッケージにある `DefaultTransport` を介入し、振る舞ってくれます。([参照](https://github.com/h2non/gock/blob/5b72870cdcba531fee7998aae31e18b1bcabf103/gock.go#L65))

#### http.DefaultTransportとは
[標準パッケージのコメント](https://github.com/golang/go/blob/master/src/net/http/transport.go#L38-L54)を読むと以下のように書いてあります。 

```tex
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`に介入します。

```go
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)を送っています。

```go:endpoint.go
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などの情報を返してくれます。

テストコードの後ろの方で、テスト対象の関数がエラーを返していないか、モックが期待した値を返してくれているか検証しています。

```go:endpoint_test.go
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が出力されるようになります。

```go

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`の条件にマッチしなかったことがわかります。

```sh
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` 関数を使って実装されています。([参照](https://github.com/h2non/gock/blob/5b72870cdcba531fee7998aae31e18b1bcabf103/gock.go#L25-L31))

```go
// 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` する必要がないくらいデータが大きい場合でも出力されてしまうという課題があります。


```sh
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
```

このような場合、独自で関数を定義して、デバッグに必要な情報だけを出力すれば見やすくなり、問題がある箇所を特定しやすくなると感じています。

試しにボディ部分だけを出力しなくなるようにしてみたいと思います。テストコードに以下の関数を追加します。

```go:endpoint_test.go
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`の引数を作成した関数を渡すように修正します。

```go:endpoint_test.go
func TestEndpoint(t *testing.T) {
	assert := assert.New(t)

	defer gock.Off()

	// 以下の行を追加するとgo test実行時にHTTPリクエストのdumpを出力してくれるようになる
	gock.Observe(DumpRequestWithoutBody)
```
以下のような出力結果に変わります。

```sh
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さんです！

### 参考記事
- [GoのRoundTripperとTransport](https://christina04.hatenablog.com/entry/2018/05/18/190000)
- [〔Go〕 前方互換性を保ちながらhttp.DefaultTransportからチューニングしたhttp.Transportをつくる](https://budougumi0617.github.io/2021/09/13/how_to_copy_default_transport/)