Timers Tech Blog

グローバルな家族アプリFammを運営するTimers inc (タイマーズ) の公式Tech Blogです。弊社のエンジニアリングを支える記事を随時公開。エンジニア絶賛採用中!→ https://timers-inc.com/engineering

Go で堅牢な AWS Lambda Function を作るためのユニットテスト

はじめまして! 昨年12月に入社しました、サーバサイドエンジニアの おの( @shout_poor ) です!

最近、弊社の Famm アプリケーションで使用する AWS Lambda Function の、 JavaScript(Node.js) から Go へのリライトを担当しました。

Go で Lambda を書くのは初めてだったのでいろいろと気づきがあったのですが、この記事では、Go におけるユニットテストについて書きたいと思います。

DI とモックアップ

Go に限らず、ユニットテストで悩ましいのが、テスト対象の外から影響をどう考え、またどう検証するかです。例えば DB やファイルの入出力、クラウドサービスの呼び出しなどです。

DB であれば DBMS をテスト環境上に立ち上げてテストデータを登録しておいたり、AWS なら localstack のようなスタブサービスを起動させたりする方法もありますが、テスト対象が関数単体であれば、関数が直接呼び出すライブラリ、DB ドライバや AWS SDK を直接モックアップで置き換えたいですよね。

Node.js や Python であれば、実行環境が読み込んだライブラリを実行中に書き換えられるので、カジュアルにモックアップを作ることができますが、静的型付け言語である Go ではそうはいきません。

そういうわけで、 DI (Dependency Injection) が必要になります。

DI は、実装オブジェクト同士を直接依存させず、抽象化したインターフェースへのみ依存することで、実装を入れ替えられるようにする設計パターンです。

AWS SDK の場合は、各サービス APIファサードとなる Client 系の構造体にはすべてインターフェースが用意されているので、これを使って実装への依存をなくしていきます。

例えば、S3 の Client である s3.S3 のメソッドシグネチャを定義した s3iface.S3API があるので、これを使って S3 を扱う構造体を実装していきます。

type S3HogeRepository struct {
    client s3iface.S3API,
}

func NewS3HogeRepository(s3Client s3iface.S3API) *S3HogeRepository {
    return &S3HogeRepository{client: s3Client}
}

func (*S3HogeRepository repository) GetFileFromS3(bucket, key string) (*s3.GetObjectOutput, error) {
    output, err := repository.client.GetObject(&s3.GetObjectInput{
        Bucket: &bucket,
        Key:    &key,
    })
    if err != nil {
        return nil, err
    }
    return output, nil
}

...

インターフェースの用意されていない構造体の場合はどうするかというと、使いたいメソッドのシグネチャを定義したインターフェースを自分で用意することで、実装への依存を切り離すことができます。 インターフェースを後付で定義できる Go の便利なところですね。

例えば、以下は Go 標準の net/http モジュールの Client 構造体をインターフェース経由で使う例です。

type APIClientInterface interface {
    Post(url, contentType string, body io.Reader) (*Response, error)
}

...

type PostAPIRepository struct {
    endpointURL string,
    apiClient APIClientInterface,
}

func NewPostAPIRepository(endpointURL string, apiClient APIClientInterface) {
    return &PostAPIRepository{
        endpointURL: endpointURL,
        apiClient: apiClient,
    }
}

func (repo *PostAPIRepository) PostData(body *DataType) error {
    bodyBytes, err := json.Marshal(body)

    response, err := repo.apiClient.Post(repo.endpointURL, "application/json", bytes.NewBuffer(bodyBytes))
    // ... Any process
}

ちなみに、 DI といえば DI コンテナ(と、実装オブジェクト同士の依存を記述する設定ファイル)を思い浮かべる方も多いと思います。 Go にも Wiredig などの DI コンテナがありますが、規模の小さいプログラムであれば、ブートストラップとなる関数で依存の解決を直接実装してしまってもいいと思います。

以下は、 Lambda Function のブートストラップであるイベントハンドラー関数で S3 のインスタンスを作って、前出の S3HogeRepository と接続する例です。

func Handler(ctx context.Context, event events.S3Event) (string, error) {
    sess := session.Must(session.NewSession())
    s3Client = s3.New(sess)
    repository = NewS3HogeRepository(s3Client)

    // ... Any process
}

func main() {
    lambda.Start(Handler)
}

さて、モックアップの実装ですが、Testify の mock パッケージが便利です。

type S3ClientMock struct {
    mock.Mock
    s3iface.S3API
}

func (mock *S3ClientMock) GetObject(arg *s3.GetObjectInput) (*s3.GetObjectOutput, error) {
    ret := mock.Called(arg)
    return ret.Get(0).(*s3.GetObjectOutput), ret.Error(1)
}

モックアップ化したい Interface と、 mock.Mock をメンバにもつ構造体を定義し、実際に呼ばれるであろうメソッドだけ実装します。メソッドの中では、 mock.Called から引数に応じた戻り値を取得し、メソッドの戻り値とします。このようにしておくと、以下の例のように、個別のテストケースの中で、モックアップメソッドの引数と戻り値を設定することができます。

