バイセル Tech Blog

バイセル Tech Blogは株式会社BuySellTechnologiesのエンジニア達が知見・発見を共有する技術ブログです。

GoでRubyみたいに単体テストを書きたくてやったこと

はじめに

こちらは バイセルテクノロジーズ Advent Calendar 2021 の 2日目の記事です。 前日の記事は早瀬さんの「Apollo Clientを採用した際のフロントエンドの構成について考えてみた」でした。

はじめまして、開発1部の藤澤です。 今回は私が所属しているプロジェクトでの取り組みを紹介したいと思います。

バイセルではRuby on Railsを採用したプロダクトが多くありますが、 最近ではGoの採用事例が増えています。 私が所属するプロジェクトでもバックエンドにGoを採用しています。

Goでバックエンドを書いていく上で、 「Railsの時あったあれは、一体どうやれば実現できるんだろう?」 と悩んで取り組んだことがいろいろありました。 今回はそんな中でも単体テストに絞った事例をいくつか紹介したいと思います。

※この記事の前提となるGoバージョンは以下の通りです

  • version go1.17.1 linux/amd64

f:id:bst-tech:20211124125223p:plain

共通処理を行いたい

例えばテストデータの生成や認証処理など、 テストケースの前後やテストスイートの前後等で共通の処理を行いたいことがあると思います。

そういう場合、RailsでRSpecを採用しているプロジェクトでは例えばこのように書いていました。

RSpec.describe "テストスイート" do
  before :all do # テストスイート実行前の共通処理
  end

  after :all do # テストスイート実行後の共通処理
  end

  before :each do # テストケース実行前の共通処理
  end

  after :each do # テストケース実行後の共通処理
  end

  example "テストケース" do
  end
end

Goで上記を実現するために標準のtestingパッケージだけを使う場合、例えばTestMain内のRun()前後に処理を書いたり、 テスト関数の前後に処理を挿入する等の実装をする必要があります。

そこで今回はtestifyというライブラリの中の、suiteパッケージを利用しました。 suiteパッケージでは以下のようにして、テストの各所で共通処理を挟み込むことができます。

type MySuite struct {
 suite.Suite
}

func (s *MySuite) SetupSuite() {} // テストスイート実施前の処理
func (s *MySuite) TearDownSuite() {} // テストスイート終了後の処理
func (s *MySuite) SetupTest() {} // テストケース実施前の処理
func (s *MySuite) TearDownTest() {} // テストケース終了後の処理
func (s *MySuite) BeforeTest(suiteName, testName string) {} // テストケース実施前の処理
func (s *MySuite) AfterTest(suiteName, testName string) {} // テストケース終了後の処理

func (s *MySuite) TestExec() {
  s.Run("テストケース", func() {
  })
}

func TestMySuite(t *testing.T) {
  // テストスイートを実施
 suite.Run(t, new(MySuite))
}

DBのテストをしたい

DB接続があるテストをどうするのか、というのはいろいろ悩まされることがあると思います。 モック化したり、テスト用のDBに繋ぐようにしたり。

今回は弊社で採用した、テスト用DBに繋ぐ方法について紹介します。

テスト用のDBでテストするにあたって、必要な要件は以下の2つかと思います。

  1. テスト実行時だけテスト用のDBに接続する
  2. 適切なタイミングでテスト用のDBをクリーンアップする

1についてはRSpecでは以下のようにしてdatabase.ymlに記載すればテスト用のDBを利用できました。

test:
  <<: *default
  database: testdb

今回Goで同様のことを行うにあたって、ベタですが環境変数で接続先のDBを切り替えるように実装しました。

アプリ実行時

func main() {
  sqlDB, err := sql.Open("postgres", os.Getenv("DB_URL"))
  if err != nil {
    log.Fatal(err)
  }
}

テスト実行時

func (s *TestSuite) SetupSuite() {
  sqlDB, err := sql.Open("postgres", os.Getenv("TEST_DB_URL"))
  if err != nil {
    log.Fatal(err)
  }
}

2についてはRSpecでは以下のように設定すればexample内でトランザクションが張られて、終了後にロールバックが行われていました。

RSpec.configure do |config|
  config.use_transactional_fixtures = true
end

Goで同様のこと行うにあたって、弊社ではgo-txdbというライブラリを導入しました。 以下のようにすることで、トランザクション内でDBへのクエリを実行でき、最後にClose()を呼ぶところでロールバックされます。(テスト内でさらにネストしてトランザクションを張ることもできます)

