My Note

自己理解のためのブログ

GoのWebフレームワークEchoで実装したAPIサーバーに対して httptestパッケージ を利用してテストコードを作成

はじめに

今回は以前に書いた 仮想通貨の価格を取得するAPIに対してテストコードを作成しました。 作成したAPIは Echo を利用しています。 このテストコードをPRを作成した段階で go testでチェックするようにします。(別記事でまとめる予定) yhidetoshi.hatenablog.com

テストコードについて

.
├── README.md
├── api
│   ├── base.go
│   ├── client.go
│   ├── crypto_coincheck.go
│   ├── crypto_coincheck_test.go
│   ├── crypto_gmo.go
│   ├── crypto_gmo_test.go
│   ├── healthcheck.go
│   ├── investment_trust.go
│   └── metal.go
├── app.yaml
├── authentication
│   └── basic_auth.go
├── go.mod
├── go.sum
├── main.go
└── secret.yaml

テストする対象のコード

作成するテストコードは以下のコードに対して作成。

  • client.go
package api

var (
    defaultURLCryptoGMO       = "https://api.coin.z.com/public/v1/ticker?symbol="
    defaultURLCryptoCoincheck = "https://coincheck.com/api/ticker?symbol="
)

type ClientCryptoCoincheck struct {
    URL *string
}
type ClientCryptoGMO struct {
    URL *string
}

func (c *ClientCryptoGMO) NewClientCryptoGMO() {
    c.URL = &defaultURLCryptoGMO
}

func (c *ClientCryptoCoincheck) NewClientCryptoCoincheck() {
    c.URL = &defaultURLCryptoCoincheck
}
  • crypto_gmo.go
package api

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"

    "github.com/labstack/echo/v4"
)

type CryptoInfoGMO struct {
    DataGMO []DataGMO `json:"data"`
}

type DataGMO struct {
    Symbol    string `json:"symbol"`
    Last      string `json:"last"` // 最後の取引価格
    Timestamp string `json:"timestamp"`
}

type ResponseCryptoGMO struct {
    ResponseDataGMO []ResponseDataGMO `json:"data"`
}

type ResponseDataGMO struct {
    Symbol    string `json:"symbol"`
    Last      string `json:"last"`
    Timestamp string `json:"timestamp"`
}

func (cl *ClientCryptoGMO) FetchCryptoGMO(c echo.Context) error {
    var url string
    cig := &CryptoInfoGMO{}
    symbol := c.QueryParam("symbol") // BTC, ETH..

    // URLを場合別け(テストと外部API)
    if *cl.URL == defaultURLCryptoGMO {
        url = *cl.URL + symbol
    } else {
        url = *cl.URL
    }

    req, err := http.NewRequest("GET", url, nil)
    if err != nil {
        log.Println(err)
    }

    client := new(http.Client)
    resp, err := client.Do(req)
    if err != nil {
        log.Println(err)
    }
    defer resp.Body.Close()

    err = json.NewDecoder(resp.Body).Decode(cig)
    if err != nil {
        log.Println(err)
    }
    responseJSON := []ResponseCryptoGMO{
        {
            ResponseDataGMO: []ResponseDataGMO{
                {
                    Symbol:    cig.DataGMO[0].Symbol,
                    Last:      cig.DataGMO[0].Last,
                    Timestamp: cig.DataGMO[0].Timestamp,
                },
            },
        },
    }
    response, err := json.Marshal(responseJSON[0])
    fmt.Printf("response=%s\n", string(response))

    if err != nil {
        log.Println(err)
    }

    return c.String(http.StatusOK, string(response))
}

テストの内容

f:id:yhidetoshi:20220313163701p:plain

  1. TestServer(127.0.0.1:Port番号)を作成する
  2. TestServerが同じレスポンス結果を返すように設定する
  3. "FetchCryptoCoincheck()" 関数に リクエスト先URLを "TestServer" にして実行する
  4. Testifyにて "FetchCryptoCoinckGMO()" からTestServer にリクエストした戻り値が 設定したJSONと同じか判定する

httptest.NewServer について

  • 実際にHTTPリクエストを送ってテストする
  • 127.0.01:未使用のPort番号 にサーバを起動

JSONファイルの比較に testifyを利用

github.com

{
    "data": [
        {
            "symbol": "BTC_JPY",
            "last": "5000000",
            "timestamp": "2022-22-22T22:22:22.222Z"
        }
    ]
}

テストコード

  • crypto_gmo_test.go
package api

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/labstack/echo/v4"
    "github.com/stretchr/testify/assert"
)