func TestS3HogeRepository(t *testing.T) {
    t.Run("GetFileFromS3", func(t *testing.T) {
        testBucket := "hoge.bucket.name"
        testKey := "/path/to/object"

        s3ClientMock := new(S3ClientMock)

        // モックアップメソッドが返す戻り値を設定
        s3ClientMock.On("GetObject", mock.Anything).Return(&s3.GetObjectOutput{
            Body:          ioutil.NopCloser(bytes.NewBufferString("hogehogefuga"))
            ContentLength: aws.Int64(12),
        }, nil)

        repo := NewS3HogeRepository(s3ClientMock)
        result, err := repo.GetFileFromS3(testBucket, testKey)
        // ... assertion of err and result
    })
}

On の第2引数に mock.Anything を指定していますが、ここには具体的な値を指定することもできます。この場合、特定の引数だった場合にだけ返す戻り値を設定できます。

ちなみに、このようにして作ったモックアップからは、テスト実行後に呼び出された履歴を取り出すことができます。これを利用して、外部の関数に適切な引数を渡せているか検証することができます。

       getObjectInput := s3ClientMock.calls[0].Arguments[0].(*s3.GetObjectOutput)
        // ... getObjectInput の内容を検証

以上のように、DI を意識した設計と testify/mock によって、自由度の高い Mocking が可能になり、外部ライブラリ呼び出しを含む関数の細かいテストが書きやすくなります。

検証 (assert)

さて、テストなので値の検証をしないといけませんが、 Go 標準のテストフレームワークでは、いわゆる assert 系の機能が用意されていません。if など普通の条件分岐で判定して、失敗したときは T.Fail(message) を呼ぶ、というシンプルな形になっています。

これはこれで学習コストが低くてよいのですが、たくさんテストを書いていると、失敗したときのメッセージを考えるのに意外と時間を費やしていることに気が付きます。

失敗時のメッセージはデバッグの効率に直結するのでおざなりにできません。かと言って、細かい値の比較にいちいちメッセージを考えて書いていくのも面倒です。

ということで、前出の Testify に含まれる assert パッケージに頼ることにしました。

以下のようなテストを動かすと

func TestSomething(t *testing.T) {
    assert := assert.New(t)
    assert.Equal(123, 456)
}

以下のようなメッセージが出ます。

--- FAIL: TestSomething (0.00s)
    test_test.go:10: 
                Error Trace:    test_test.go:10
                Error:          Not equal: 
                                expected: 123
                                actual  : 456
                Test:           TestSomething
FAIL
FAIL    command-line-arguments  0.007s
FAIL

特に自分でメッセージを書かなくとも、失敗理由と期待値、実際の値などが出力されます。

もちろん、必要に応じて自分で情報を付加することもできます。

func TestSomething(t *testing.T) {
    assert := assert.New(t)
    assert.Equal(123, 456, "もちろん失敗する")
}
--- FAIL: TestSomething (0.00s)
    test_test.go:10: 
                Error Trace:    test_test.go:10
                Error:          Not equal: 
                                expected: 123
                                actual  : 456
                Test:           TestSomething
                Messages:       もちろん失敗する
FAIL
FAIL    command-line-arguments  0.007s
FAIL

testfy/assersion には多くの assert 関数が含まれているので、テストの意味を考えながら適切な関数を選んで使うことで、効率よくデバッグにも便利なテストを書くことができると思います。

テストカバレッジの確認

go では、標準でカバレッジ計測ツールが用意されています。

$ go test -coverprofile=cover.out 
$ go tool cover -html=cover.out -o cover.html

f:id:shout_poor:20200221105735p:plain
Goのカバレッジ測定ツール出力例

出力された HTML ファイルを開くと、このような感じで、通過コードが緑、非通過コードが赤で表示されます。

経験上、品質を向上させるために、テストカバレッジを測定することはとても有効です。

といっても、数字としてのカバレッジ「レート」ではなく、具体的にどの分岐が通過しているか/いないか、ということが可視化されていることが重要です。

非通過コードがあれば、仕様を満たす上でその部分のテストが必要か否か、必要でないなら、そのコードが削除できないかどうか、一度は考えてみるとよいでしょう。

最後に

AWS Lambda の実装言語として Go を選ぶのは、プラットフォームバージョンのライフサイクルの長さやパッケージのしやすさなどで優位な点が多いのですが、Node.js や Python に比べると、クラウドサービス API の利用など副作用のテストがやりにくい印象があります。

しかし、インターフェースを意識してモジュールをしっかり設計し、Testify などを駆使してテスト作り込んで、またその過程でコードの無駄を削いでいければ、静的型付け言語としての特性も相まって、よりコンパクトで堅牢なアプリケーションが作れると思います。

積極採用中!!

子育て家族アプリFammを運営するTimers inc.では、現在エンジニアを積極採用中!
急成長中のサービスの技術の話を少しでも聞いてみたい方、スタートアップで働きたい方など、是非お気軽にご連絡ください!
採用HP: http://timers-inc.com/engineerings

プロダクトマネージャーも積極募集中です!

Timersでは各職種を積極採用中!

急成長スタートアップで、最高のものづくりをしよう。

募集の詳細をみる