GoのWebフレームワークEchoで実装したAPIサーバーに対して httptestパッケージ を利用してテストコードを作成
はじめに
今回は以前に書いた 仮想通貨の価格を取得するAPIに対してテストコードを作成しました。 作成したAPIは Echo を利用しています。 このテストコードをPRを作成した段階で go testでチェックするようにします。(別記事でまとめる予定) yhidetoshi.hatenablog.com
テストコードについて
- (参考)Goアプリのディレクトリ構成
. ├── 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)) }
テストの内容
- TestServer(127.0.0.1:Port番号)を作成する
- TestServerが同じレスポンス結果を返すように設定する
- "FetchCryptoCoincheck()" 関数に リクエスト先URLを "TestServer" にして実行する
- Testifyにて "FetchCryptoCoinckGMO()" からTestServer にリクエストした戻り値が 設定したJSONと同じか判定する
■ httptest.NewServer
について
- 実際にHTTPリクエストを送ってテストする
- 127.0.01:未使用のPort番号 にサーバを起動
JSONファイルの比較に testifyを利用
参考記事
テストサーバで返すレスポンスを以下にする
{ "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でテスト
❯ 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" を利用すると サーバを立ち上げずにリクエストをシミュレートできるのでそれぞれの使い方は状況に応じて使い分けていければと思います。
まだまだこのあたりは書いた経験が少ないので勉強していこうと思います。