はじめに
この記事はGo (その3) Advent Calendar 2016の18日目の記事です。
最近Goの勉強がてら、とある社内向けWebサービスのAPIを叩くCLIツールを書いてます。
有名どころのWebサービスならたいてい既に誰かがSDKなりCLIツールを作ってたりするので、それを使ればよいと思いますが、クローズドな社内向けのWebサービスとかだと、まぁ欲しい人が自分で書くしかないですね。
で、簡単なプロトタイプが動くようになったので、このあたりで覚えたことを整理してまとめてアウトプットしておこうかなと思います。
内容的には
- サブコマンド形式のCLIツールを作り方
- spf13/cobraの使い方
- viperと連携した透過的な設定ファイルの読み込み
- version番号の埋め込み、Makefile
- pkg/errorsによるエラーの伝搬、contextを使ったタイムアウト
- 標準のnet/httpパッケージでAPIにHTTPリクエストを投げる
- APIのリクエスト/レスポンス型を定義してjson.Decoderでデコードする
- Unixtime型を定義してJSONエンコード/デコード関数をカスタマイズする
- APIを叩くClientを作る
- 部品をつなげてAPIを呼び出す
- 標準のnet/http/httptestパッケージでHTTPサーバをモックしてテストする
- httptestでモックサーバを作る
- Table Driven TestでAPI呼び出しをテストする
というかんじで、特に目新しい話は出てこないですが、webサービスのAPIを叩くCLIツールを作る上で必要そうなことが一通りまとまってると、これから同じようなものを作ろうかなと思ってる人の参考になるかなぁと思ってがんばって書きます。長文です。
あとまだGo初心者なので間違った変なこと書いてたら教えてください。
作るもの
この記事では、サンプルとして以下の機能を持つ hoge
コマンドを作っていきます。
hoge
コマンドは hoge stack show
サブコマンドに 引数 <AppStackID>
を指定すると、
$ hoge stack show 1
https://hoge.example.com/api/app_stacks/1
にGETリクエストを送信し、
レスポンスとして以下のようなJSONを受け取り、
{
"app_stack":{
"id":1,
"name":"foo",
"inserted_at":1481537486,
"updated_at":1481537486
}
}
これをパースして以下のように整形して出力します。
$ hoge stack show 1
id: 1, name: foo, inserted_at: 2016-12-12T19:11:26+09:00, updated_at: 2016-12-12T19:11:26+09:00
すごく単純ですが、これを実装するだけでもAPIを叩くCLIツールを作る上で必要なエッセンスがいろいろ詰まってます。
コードのサンプルは以下に置いておきました。
https://github.com/minamijoyo/api-cli-go-example
が、これだけだとAPIサーバがないので、実質モックしてあるtestぐらいしか使えません。
まぁ以下の説明だとコードの断片で分かりづらくて、コード全体を眺めたい時にでも見て下さい。
サブコマンド形式のCLIツールを作り方
CLIツールを作る上でサブコマンドのグループ化や、オプションのパースなどは自前で書いてもよいですけど、まぁありもののライブラリを使うと簡単です。いろいろ選択肢はありますが、私は個人的な好みでspf13/cobraを使っています。
cobraのよいところは、サブサブコマンドとかが作りやすかったりとか、spf13/viperと組み合わせて、設定ファイルの読み込みとコマンドラインのオプション指定での上書きを透過的に扱えることです。
main関数を作る
cobraを使う場合は、main関数は基本的に cmd.RootCmd.Execute()
に委譲するだけです。
package main
import (
"fmt"
"os"
"github.com/minamijoyo/api-cli-go-example/cmd"
)
func main() {
if err := cmd.RootCmd.Execute(); err != nil {
fmt.Printf("%+v\n", err)
os.Exit(1)
}
}
RootCmdの定義はviperと組み合わせる場合は、大体こんな感じです。
package cmd
import (
"fmt"
"net/http"
"runtime"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
var RootCmd = &cobra.Command{
Use: "hoge",
Short: "A hoge CLI written in Go.",
SilenceErrors: true,
SilenceUsage: true,
}
func init() {
cobra.OnInitialize(initConfig)
RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default $HOME/.hoge.yml)")
RootCmd.PersistentFlags().StringP("url", "", "https://hoge.example.com/api", "hoge endpoint URL")
viper.BindPFlag("url", RootCmd.PersistentFlags().Lookup("url"))
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
}
viper.SetConfigName(".hoge")
viper.AddConfigPath("$HOME")
viper.AutomaticEnv()
viper.ReadInConfig()
}
ポイントとして、以下の設定で、 エンドポイントURLのデフォルト値を設定しつつ、 --url
というオプション上書きできるようにしています。
RootCmd.PersistentFlags().StringP("url", "", "https://hoge.example.com/api", "hoge endpoint URL")
また、以下の設定により、viperの url
というキーと関連付けており、
viper.BindPFlag("url", RootCmd.PersistentFlags().Lookup("url"))
たとえば開発時は $HOME/.hoge.yml
に以下のように書いておくと、
url: http://192.168.99.100:4000/api
開発時はローカルのAPIサーバに向けておきたいというときに、毎回オプション指定しなくても、 コマンドのデフォルト値 < 設定ファイル < コマンドラインオプション
の優先順位で上書きができてコード内で参照する場合は、このように viper.GetString("url")
で入力ソースを気にせずに透過的に扱うことができます。よく使うオプションが人によって違うのでバイナリに埋め込みたくないときとかに、わりと便利です。
func newDefaultClient() (*Client, error) {
endpointURL := viper.GetString("url")
httpClient := &http.Client{}
userAgent := fmt.Sprintf("hoge/%s (%s)", Version, runtime.Version())
return newClient(endpointURL, httpClient, userAgent)
}
ここででてきた Client
型についてはあとで説明します。
またuserAgentに Version
変数を埋めてますが、 これはこのツールのバージョン番号でビルド時に変数で埋めます。
versionコマンドを作る
ついでに hoge version
サブコマンドも作っておきましょう。
cobraでは AddCommand
で親のコマンドを指定してサブコマンドを登録できます。
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var (
Version string
Revision string
)
func init() {
RootCmd.AddCommand(newVersionCmd())
}
func newVersionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "version",
Short: "Print version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("hoge version: %s, revision: %s\n", Version, Revision)
},
}
return cmd
}
Version
にはv0.0.1のようなツールのバージョン番号 Revision
にはgitコミットのハッシュ値をビルド時に -ldflags
オプションを指定して埋めます。ちなみに今回のようにmainパッケージ以外の変数を指定する場合は、以下のようにパッケージパスを明示的に指定しないと埋め込んでくれないようなので注意。
$ go build -ldflags "-X github.com/minamijoyo/api-cli-go-example/cmd.Version=v0.0.1 -X github.com/minamijoyo/api-cli-go-example/cmd.Revision=2463e27" -o bin/hoge
$ bin/hoge version
hoge version: v0.0.1, revision: 2463e27
Makefile
毎度引数指定が面倒くさいので、ビルドスクリプトを書きたくなりますが、GoはC言語の文化圏のようで、この手のプロジェクトのタスク管理ツールとしてはmakeがよく使われています。というわけで、ここらでMakefileも作っておきます。
ついでにgoxでクロスビルドして、ghrでリリースするタスクやらlint/vet/testなど入りそうなタスクも一式準備しておく。このへんはまだまだ試行錯誤中である。
まだ悩み中の依存ライブラリの解決はいろいろ流儀があるようで、vendor配下に全部入れちゃうとか、godeps/glide/gomでバージョン管理するなど、まだ決め兼ねているのだけど、しばらく困るまでまだグローバルにまとめて入れてます。そのうちなんか考える。
NAME := hoge
VERSION := v0.0.1
REVISION := $(shell git rev-parse --short HEAD)
LDFLAGS := "-X github.com/minamijoyo/api-cli-go-example/cmd.Version=${VERSION} -X github.com/minamijoyo/api-cli-go-example/cmd.Revision=${REVISION}"
OSARCH := "darwin/amd64 linux/amd64"
GITHUB_USER := minamijoyo
ifndef GOBIN
GOBIN := $(shell echo "$${GOPATH%%:*}/bin")
endif
LINT := $(GOBIN)/golint
GOX := $(GOBIN)/gox
ARCHIVER := $(GOBIN)/archiver
GHR := $(GOBIN)/ghr
$(LINT): ; @go get github.com/golang/lint/golint
$(GOX): ; @go get github.com/mitchellh/gox
$(ARCHIVER): ; @go get github.com/mholt/archiver/cmd/archiver
$(GHR): ; @go get github.com/tcnksm/ghr
.DEFAULT_GOAL := build
.PHONY: deps
deps:
go get -d -v .
.PHONY: build
build: deps
go build -ldflags $(LDFLAGS) -o bin/$(NAME)
.PHONY: install
install: deps
go install -ldflags $(LDFLAGS)
.PHONY: cross-build
cross-build: deps $(GOX)
rm -rf ./out && \
gox -ldflags $(LDFLAGS) -osarch $(OSARCH) -output "./out/${NAME}_${VERSION}_{{.OS}}_{{.Arch}}/{{.Dir}}"
.PHONY: package
package: cross-build $(ARCHIVER)
rm -rf ./pkg && mkdir ./pkg && \
pushd out && \
find * -type d -exec archiver make ../pkg/{}.tar.gz {}/$(NAME) \; && \
popd
.PHONY: release
release: $(GHR)
ghr -u $(GITHUB_USER) $(VERSION) pkg/
.PHONY: lint
lint: $(LINT)
@golint ./...
.PHONY: vet
vet:
@go vet ./...
.PHONY: test
test:
@go test ./...
.PHONY: check
check: lint vet test build
上記のMakefileだとビルドやパッケージング時に以下のディレクトリにファイルができちゃうので、よしなに.gitignoreしておく。
# Build
bin/*
out/*
pkg/*
stackコマンドグループを作る
stackコマンドを作る。これ自体はコマンドグループであって、機能はなく、 AddCommandでRootCmdに stack
コマンドを登録しつつ、 自分に stack show
サブコマンドを登録する。あと hoge stack
だけ呼ばれたら cmd.Help()
でヘルプでも出しておく。
package cmd
import (
"context"
"fmt"
"strconv"
"time"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
func init() {
RootCmd.AddCommand(newStackCmd())
}
func newStackCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "stack",
Short: "Manage Stack resources",
Run: func(cmd *cobra.Command, args []string) {
cmd.Help()
},
}
cmd.AddCommand(
newStackShowCmd(),
)
return cmd
}
stack showサブコマンドを作る
本題のstack showサブコマンドを作る。 RunE
で runStackShowCmd
コマンドを登録して、これが処理の実体。
ちなみに Run関数を登録するときに Run
じゃなくて RunE
にしておかないとerrorが返せないので注意。
func newStackShowCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "show <StackID>",
Short: "Show Stack",
RunE: runStackShowCmd,
}
return cmd
}
ようやく処理の実体っぽいところになってきた。
func runStackShowCmd(cmd *cobra.Command, args []string) error {
client, err := newDefaultClient()
if err != nil {
return errors.Wrap(err, "newClient failed:")
}
if len(args) == 0 {
return errors.New("StackID is required")
}
appStackID, err := strconv.Atoi(args[0])
if err != nil {
return errors.Wrapf(err, "failed to parse StackID: %s", args[0])
}
req := AppStackShowRequest{
ID: appStackID,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := client.StackShow(ctx, req)
if err != nil {
return errors.Wrapf(err, "StackShow was failed: req = %+v, res = %+v", req, res)
}
appStack := res.AppStack
fmt.Printf(
"id: %d, name: %s, inserted_at: %v, updated_at: %v\n",
appStack.ID, appStack.Name, appStack.InsertedAt, appStack.UpdatedAt,
)
return nil
}
やってることを順に説明していく。
client, err := newDefaultClient()
if err != nil {
return errors.Wrap(err, "newClient failed:")
}
まず、 newDefaultClient()
で clientを作る。clientについては後述。
if len(args) == 0 {
return errors.New("StackID is required")
}
appStackID, err := strconv.Atoi(args[0])
if err != nil {
return errors.Wrapf(err, "failed to parse StackID: %s", args[0])
}
req := AppStackShowRequest{
ID: appStackID,
}
引数チェックをしてから、引数をパースして、 AppStackShowRequest
を生成。この型についても後述。
ちなみに、errors.Wrapfとかやってるのは、受け取ったエラーに注釈を付けつつ、呼び出し元にエラーを伝搬させるための技法で、単純に低レイヤで発生したエラーを返すだけだと、ライブラリ呼び出し内で起きたエラーなどは何が起きたのか分からないことが多いので、各レイヤで、何の処理が失敗したのか付加情報を付けて、上位のレイヤに返すことでエラーメッセージの意味が分かりやすくなる。このへんはGolangのエラー処理とpkg/errorsあたりも合わせて読むとよいかと。
次に、これがAPI呼び出し処理部分にあたる。
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := client.StackShow(ctx, req)
if err != nil {
return errors.Wrapf(err, "StackShow was failed: req = %+v, res = %+v", req, res)
}
10秒でタイムアウトするコンテキストを作って、 client.StackShow
を呼び出す。ネットワーク呼び出しをするコマンドはAPIサーバと通信できない可能性があるので、このようなタイムアウト処理を仕込んでおくことは有用である。
コンテキストの説明についてはGo1.7のcontextパッケージが分かりやすい。こーゆータイムアウト処理みたいなのがスマートに書けるのはGoのよいところですね。
appStack := res.AppStack
fmt.Printf(
"id: %d, name: %s, inserted_at: %v, updated_at: %v\n",
appStack.ID, appStack.Name, appStack.InsertedAt, appStack.UpdatedAt,
)
return nil
最後に client.StackShow
の戻り値で、型はここでは明示されていないが *AppStackShowResponse
から値を取り出して、表示して終了する。ここでは直接 fmt.Printf
を読んじゃってるけど、標準入力も差し替えられるようにしておいた方がよいかもしれない。
標準のnet/httpパッケージでAPIにHTTPリクエストを投げる
CLIとしての呼び出しの仕組み自体は以上で、ここからAPI呼び出しの実装を作っていく。
HTTPリクエストを投げるところを書く前に、まずはリクエストとレスポンスの型を定義しておこう。
もうそろそろ忘れてそうなので、参考までにパースしたかったレスポンスのJSONを再掲しておく
{
"app_stack":{
"id":1,
"name":"foo",
"inserted_at":1481537486,
"updated_at":1481537486
}
}
APIのリクエスト/レスポンス型を定義してjson.Decoderでデコードする
APIのリクエストとレスポンスの型を定義しておくと、素のJSONを扱うよりも間違いが少なくてよいし、ドキュメンテーションにも役に立つ。
ポイントはstructを宣言する時にjsonのフィールドタグを付けておくことで、json.Decoderすると自動でstructのフィールドにマッピングできるようにしている。
type AppStack struct {
ID int `json:"id"`
Name string `json:"name"`
InsertedAt Unixtime `json:"inserted_at"`
UpdatedAt Unixtime `json:"updated_at"`
}
type AppStackShowRequest struct {
ID int `json:"id"`
}
type AppStackShowResponse struct {
AppStack AppStack `json:"app_stack"`
}
あと、structの各フィールドは大文字で初めてexportしておかないと json.Decoder
でデコードしてくれない罠があって、ハマってすごい悩んだ。
この辺も勢いでcmdパッケージ配下にまとめて書いちゃったけど、別のパッケージに分けた方がよかったような気もする。このAPIをCLIではなくて、さらに別のプログラムからSDKとして呼び出したいみたいな用途は今のところないので特に困ってはいないんだけど。
Unixtime型を定義してエンコード/デコード関数をカスタマイズする
ところでこのAPIでは、タイムスタンプが 1481537486
みたいなUnixtimeで返ってくるので、これをそのまま表示してもヒューマンリーダブルではないし、できれば time.Time
に変換して表示フォーマットなどを扱いやすいようにしておきたい。標準でUnixtimeを表す型はなさそうだったので、 独自にtime.Time
を包んだだけの Unixtime
という型を定義した。このように自分で定義した型をJSONエンコド/デコードできるようにするには MarshalJSON
と UnmarshalJSON
を独自に定義しておくとカスタマイズした処理を挟み込める。
package cmd
import (
"fmt"
"strconv"
"time"
)
type Unixtime struct {
time.Time
}
func IntToUnixtime(timestamp int) Unixtime {
return Unixtime{time.Unix(int64(timestamp), 0)}
}
func (t *Unixtime) MarshalJSON() ([]byte, error) {
timestamp := fmt.Sprint(t.Unix())
return []byte(timestamp), nil
}
func (t *Unixtime) UnmarshalJSON(b []byte) error {
timestamp, err := strconv.Atoi(string(b))
if err != nil {
return err
}
t.Time = time.Unix(int64(timestamp), 0)
return nil
}
func (t Unixtime) String() string {
return t.Format(time.RFC3339)
}
ちなみにこのUnixtimeのデコード方法は、
以下のGoogleグループのスレッド内で紹介されていた
unmarshal unix time from a json stream is of different format
以下のサンプルコードを参考した。
https://play.golang.org/p/WltVTKxylT
APIを叩くClientを作る
次に、Clientの型を定義して、コンストラクタやら、HTTPリクエストを生成したり、レスポンスのJSONをデコードしたりする共通のヘルパ関数を作っていく。
このあたりは、以下の記事をいろいろ参考にしつつ書きました。
GolangでAPI Clientを実装する
というかほぼ上記のサンプルコードからの劣化コピーなんだけど、まじdeeeetさんのブログお世話になりすぎて、ありがたい。
最初にClientの型を定義しておく。
package cmd
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"path"
"github.com/pkg/errors"
)
type Client struct {
EndpointURL *url.URL
HTTPClient *http.Client
UserAgent string
}
次にコンストラクタを作っておく。
変数を外から差し込めるようにしてるのは、あとでダミーのMockサーバを立ててテストするため。
func newClient(endpointURL string, httpClient *http.Client, userAgent string) (*Client, error) {
parsedURL, err := url.ParseRequestURI(endpointURL)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse url: %s", endpointURL)
}
client := &Client{
EndpointURL: parsedURL,
HTTPClient: httpClient,
UserAgent: userAgent,
}
return client, nil
}
HTTPリクエスト生成用のヘルパはURLを組み立てて、HTTPヘッダなどを設定する。 req.WithContext(ctx)
のところで、呼び出し元からもらったコンテキストをHTTPリクエストに引き回しているのがポイント。
func (client *Client) newRequest(ctx context.Context, method string, subPath string, body io.Reader) (*http.Request, error) {
endpointURL := *client.EndpointURL
endpointURL.Path = path.Join(client.EndpointURL.Path, subPath)
req, err := http.NewRequest(method, endpointURL.String(), body)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", client.UserAgent)
return req, nil
}
レスポンスのデコード用のヘルパメソッドも定義しておく。 decoder.Decode
のところでJSONのレスポンスをstructにマップしてくれる、
func decodeBody(resp *http.Response, out interface{}) error {
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
return decoder.Decode(out)
}
型が interface{}
なのにいいかんじにマップしてくれるのは黒魔術っぽい予感がするけど、たぶん中でreflectを使って型定義を参照していいかんじにゴニョゴニョしてくれてるのだろう。
部品をつなげてAPIを呼び出す
部品は揃ったので、ついにAPIを呼び出そう。
入力に AppStackShowRequest
型をもらって、適切なエンドポイントのURLを組み立て、HTTPリクエストを送り、レスポンスのJSONをデコードして、 *AppStackShowResponse
にセットして返す。
func (client *Client) StackShow(ctx context.Context, apiRequest AppStackShowRequest) (*AppStackShowResponse, error) {
subPath := fmt.Sprintf("/app_stacks/%d", apiRequest.ID)
httpRequest, err := client.newRequest(ctx, "GET", subPath, nil)
if err != nil {
return nil, err
}
httpResponse, err := client.HTTPClient.Do(httpRequest)
if err != nil {
return nil, err
}
var apiResponse AppStackShowResponse
if err := decodeBody(httpResponse, &apiResponse); err != nil {
return nil, err
}
return &apiResponse, nil
}
client.HTTPClient.Do(httpRequest)
のところが、実際にHTTPリクエストを投げてるところ。部品が揃っているとここでやることはつなぎあわせるだけであんまりない。そういえば、HTTPステータスコードのハンドリングもした方がよいかもしれない。
なんにせよ、これで、 hoge stack show
コマンドからのAPI呼び出しがついに完成した。
こんなかんじで、あとは必要なAPIに対応するリクエスト/レスポンスの型を定義して、それを適切なエンドポイントに投げるサブコマンドを順次で生やしていけば、よいかんじである。
標準のnet/http/httptestパッケージでHTTPサーバをモックしてテストする
CLIツールの機能としてはできたので、めでたしめでたし以上終了でもよいのだけど、メンテナンスを考えると早い段階でテストも書いておきたい。
Goでは標準のnet/http/httptestパッケージでHTTPサーバを簡単にモックできるので、これを使う。
httptestでモックサーバを作る
モックサーバとテスト用のClientは複数のAPIで使いまわすので、さきにヘルパ関数を作っておく。
package cmd
import (
"net/http"
"net/http/httptest"
"net/url"
)
func newMockServer() (*http.ServeMux, *url.URL) {
mux := http.NewServeMux()
server := httptest.NewServer(mux)
mockServerURL, _ := url.Parse(server.URL)
return mux, mockServerURL
}
func newTestClient(mockServerURL *url.URL) *Client {
endpointURL := mockServerURL.String() + "/api"
httpClient := &http.Client{}
userAgent := "test client"
client, _ := newClient(endpointURL, httpClient, userAgent)
return client
}
やってることは httptest.NewServer
でモックサーバを立てて、そのURLをテスト用のクライアント渡してリクエスト先をモックサーバに向けられるように準備しておく。
mux
を返しているのは、これはあとで、APIごとのエンドポイントのURLを登録するのに使う。
Table Driven TestでAPI呼び出しをテストする
Goでテストを書く場合は、 *_test.go
というファイルに Test〜(t *testing.T)
という関数を定義する。
また、入力と期待値を列挙したテストケース表を定義してループで回してテストするTable Driven Testという手法がよく使われている。
というわけでテストケース表を定義する。ここでは、入力として AppStackShowRequest
と HTTPレスポンスBodyのstringを入力として、期待した *AppStackShowResponse
が返ってくるかを確認したいので、こんなかんじにしてみた。
package cmd
import (
"context"
"fmt"
"net/http"
"reflect"
"testing"
)
func TestStackShow(t *testing.T) {
cases := []struct {
req AppStackShowRequest
res string
want *AppStackShowResponse
}{
{
req: AppStackShowRequest{
ID: 1,
},
res: `{
"app_stack":{
"id":1,
"name":"pretty",
"inserted_at":1234567890,
"updated_at":1481537486
}
}`,
want: &AppStackShowResponse{
AppStack: AppStack{
ID: 1,
Name: "pretty",
InsertedAt: IntToUnixtime(1234567890),
UpdatedAt: IntToUnixtime(1481537486),
},
},
},
{
req: AppStackShowRequest{
ID: 2,
},
res: `{"app_stack":{"updated_at":1481953207,"name":"compact_unordered","inserted_at":1234567890,"id":2}}`,
want: &AppStackShowResponse{
AppStack: AppStack{
ID: 2,
Name: "compact_unordered",
InsertedAt: IntToUnixtime(1234567890),
UpdatedAt: IntToUnixtime(1481953207),
},
},
},
}
テストケースのバリエーションはいろいろ考えられるけど、ここではとりあえず、ちゃんと整形されているパターンと、改行なしで並び順も異なるパターンの2パターン作っておく。
ケース表が準備できたら、モックサーバとクライアントを初期化して、テストケースごとに、 HandleFunc
でAPIのエンドポイントに合わせて、固定のレスポンスを返すように仕込み、 client.StackShow
を呼び出して期待値と結果を比較する。
mux, mockServerURL := newMockServer()
client := newTestClient(mockServerURL)
for _, tc := range cases {
hundlePath := fmt.Sprintf("/api/app_stacks/%d", tc.req.ID)
mux.HandleFunc(hundlePath, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, tc.res)
})
got, err := client.StackShow(context.Background(), tc.req)
if err != nil {
t.Fatalf("StackShow was failed: req = %+v, got = %+v, err = %+v", tc.req, got, err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("got=%+v, want=%+v", got, tc.want)
}
}
}
structのような構造を持ったものを一括で比較するのは reflect.DeepEqual
を使うと各フィールドを再帰的に比較してくれるので、手抜きして比較はDeepEqualに丸投げしている。
まぁこんなかんじで対応するAPIを増やしたら、それに合わせてテストも入出力を並べるだけで、同じパターンで増やしていけるかんじになった。よさげ。
おわりに
GolangでWebサービスのAPIを叩くCLIツールを作ってみた。
作ったものは非常に単純なものなんだけど、必要なエッセンスがいろいろ詰まっていて、学ぶことは多いですね。
この記事がこれから同じようなものを作ろうかなと思ってる人の参考になれば幸いです。