Help us understand the problem. What is going on with this article?

GolangでwebサービスのAPIを叩くCLIツールを作ろう

More than 1 year has passed since last update.

はじめに

この記事は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() に委譲するだけです。

main.go
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と組み合わせる場合は、大体こんな感じです。

cmd/root.go
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 に以下のように書いておくと、

$HOME/.hoge.yml
url: http://192.168.99.100:4000/api

開発時はローカルのAPIサーバに向けておきたいというときに、毎回オプション指定しなくても、 コマンドのデフォルト値 < 設定ファイル < コマンドラインオプション の優先順位で上書きができてコード内で参照する場合は、このように viper.GetString("url") で入力ソースを気にせずに透過的に扱うことができます。よく使うオプションが人によって違うのでバイナリに埋め込みたくないときとかに、わりと便利です。

cmd/root.go
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 で親のコマンドを指定してサブコマンドを登録できます。

cmd/version.go
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() でヘルプでも出しておく。

cmd/stack.go
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サブコマンドを作る。 RunErunStackShowCmd コマンドを登録して、これが処理の実体。
ちなみに Run関数を登録するときに Run じゃなくて RunE にしておかないとerrorが返せないので注意。

cmd/stack.go
func newStackShowCmd() *cobra.Command {
    cmd := &cobra.Command{
        Use:   "show <StackID>",
        Short: "Show Stack",
        RunE:  runStackShowCmd,
    }

    return cmd
}

ようやく処理の実体っぽいところになってきた。

cmd/stack.go
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
}

やってることを順に説明していく。

cmd/stack.go
    client, err := newDefaultClient()
    if err != nil {
        return errors.Wrap(err, "newClient failed:")
    }

まず、 newDefaultClient() で clientを作る。clientについては後述。

cmd/stack.go
    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呼び出し処理部分にあたる。

cmd/stack.go
    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のよいところですね。

cmd/stack.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のフィールドにマッピングできるようにしている。

cmd/schema.go
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エンコド/デコードできるようにするには MarshalJSONUnmarshalJSON を独自に定義しておくとカスタマイズした処理を挟み込める。

cmd/schema.go
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の型を定義しておく。

cmd/client.go
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サーバを立ててテストするため。

cmd/client.go
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リクエストに引き回しているのがポイント。

cmd/client.go
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にマップしてくれる、

cmd/client.go
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 にセットして返す。

cmd/stack.go
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で使いまわすので、さきにヘルパ関数を作っておく。

cmd/mock.go
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 が返ってくるかを確認したいので、こんなかんじにしてみた。

cmd/stack_test.go
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 を呼び出して期待値と結果を比較する。

cmd/stack_test.go
    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ツールを作ってみた。
作ったものは非常に単純なものなんだけど、必要なエッセンスがいろいろ詰まっていて、学ぶことは多いですね。

この記事がこれから同じようなものを作ろうかなと思ってる人の参考になれば幸いです。

crowdworks
21世紀の新しいワークスタイルを提供する日本最大級のクラウドソーシング「クラウドワークス」のエンジニアチームです!
https://crowdworks.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away