アプリケーションの動作を担保するテストをどう書くか

こんにちは。kimihiro_nです。
今回はアプリケーションの動作を保証するために不可欠なテストコードの書き方についてです。 特に外部依存要素のテストに焦点を当ててみていきたいと思います。

外部に依存するテストコード

皆さんはアプリケーションのテストコードを書いていますか? 内部的な状態を持たず、入力と出力が常に変化しない関数であれば、テストコードを書くのは比較的容易です。実際に関数を呼び出ししてその出力と期待値が一致しているかをみればテストすることができます。

しかし実際にアプリケーションを開発する場合、データベースへの接続だったり外部へのAPI呼び出しだったりといった外部の状態に依存した処理が含まれることが多いです。このような場合、素直にテストを書くのが難しいです。

多くの場合モックを利用して実際のデータベース呼び出しを置き換えたり、テスト用のリソースをdockerなどで構築してダミーデータを使って動作を確認する事になるでしょう。

これらの方法にはそれぞれ一長一短あります。

モックを利用する場合

モックを利用する利点としては、外部のリソースに頼らず純粋な単体テストとして実行出来ることです。しかし、実際のリソースに接続していないため、いざ繋いで動かしてみたら全く期待通りでなかったということも少なくありません。

テスト用リソースを利用する場合

テスト用のリソースを用意する場合、テストの段階でアプリケーションの挙動をある程度保証することができます。ただテスト用に動かす環境を作るのはコストが高く、リソースによってはローカルで再現できないといったこともよくあります。またローカルで動かせてもCI上で動かすのが大変だったり、テストの実行が遅くなってしまったりと万能ではないです。

アプリケーションの動作を担保するための方針

ではどのようにテストを書けば、アプリケーションの動作を担保することが出来るでしょうか。テストを書くための時間や動作環境によってベストな方法は異なりますが、最近私が採用している方針を共有したいと思います。

レポジトリの作成

まずはアプリケーションをテストしやすくするため、クリーンアーキテクチャでいうところのレポジトリを作成しています。データベースや外部APIなどのアクセスを抽象化するためのレイヤーです。

// UserRepository インターフェース
type UserRepository interface {
    // ユーザーを保存する
    Save(ctx context.Context, user *User) error
}

// EmailRepository インターフェース
type EmailRepository interface {
    // Eメールを送信する
    Send(ctx context.Context, email string, message string) error
}

Go言語での例になりますが、このような形で裏側のリソースに依存しないインターフェースをレポジトリとして定義します。ユーザーの保存先はMySQLかもしれませんし、NoSQLのDynamoDBになるかもしれませんが、インターフェースとしては受け取ったユーザーを保存するだけとなっています。メールについても同様でどんなAPI、SDKを利用するかはこの層では触れません。

レポジトリを使ったコードの実装

// UserService はユーザー管理に関わるサービスです
type UserService struct {
    userRepo   UserRepository  // ユーザーデータを操作するためのリポジトリ
    emailRepo  EmailRepository  // Eメール送信を扱うリポジトリ
}

func (s *UserService) RegisterUser(ctx context.Context, user *User, email string) error {
    // 入力のバリデーション
    if user.Name == "" || email == "" {
        return errors.New("invalid input")
    }

    // ユーザー情報の保存
    err := s.userRepo.Save(ctx, user)
    if err != nil {
        return err
    }

    // メール送信
    message := fmt.Sprintf("Hello %s, welcome!", user.Name)
    err = s.emailRepo.Send(ctx, email, message)
    if err != nil {
        return err
    }

    return nil
}

アプリケーションのロジックを組み立てるときは、このレポジトリを利用してコードを書いていきます。いわゆるユースケース層です。今回はユーザー登録の流れを例としてます。レポジトリで定義されたインターフェースを呼び出しているだけなので、この部分でも裏側のリソースが何なのかを気にすることなく実装が出来ます。

ユースケースの流れ

このコードを動かす時は、UserRepo、EmailRepoを実際の外部リソースを使って実装することになります。 図の例としてはUserRepoをMySQLで、EmailRepoをAmazon SESとしてみました。

この状態でどうテストを書くのがいいのかを考えてみます。

ユースケース層のテストコードを書く

レポジトリをモックしてユースケースをテスト
先ほども書いたとおり、ユースケース層ではレポジトリを経由して呼び出しているため、バックエンドがなにかを気にせずロジックを組み立てることが出来ます。バックエンドが何でもいいので当然モックでも問題ありません。

なので gomock などのモックライブラリを活用し、レポジトリをモックした上でユースケースのテストを書きます。

以下は、gomock を使用して UserRepository と EmailRepository をモックし、UserService の RegisterUser 関数をテストする例です。

func TestRegisterUser(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockUserRepo := NewMockUserRepository(ctrl)
    mockEmailRepo := NewMockEmailRepository(ctrl)

    ctx := context.Background()
    user := &User{Name: "Test User"}
    email := "test@example.com"
    message := fmt.Sprintf("Hello %s, welcome!", user.Name)

    // UserRepoのSaveが1度呼ばれることを確認
    mockUserRepo.EXPECT().Save(ctx, user).Return(nil).Times(1)
    // EmailRepoのSendが1度呼ばれることを確認
    mockEmailRepo.EXPECT().Send(ctx, email, message).Return(nil)Times(1)

    service := &UserService{
        userRepo:  mockUserRepo,
        emailRepo: mockEmailRepo,
    }
    // mockを利用してRegisterUserを呼び出し
    err := service.RegisterUser(ctx, user, email)
    assert.NoError(err)
}

