go-vcrを使ったSymmetric API Testingのメモ

github.com

go-vcrを使おうとしてみたのでそのメモ。

Synmetric API Testing?

Symmetric API Testingという言葉は、この記事が使っている言葉のよう。

単純に言えば、通常は何らかのクライアントライブラリにを作成したときに、実際のリクエストとテスト用に利用するリクスエストを分けたい、外部への通信などの密結合を自動テストのタイミングなどでは避けたい、このときに特殊な書き方をしてコードを分ける(asymmetric)のではなく、同様のコードをそのまま使うようにして行いたい(symmetric)という趣旨らしい。

実際のところどうやっているかといえば、responseをどこかに保存しておくというようなことをしている。

requestを飛ばすときに以下のようなコードを書くことで標準出力にresponseを出力する事ができる。

// respはresponse
resp.Body = ioutil.NopCloser(io.TeeReader(io.TeeReader(resp.Body, f), os.Stdout))

これに手を加えて、保存してあげれば良い。と言う感じの模様。例えば、URL名と紐付いた形で結果を保存しておく(メソッド名もprefixとして付けたほうが良い場合もあるかもしれない)。テストのときにはこちらを見れば良いという趣旨らしい。

filename := filepath.Join(append([]string{"json"}, strings.Split(strings.TrimPrefix(urlStr, c.baseUrl), "/")...)...)
f, _ := os.Create(filename)
resp.Body = ioutil.NopCloser(io.TeeReader(io.TeeReader(resp.Body, f), os.Stdout))

ただこれだけだとSynmetricにならないのでは?という気がする。

どうやってSynmetricにしている?

Synmetricということなので実際のコードの書き手とテストコードの書き手が感じる書き味のようなものが同じということなのだろう。例えば、環境変数を見てファイルが有ればファイルを見るというような形になっているのかもしれない。

基本的な味方はnet/http.ClientのTransportというフィールド。これを利用して使い方が同じ様な形式になるようにしていく。RoundTripperというインターフェイスのものを受け取れる。通信の処理に対するinterceptorのようなものと言うふうに考えれば良い。

通常のリクエスト自体はnet/http.DefaultTransportが行っている。nilの場合はこれが使われる。middlewareのようなことがやりたければ、これを利用するRoundTripperなどを書いてあげれば良い。ということになる。

type Client struct {
    // Transport specifies the mechanism by which individual
    // HTTP requests are made.
    // If nil, DefaultTransport is used.
    Transport RoundTripper

...
}

type RoundTripper interface {
    // とても長いコメントが書いてあるが省略
    RoundTrip(*Request) (*Response, error)
}

単純には、クライアントライブラリを *http.Client を受け取れるように設計しておけば良い。

type Client struct {
    *http.Client
}

func NewClient(client *http.Client) *Client {
    return &Client{Client: client}
}

あとは、xxxtestみたいな形でFake用のClientを作るというような形にする。

NewClient(client *http.Client, interceptor http.RoundTripper) *xxx.Client {
    if client == http.DefaultClient {
        panic("not use actual client") // errorでも良いかもしれないが
    }
    client.Transport = interceptor
    return &xxx.Client{Client: client}
}

例えばの話。

go-vcr

手抜きをしたいならgo-vcrが使えるかもしれない。元々この記事はこのライブラリの使い方の個人用のメモのつもりだった。

github.com

go-vcrはこの辺をいい感じにやってくれるライブラリの模様(vcrというruby製のライブラリが祖。個人的にはVCR.pyというpython製のライブラリの方で馴染みが深い)。

r, err := recorder.New("<filename>")
if err != nil {
    return err
}
defer r.Stop()
client.Transport = r
res, err := client.Get("http://example.net") // 本来はAPI request
if err != nil {
    return err
}

完全に動くコードの例はこういう感じ。

main.go

package main

import (
    "io"
    "log"
    "net/http"
    "os"
    "time"

    "github.com/dnaeon/go-vcr/cassette"
    "github.com/dnaeon/go-vcr/recorder"
)

