ジョブに対する自動テスト実装のアプローチ
はじめに
こんにちは、バックエンドを中心に開発をしている野島と申します。
最近は、弊社プロダクトであるSmartMat Cloudに対する自動テストの拡充を推し進めています。
SmartMat Cloudは在庫の重量を計測し、閾値を下回った際に自動で発注を行う機能を有するIoTプロダクトです。この記事ではその発注機能、特にジョブに対するテストについて共有します。
発注ジョブに対する自動テストを作成する中で、下記の課題がありました。
- どのようにしてジョブを実行するか
- ジョブの実行結果はどう評価するか
これらに対するアプローチを共有します。
どのようにしてジョブを実行するか
発注ジョブはCronで定期的に実行されており、テストをしたい任意のタイミングでどのように発注処理を実行するかが課題でした。
そこで、テスト用のWebAPIを作成し、これを呼び出すことでジョブと同じ処理を実行することにしました。
弊社はクリーンアーキテクチャを採用していることもあり、ジョブの実装は下記が分離されています。
- ビジネスロジックの実装、ジョブのユースケース
- ビジネスロジックを呼び出すための実装
- コマンドライン引数のパース
- DB接続等のインフラ層の初期化
テストしたいものはビジネスロジックなので、WebAPIでジョブのユースケースを公開し、これにリクエストを送信します。
そうすると、Cronで実行されているジョブと同じロジックを任意のタイミングで実行できます。
このWebAPIは、テスト用なので、本番環境にはデプロイしません。
簡単な実装例
package main
import "net/http"
func main() {
// ...production環境用のコード...
// ...development環境でのみデプロイされる、テスト用のコード...
if env == Development {
http.HandleFunc("/internal/order-job", func(w http.ResponseWriter, r *http.Request) {
err := NewJobUsecase(
// ...DI
).Order()
if err != nil {
w.Write([]byte(err.Error()))
return
}
w.Write([]byte("ok"))
return
})
http.ListenAndServe(":8080", nil)
}
}
type jobUsecaseImpl struct {
// ...dependency
}
func (r *jobUsecaseImpl) Order() error {
// ...発注ロジック
return nil
}
func NewJobUsecase() JobUsecase {
return &jobUsecaseImpl{
// ...DI
}
}
type JobUsecase interface {
Order() error
}
var env = "development"
const Development = "development"
呼び出し結果
$ curl http://localhost:8080/internal/order-job
ok
ジョブの実行結果はどう評価するか
発注ジョブは外部サービスのAPIを実行し、発注処理を依頼します。
外部サービスに依存があります。
この場合テストで保証したいものは、外部サービスへの発注依頼が正しく行われているか、外部サービスとのコミュニケーションを行う際の仕様が守られているか、です。
そこで、外部API呼び出しの部分にモックを利用し、正しく引数に発注情報が設定されていること(=正しく発注依頼が行われていること)を確認します。
これは、単体テストの考え方/使い方という本の、管理下にない依存に対してはモックを使うと効果的なテストになる、という考え方を参考にしています。
また、第5章で見たように、管理下にない依存とのコミュニケーションに対して、そのコミュニケーションを行う際の仕様を維持することは、後方互換の維持に繋がります。そのため、このような依存に対してモックを使うことは非常に効果的です。なぜなら、この種の依存をモックに置き換えていれば、どのようなリファクタリングを行ったとしても、管理下にない依存とのコミュニケーションを行う際の仕様が壊れていないことを確認できるからです。
単体テストの考え方/使い方 p.270より引用
簡単な実装例
先ほど実装したものの差分を載せます。
モックの実装には、moqというモックを自動生成するライブラリを用いています。
package main
import (
"encoding/json"
"github.com/HiromasaNojima/explain-order-job-test/orderapi"
"net/http"
)
func main() {
// ...production環境用のコード...
// ...development環境でのみデプロイされる、テスト用のコード...
if env == Development {
http.HandleFunc("/internal/order-job", func(w http.ResponseWriter, r *http.Request) {
externalAPI := &orderapi.ExternalAPIMock{
RequestOrderFunc: func(req orderapi.OrderInfo) error {
return nil
},
}
err := NewJobUsecase(
// ...DI
externalAPI,
).Order()
if err != nil {
w.Write([]byte(err.Error()))
return
}
// 発注用APIをリクエストした時の値を取得、レスポンスで返却し、受け取り側で意図した通りにリクエストされているか評価する。
called := externalAPI.RequestOrderCalls()[0]
b, err := json.Marshal(called)
w.Write(b)
return
})
http.ListenAndServe(":8080", nil)
}
}
type jobUsecaseImpl struct {
// ...dependency
externalAPI orderapi.ExternalAPI
}
func (r *jobUsecaseImpl) Order() error {
// ...発注ロジック
req := orderapi.OrderInfo{
ProductName: "発注したい商品の名前",
}
// ...発注APIの呼び出し
err := r.externalAPI.RequestOrder(req)
if err != nil {
return err
}
return nil
}
func NewJobUsecase(externalAPI orderapi.ExternalAPI) JobUsecase {
return &jobUsecaseImpl{
// ...DI
externalAPI: externalAPI,
}
}
package orderapi
type ExternalAPI interface {
RequestOrder(request OrderInfo) error
}
type externalAPIImpl struct{}
type OrderInfo struct {
// ..発注情報
ProductName string `json:"product_name"`
}
func (r externalAPIImpl) RequestOrder(request OrderInfo) error {
// 外部API呼び出し
return nil
}
// Code generated by moq; DO NOT EDIT.
// github.com/matryer/moq
package orderapi
import (
"sync"
)
// Ensure, that ExternalAPIMock does implement ExternalAPI.
// If this is not the case, regenerate this file with moq.
var _ ExternalAPI = &ExternalAPIMock{}
// ExternalAPIMock is a mock implementation of ExternalAPI.
//
// func TestSomethingThatUsesExternalAPI(t *testing.T) {
//
// // make and configure a mocked ExternalAPI
// mockedExternalAPI := &ExternalAPIMock{
// RequestOrderFunc: func(request OrderInfo) error {
// panic("mock out the RequestOrder method")
// },
// }
//
// // use mockedExternalAPI in code that requires ExternalAPI
// // and then make assertions.
//
// }
type ExternalAPIMock struct {
// RequestOrderFunc mocks the RequestOrder method.
RequestOrderFunc func(request OrderInfo) error
// calls tracks calls to the methods.
calls struct {
// RequestOrder holds details about calls to the RequestOrder method.
RequestOrder []struct {
// Request is the request argument value.
Request OrderInfo
}
}
lockRequestOrder sync.RWMutex
}
// RequestOrder calls RequestOrderFunc.
func (mock *ExternalAPIMock) RequestOrder(request OrderInfo) error {
if mock.RequestOrderFunc == nil {
panic("ExternalAPIMock.RequestOrderFunc: method is nil but ExternalAPI.RequestOrder was just called")
}
callInfo := struct {
Request OrderInfo
}{
Request: request,
}
mock.lockRequestOrder.Lock()
mock.calls.RequestOrder = append(mock.calls.RequestOrder, callInfo)
mock.lockRequestOrder.Unlock()
return mock.RequestOrderFunc(request)
}
// RequestOrderCalls gets all the calls that were made to RequestOrder.
// Check the length with:
// len(mockedExternalAPI.RequestOrderCalls())
func (mock *ExternalAPIMock) RequestOrderCalls() []struct {
Request OrderInfo
} {
var calls []struct {
Request OrderInfo
}
mock.lockRequestOrder.RLock()
calls = mock.calls.RequestOrder
mock.lockRequestOrder.RUnlock()
return calls
}
呼び出し結果
curl http://localhost:8080/internal/order-job
{"Request":{"product_name":"発注したい商品の名前"}}
テストを実装しているツールで、レスポンスに想定通りの値が設定されていることが確認できたらOKです。
まとめ
ジョブに対する自動テストを作成するためのアプローチを紹介しました。
任意のタイミングでジョブの処理を実行するために、ロジックをテスト用のAPIで公開する、という方法を採用しました。
他には本番環境と同じようにジョブを起動する方法も考えられます。
しかし、これを実装するためのコストの大きさや時間的制約により、また、ジョブの起動/終了が正しく行われることの担保はSREの責務と考えているため、本番環境と同じようにジョブを起動する方法は採用しませんでした。
本記事の内容がジョブの自動テストを実装する際の一助となれば幸いです。
参考文献
本文中のソースコード
Discussion