func TestFetchCryptoGMO(t *testing.T) {
    // テストサーバを用意(戻り値は指定したjsonを返す)
    ts := httptest.NewServer(
        http.HandlerFunc(
            func(res http.ResponseWriter, req *http.Request) {
                if req.Method != "GET" {
                    t.Error("Request method should be GET but: ", req.Method)
                }

                respJSON, _ := json.Marshal(map[string][]map[string]string{
                    "data": {
                        {
                            "symbol":    "BTC_JPY",
                            "last":      "5000000",
                            "timestamp": "2022-22-22T22:22:22.222Z",
                        },
                    },
                })

                res.Header()["Content-Type"] = []string{"application/json"}
                fmt.Fprint(res, string(respJSON))
            }))
    defer ts.Close()

    // テストサーバからのレスポンスを取得する(比較する用)
    resp, err := http.Get(ts.URL)
    if err != nil {
        fmt.Println(err)
    }

    testData, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        fmt.Println(err)
    }
    fmt.Printf("testData=%s\n", string(testData))
    defer resp.Body.Close()

    // FetchCryptoGMO()の引数のためにコンテキストを用意
    e := echo.New()
    req := httptest.NewRequest("GET", "/", nil)
    req.Header.Set("Content-Type", "application/json")
    //req, err := http.NewRequest("GET", ts.URL, nil)
    rec := httptest.NewRecorder()
    c := e.NewContext(req, rec)

    cc := &ClientCryptoGMO{}
    cc.URL = &ts.URL // FetchCryptoGMO()のリクエスト先をテストサーバーに向ける

    // Test(テストサーバの戻り値 と FetchCryptoGMO()の戻り値を比較)
    if assert.NoError(t, cc.FetchCryptoGMO(c)) {
        assert.Equal(t, http.StatusOK, rec.Code)
        assert.JSONEq(t, string(testData), rec.Body.String())
    }
}

Testifyでテスト

  • assert.JSONEq を利用
    • jsonの比較テストで {key, value}やその型までチェックしてくれる
    • jsonのデータの並び順は問われない
❯ go test -run TestFetchCryptoCoincheck
--- FAIL: TestFetchCryptoCoincheck (0.00s)
    crypto_coincheck_test.go:69:
            Error Trace:    crypto_coincheck_test.go:69
            Error:          Not equal:
                            expected: map[string]interface {}{"last":"5000000", "symbol":"btc_jpy", "timestamp":1.423377841e+09}
                            actual  : map[string]interface {}{"last":"5000000", "symbol":"", "timestamp":1.423377841e+09}

                            Diff:
                            --- Expected
                            +++ Actual
                            @@ -2,3 +2,3 @@
                              (string) (len=4) "last": (string) (len=7) "5000000",
                            - (string) (len=6) "symbol": (string) (len=7) "btc_jpy",
                            + (string) (len=6) "symbol": (string) "",
                              (string) (len=9) "timestamp": (float64) 1.423377841e+09
            Test:           TestFetchCryptoCoincheck
FAIL
exit status 1
FAIL    github.com/yhidetoshi/apiEchoGAE-private/api    0.280s
  • テストが成功した場合の例
❯ go test -run TestFetchCryptoGMO
=== RUN   TestFetchCryptoGMO
testData={"data":[{"last":"5000000","symbol":"BTC_JPY","timestamp":"2022-22-22T22:22:22.222Z"}]}
response={"data":[{"symbol":"BTC_JPY","last":"5000000","timestamp":"2022-22-22T22:22:22.222Z"}]}
--- PASS: TestFetchCryptoGMO (0.00s)
PASS
ok      github.com/yhidetoshi/apiEchoGAE-private/api    0.205s

備考

■ interface{} を利用したデータ処理について

参考 interface{}でパースして処理する場合

  • jsonをパースする構造体
type CryptoInfoCoincheck struct {
    Last      interface{} `json:"last"`
    Timestamp int64       `json:"timestamp"`
}
  • Last interface{}json:"last"`` にデータをセットする場合に、 typeを調べて それぞれ interface{}から型をキャストする必要がある。
  • 今回は "Last" が float64 と string で受ける場合があったので以下の通りで処理しました。
// check type for interface{} value of last
    switch coincheck.Last.(type) {
    case float64:
        last = fmt.Sprintf("%.10g", coincheck.Last) // no use 'exponential(e+)'
    case string:
        last = coincheck.Last.(string)
    }

さいごに

今回は、httptest.NewServer を利用してエンドポイントを設けて実際にリクエストを飛ばしてテストを実施しました。 一方で"ServeHTTP" を利用すると サーバを立ち上げずにリクエストをシミュレートできるのでそれぞれの使い方は状況に応じて使い分けていければと思います。 まだまだこのあたりは書いた経験が少ないので勉強していこうと思います。

参考