8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

弁護士ドットコムAdvent Calendar 2021

Day 11

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

Last updated at Posted at 2021-12-10

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

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

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

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

Web APIモックテストのメリット・デメリット

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

mojikyo45_640-2.gif

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と微妙な違いはあります。
  • ③マッチしたリクエスト回数のハンドリング方法が同じ(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.

DefaultTransportTransport のデフォルト実装で、http.Client構造体の Transport を指定せずにGETやPOSTといったHTTPリクエストを送る場合などに使われます。(http.Gethttp.Post などからも暗黙的に使われています。)

注意

なので、同一ホストの最大コネクション数( MaxIdleConnsPerHost )を指定して Transport 構造体を生成するなどの場合、テスト実行時にgockが介入せずに実際のリクエストを送ってしまうので注意が必要です。

このような場合gockでは gock.InterceptClient を使って、引数で指定した clientTransportに介入します。

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

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

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

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


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

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

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

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

endpoint_test.go
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さんです!

参考記事

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?