txdb.Register("txdb", "postgres", os.Getenv("TEST_DB_URL")) // sqlにDB接続情報を持ったdriverを登録する

sqlDB, err := sql.Open("txdb", uuid.New().String()) // DBに接続する(トランザクション開始)
if err != nil {
  log.Fatal(err)
}

// ここにSQL実行するテストを書く

sqlDB.Close() // ロールバックされる

これを上で紹介したsuiteを組み合わせて以下のように共通処理として実装し、 各テストスイートで組み込み型として利用することで、 プロジェクト内の全てのテストケースでDBのクリーンアップが行われるようにしています。

var (
  sqlDB *sql.DB
)

type TestSuite struct {
  suite.Suite
}

func (s *TestSuite) SetupSuite() {
  txdb.Register("txdb", "postgres", os.Getenv("TEST_DB_URL"))
}

func (s *TestSuite) SetupTest() {
  sqlDB, err := sql.Open("txdb", uuid.New().String())
  if err != nil {
    log.Fatal(err)
  }
}

func (s *TestSuite) TearDownTest() {
  sqlDB.Close()
}

テストデータを準備したい

テストのためのデータ生成といのも大変面倒なものです。 RSpecを使用したプロジェクトでは、例えばFactoryBot等を使ってテストデータを生成していました。

FactoryBot.define do
  factory :sample do
    sequence(:id) {|n| n}
    name { '名前' }
  end
end
RSpec.describe Sample, type: :model
  describe 'Sampleテスト' do
    let!(:sample_data) { create(:sample) }
  end
end

Goで同様のことを行うにあたって、弊社ではfactory-goというライブラリを利用しました。 factory-goを使うことによって以下のようにFactoryBotに近い使い心地でテストデータを生成できます。

var SampleFactory = factory.NewFactory(
    &model.Sample{},
).SeqInt("ID", func(n int) (interface{}, error) {
    return n, nil
}).Attr("Name", func(args factory.Args) (interface{}, error) {
    return "名前", nil
})
func (s *MySuite) TestSample() {
  sampleData := factories.SampleFactory.MustCreate().(*model.Sample)
  sqlDB.Create(&sampleData)
}

外部連携をテストしたい

例えば外部のAPIを叩く処理があるときなど、テストの度に毎回APIを実行するわけにはいきません。 かといって連携自体をスキップしてしまうとその後の処理がテストされません。 なのでモック化を検討することになると思いますが、RSpecでは簡単に関数のモック化が行えていました。

以下のような外部連携があるクラスがあったとして、

class APIClient
  def new_client
    // 外部連携処理
    // ...
    client
  end
end

モック化はこのようにできていました。

before do
  allow_any_instance_of(APIClient).to receive(:new_client).and_return(client)
end

Goで同様のことを行うにあたって、弊社では上で紹介したtestifyの中のmockパッケージを利用しました。 以下のように書くことで関数をモック化でき、テストケース毎にモック関数の戻り値を変更できます。

以下のように外部連携がある処理をインターフェースとして定義します。

type APIConnector interface {
  NewClient() (APIClient, error)
}

type APIConnectorImpl struct {
}

func (i APIConnectorImpl) NewClient() (APIClient, error) {
  // 外部連携処理
  // ...

  return &client, err
}

定義したインターフェースに対応するモックを実装します。

type APIConnectorMock struct {
  Mock
}

func (m APIConnectorMock) NewClient() (APIClient, error) {
  result := m.Called()
  return result.Get(0).(APIClient), result.Error(1)
}

テスト時の使い方はこのような感じです。 Returnに好きな値を渡して関数の戻り値とすることができます。

apiConnector := new(APIConnectorMock)
apiConnector.On("NewClient").Return(&APIClient{}, nil) // 正常に戻り値返す場合
apiConnector.On("NewClient").Return(nil, errors.New("Error")) // エラーを返す場合

まとめ

いかがでしたでしょうか。 Goを新たに導入するにあたっては、今回紹介した単体テスト部分以外も含めて、基本的にはオープンソースの外部ライブラリを導入してやりたいことを解決する、ということがほとんどでした。 使いやすいパッケージ管理の仕組みと、素晴らしいライブラリがたくさんあるのはGoの強みだなと感じています。

最後にバイセルではエンジニアを募集しています。

少しでも興味のある方はぜひご連絡ください!

hrmos.co hrmos.co hrmos.co

明日の バイセルテクノロジーズ Advent Calendar 2021 は松榮さんからオークションプロダクトでの取り組みについて紹介されるのでそちらもぜひ併せて読んでみてください!