モックライブラリにはメソッドがどんな引数で、何度呼ばれたかを確かめる機能があります。これを活用し、ユースケースがどのような動きをして欲しいのかをテストすることが可能です。 今回はシンプルな例なのでありがたみが少ないですが、例えば「バリデーションでエラーが出たらユーザーのSaveやメールのSendが呼ばれないこと」「Save時にエラーが出たらメールのSendが呼ばれないこと」は同様にモックを使ってテストすることが出来ます。実際のアプリケーションではもっと処理が複雑になると思いますが、そういった場合でもしっかりテスト出来ることが強みです。

一方でモックのセットアップでコード量が膨れやすいという欠点もあるので網羅的なテストにはあまり向いていないです。ユースケースの処理を変更する度それに応じてテストもまとめて変更する必要があるので、代表的なシナリオをピックアップしてテストを書くことが多いです。細かいロジックに関しては切り出してユニットテストにするのが確実です。

レポジトリ実装のテストをする

残りのレポジトリ実装の部分

ユースケースの部分はテストをすることが出来ました。 残るレポジトリ実装の部分についてもテストが出来れば、アプリケーション全体からみて一連の動作確認が行えたと言えそうです。 しかし、こちらはMySQLだったりAmazon SESだったりと背後のリソースに依存する部分が大きく、実際に動かしてみないと確認が難しそうです。

考えられる方針としては

  • ローカルで実際のリソースを動かす
  • ローカルでエミュレーターを動かす
  • 実際のリソースに接続してしまう
  • モックを使って入出力だけを確認する

あたりがありますが、どれを利用するかはリソースによっても異なってきます。

ローカルで実際のリソースを動かす

MySQLの場合、Dockerなどで簡単に動かすことが出来るので、docker composeなどで開発環境と一緒に立ち上げてしまうのがお手軽です。

# compose.yaml 

services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD=yes
      MYSQL_USER: testuser
    volumes:
      - db-data:/var/lib/mysql

volumes:
  db-data:

バージョンを揃えてあげることで本番環境との挙動のずれも最小限に抑えて動作確認ができます。 欠点としては動作を確認するためにデータベースを立ち上げたりデータをセットアップしたりと準備が多いことです。 また、CI上で動かすことを考えると、実行時間やCIサーバーのメモリの問題も出てきます。

ローカルでエミュレーターを動かす

DynamoDBのようにマネージドなサービスの場合、そのままローカルで動かすことは出来ません。 しかしDynamoDB localやLocalStackといった開発環境で代替する仕組みが公式・非公式で用意されていることも多く、そちらを活用することができます。 SESのようなサービスもLocalStackで模擬的に動かすことが可能です。(Community版はモックのみ)
Simple Email Service (SES) | Docs

AWSのSDKの場合バックエンドの Endpoint を差しかえられるようになっているため、これを docker compose で立ち上げたエミュレーターに向ければローカルで動作確認が可能です。

   var sesEndpoint *string
    // SES_ENDPOINT が定義されていたら上書きする
    if os.Getenv("SES_ENDPOINT") != "" {
        sesEndpoint = aws.String(os.Getenv("SES_ENDPOINT"))
    }
    sesClient := sesv2.NewFromConfig(cfg, func(o *sesv2.Options) {
        o.BaseEndpoint = sesEndpoint
    })

注意すべきところはやはりエミュレートしたものであることです。実環境とは異なる上限値が用いられていたりと完全な動作確認をすることは難しいです。

実際のリソースに接続してしまう

ローカルから直接実際のリソースに接続して確認するのも時には有効です。 動作を確かめる意味では確実ですし、流れているデータをそのまま利用できるなどの利点もあります。 反面、開発・テスト用のインフラを構築するコストや、リソースへセキュアに接続する方法をどうするかといった課題が出てきます。 テストコードから実際のリソースに接続するのも弊害が出やすいため、動作確認での利用に留めつつ、テストとしては別の手段を用意するのが賢明かもしれません。

モックを使って入出力だけを確認する

MySQLなどのデータベース接続ではsqlmock等を利用して、SQLの組み立てが期待通りかを確認する方法もあります。 クエリビルダーを活用しているときは有用な手段となります。

import (
    "context"
    "database/sql"
    "github.com/DATA-DOG/go-sqlmock"
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestRegisterUser(t *testing.T) {
    db, mock, err := sqlmock.New()
    assert.NoErr(err)
    defer db.Close()

    ctx := context.Background()
    user := &User{Name: "Test User", Email: "test@example.com"}

    mock.ExpectExec("^INSERT INTO users \\(name, email\\) VALUES \\(\\?, \\?\\)$").
        WithArgs(user.Name, user.Email).
        WillReturnResult(sqlmock.NewResult(1, 1))

    repo := NewUserRepo(db)
    err = repo.Save(ctx, user)
    assert.NoErr(err)

    if err := mock.ExpectationsWereMet(); err != nil {
        t.Errorf("there were unfulfilled expectations: %s", err)
    }
}

クエリビルダーを利用せず生のSQLを使って接続する場合などは、sqlmockを使用しても実際のSQLとテストのSQLが一致するかを確認するだけとなってしまい、意義のあるテストが行いづらいです。 モックを利用する場合なにをテストしたいのかが難しいところです。

まとめ

アプリケーション全体のテストを書くには、外部リソースに依存する部分をレポジトリとして抽象化することが大切です。 アプリケーションから外部依存をレポジトリとして分離することでロジック自体のテストが書きやすくなります。 完全なクリーンアーキテクチャを採用していなくても、外部依存要素を抽象化し、テストを簡素化するという考え方は有効です。 レポジトリの実装のテストは、実際のリソースを用いるかエミュレーターを利用するかなど、状況に応じて適切な方法を選択していきましょう。