func main() {
    if err := run(); err != nil {
        log.Fatalf("!%+v", err)
    }
}

func run() error {
    r, err := recorder.New("fixtures/01")
    if err != nil {
        return err
    }
    defer r.Stop()

    // ここは省略可能
    {
        r.AddFilter(func(i *cassette.Interaction) error {
            delete(i.Request.Headers, "Authorization")
            return nil
        })
        r.AddPassthrough(func(req *http.Request) bool {
            return req.URL.Path == "/login"
        })
    }

    client := &http.Client{
        Transport: r,
        Timeout:   3 * time.Second,
    }

    url := os.Getenv("URL")
    res, err := client.Get(url)
    if err != nil {
        return err
    }
    if _, err := io.Copy(os.Stdout, res.Body); err != nil {
        return err
    }
    defer res.Body.Close()
    return nil
}

実行してみる。テキトーに https://httpbin.org/headers にリクエストしている。 2回目はネットワークにリクエストを飛ばさないので、早い。

$ URL=https://httpbin.org/headers

$ time go run /main01.go
{
  "headers": {
    "Accept-Encoding": "gzip", 
    "Host": "httpbin.org", 
    "User-Agent": "Go-http-client/2.0", 
    "X-Amzn-Trace-Id": "Root=1-5f36b69c-c5b786ff21a06da51a757764"
  }
}

real    0m1.355s
user    0m0.472s
sys 0m0.251s

$ time go run /main01.go
{
  "headers": {
    "Accept-Encoding": "gzip", 
    "Host": "httpbin.org", 
    "User-Agent": "Go-http-client/2.0", 
    "X-Amzn-Trace-Id": "Root=1-5f36b69c-c5b786ff21a06da51a757764"
  }
}

real    0m0.310s
user    0m0.355s
sys 0m0.219s

このとき以下の様なファイルが保存されている。

fixture/01.yaml

---
version: 1
interactions:
- request:
    body: ""
    form: {}
    headers: {}
    url: https://httpbin.org/headers
    method: GET
  response:
    body: "{\n  \"headers\": {\n    \"Accept-Encoding\": \"gzip\", \n    \"Host\": \"httpbin.org\", \n    \"User-Agent\": \"Go-http-client/2.0\", \n    \"X-Amzn-Trace-Id\": \"Root=1-5f36b69c-c5b786ff21a06da51a757764\"\n  }\n}\n"
    headers:
      Access-Control-Allow-Credentials:
      - "true"
      Access-Control-Allow-Origin:
      - '*'
      Content-Length:
      - "190"
      Content-Type:
      - application/json
      Date:
      - Fri, 14 Aug 2020 16:06:52 GMT
      Server:
      - gunicorn/19.9.0
    status: 200 OK
    code: 200
    duration: ""

はい。

余談

では、go-vcrが最高に便利か?というと個人的には不満を持っていたりもしている。

例えば、go-vcrは1つのrecorderが対応できるmock request(cassette)は1つだけという制限がある。これは複数のrequestで構成されている様な処理のテストには不適。

あと、こういうケースは無理。

  1. GET /x
  2. PATCH /x
  3. GET /x

1つ目と3つ目は同じパラメーターで別のresponseというようなケース。

あと、Synmetric API testingという考え方に疑義があるときがある。go-vcrは実際のrequestを飛ばすか飛ばすかを条件に合致するファイルの存在の有無で決めているのだけれど、テスト中は一切実際のリクエストが飛ばされる余地が無いようにしたいことがある1

まとめ

  • クライアントライブラリなどの実装のときに、Symetric API testingという考え方が便利なことがある
  • 仕組みのキモはnet/http.Client.Transport
  • go-vcrというライブラリが便利に使える場合もある
  • (クライアントライブラリのテストに、httptest.Serverを別途立ち上げる以外の方法もある)

gist

参考


  1. ここではSymetric API testingとgo-vcrを結びつけて表現しているが、結びつけているのはそれぞれの筆者作者ではなく自分