$shibayu36->blog;

株式会社アンドパッドでエンジニアをしています。プログラミングや読書のことなどについて書いています。

golangでAPIなど外部にアクセスするロジックのテストをする

golangで、例えばGithubAPIを叩くような、特定のAPIにアクセスするロジックを書いた時、何も考えずにテストを書くと、テストを実行する際にもそのまま外部のAPIにアクセスしてしまう。この場合、色んなパターンのテストを書きづらい、依存している外部サービスが落ちたらテストも一緒に落ちるなどの問題が起こる。

このような問題から、統合テストではなくユニットテストのときは手元のみで完結して、外部サービスに依存しない状況でテストを書きたくなることがある。そこで今回は外部にアクセスするロジックを、手元で完結させた状態でテストする方法を試したので、その方法について書いてみる。

テストしたいコード

例えば以下のようなコード。Githubhttps://github.com/shibayu36/shibayu36 の最新のリリースタグを取得し、そのリリースタグ名を出力する。これはGithubReleases APIにアクセスしている。

package main

import (
package main

import (
	"context"
	"fmt"

	"github.com/google/go-github/github"
)

func main() {
	ctx := context.Background()
	client := github.NewClient(nil)
	release, _, _ := client.Repositories.GetLatestRelease(ctx, "shibayu36", "shibayu36")
	fmt.Println(release.GetTagName())
}

テストを書くための作戦

上記のような実装をそのままテストしようとすると、

  • テスト中にGithubAPIにアクセスしてしまう
  • Github上に様々なデータを作らないと、色んなパターンのテストが出来ない

という問題がある。

そこで以下のような作戦でテストを書けるようにする。

  • 実装はstructのメソッドとして定義し、APIのベースのURLを切り替え可能にする
  • テスト中はhttp.NewServeMux() + httptestを利用し、テスト用のサーバを立てる

ではこの二点をそれぞれ対応してみる。

実装はstructのメソッドとして定義し、APIのベースのURLを切り替え可能にする

まず先程のコードをテストしやすいように、「GithubAPIにアクセスし最新のリリースタグ名を取得する」という部分をメソッド化する。そのメソッドをmain関数で呼んで、リリースタグ名を出力する。

package main

import (
	"context"
	"fmt"
	"net/url"

	"github.com/google/go-github/github"
)

type myClient struct {
	apiGithubURL string
}

// ownerとrepoから最新のリリースタグ名を取得するメソッド
func (c *myClient) GetLatestReleaseTagName(owner, repo string) (string, error) {
	ctx := context.Background()
	client := github.NewClient(nil)

	// Github APIのベースURLを切り替えられるようにしておく
	client.BaseURL = c.getAPIGithubURL()

	release, _, err := client.Repositories.GetLatestRelease(ctx, owner, repo)
	if err != nil {
		return "", err
	}
	return release.GetTagName(), nil
}

// go-githubのクライアントのClient.BaseURLに渡せるURLオブジェクト作成
// myClientインスタンス作成時にapiGithubURLを明示的に指定したら、
// それを利用するように。そうでなければデフォルトのURLを使う。
func (c *myClient) getAPIGithubURL() *url.URL {
	u := "https://api.github.com"
	if c.apiGithubURL != "" {
		u = c.apiGithubURL
	}
	apiURL, _ := url.Parse(u + "/")
	return apiURL
}

func main() {
	tagName, err := (&myClient{}).GetLatestReleaseTagName("shibayu36", "shibayu36")
	fmt.Println(tagName, err)
}

この部分でのポイントは

  • github.com/google/go-github/github で作ったclientはclient.BaseURLにURLオブジェクトを与えることでAPIのベースURLを切り替えることができる
  • githubAPIを叩く時に、myClientのapiGithubURLフィールドが指定されていたらそちらに、そうでなければ普通にapi.github.comを利用するようにclient.BaseURLを指定する
    • client.BaseURLにそのまま渡せるオブジェクトを作るgetAPIGithubURLメソッドを作っておくと便利

これで、&myClient{}でオブジェクトを作ったら普通にGithubにアクセスし、&myClient{apiGithubURL: "http://api.example.com"}でオブジェクトを作っておくと、http://api.example.com にアクセスするような実装になった。

http.NewServeMux() + httptestを利用し、テスト用のサーバを立てる

実装でアクセス先を切り替えられるようになった。あとはテスト中にダミーのサーバを立てて、アクセス先をそちらに切り替えてテストすれば良い。以下のとおり。これで外部にアクセスせずに、APIなどを使うテストをかくことが出来た。

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestGetLatestReleaseTagName(t *testing.T) {
	// NewServeMuxを使えば、特定のPATHでレスポンスを返すような
	// アプリケーションを簡単に書けるので、
	// shibayu36/sample-repoの最新リリースAPIを模倣する。
	mux := http.NewServeMux()
	mux.HandleFunc("/repos/shibayu36/sample-repo/releases/latest", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, `{"tag_name": "0.0.1"}`)
	})

	// テスト用に作ったアプリケーションでテストサーバを立てる
	apiGithubServer := httptest.NewServer(mux)
	defer apiGithubServer.Close()

	// APIアクセスを立てたテストサーバに向けてmyClientオブジェクトを作成
	c := &myClient{
		apiGithubURL: apiGithubServer.URL,
	}

	{
		// 成功パターンのテスト
		tagName, err := c.GetLatestReleaseTagName("shibayu36", "sample-repo")
		assert.NoError(t, err)
		assert.Equal(t, "0.0.1", tagName, "shibayu36/sample-repoのタグが取れている")
	}

	{
		// 失敗パターン(レポジトリがない)のテスト
		_, err := c.GetLatestReleaseTagName("shibayu36", "wrong-repo")
		assert.Error(t, err, "存在しないrepoなのでエラー")
	}
}

まとめ

今回はgolangで外部にアクセスするロジックを手元で完結させた状態でテストする方法について書いてみた。

テスト用のダミーサーバを立てるには、http.NewServeMux() + httptestが非常に便利だった。golangの場合、テスト時にgithub.Client.Repositories.GetLatestReleaseを置き換えるみたいなことが出来ないため、実装部分でうまく置き換えられるようにしておくのが少し難しかった。

ちなみにmkrのプラグインインストーラも今回の作戦でテストをしているので参考にどうぞ。