Help us understand the problem. What is going on with this article?

インメモリDBを使ってRDBアクセスのあるコードをユニットテストする(Golang+Gormを例に)

More than 1 year has passed since last update.

はじめに

Webアプリ等のコードをユニットテストする際に、RDBアクセスがあるロジックをどのようにテストするかは、チームにより方針が分かれがちな問題だと思います。

いろいろなやり方があり、それぞれに賛否両論があるらしいのはなんとなく知っています。
その中で、主に機能性をテストしたいユニットテストにおいては、私個人的にはインメモリDBを使ってやる方法がよい(場合が多い)と思っています。インメモリDBとは、その名の通りメモリ内だけで動くDBのことです。

理由としては、

  • 1つのテストに閉じた世界でデータを扱うことができる
    • テストの内容が1箇所にまとまっていて見やすい
    • 他のテストとの兼ね合いを気にしなくてよい
  • 環境構築不要
  • 早い?(たぶん)

と考えるからです。

これをどのように実現するかについて、この記事で書いていこうと思います。

SQLiteのインメモリDBを使う

私自身、最近まで知らなかったのですが、SQLiteにはインメモリDBの機能があります。

SQLite公式ドキュメントより引用

If the filename is ":memory:", then a private, temporary in-memory database is created for the connection. This in-memory database will vanish when the database connection is closed.

この文章に、すべての説明が凝縮されています。

これを試すために、Golang+Gormを題材にして簡単なCRUDを書き、そのインメモリDBを使ってユニットテストを書いてみようと思います。

サンプルアプリ:Golang+GormでのシンプルCRUD

まず、テーブルのレコードをマッピングするための構造体を定義します。

crud.go
type Todo struct {
    ID       int    `gorm:"AUTO_INCREMENT;primary_key"`
    Category string `gorm:"not null;size:8"`
    Content  string `gorm:"not null;size:255"`
}

シンプルなCRUD用の関数を作ります。
短くするために、ここではCreate/Deleteを省略してRead/Updateのみ記載します。

crud.go
func Read(db *gorm.DB, todo *Todo, id int) error {
    return db.Find(todo, "id = ?", id).Error
}

func Update(db *gorm.DB, id int, category string, content string) error {
    return db.Model(&Todo{}).Where("id = ?", id).Updates(&Todo{Category: category, Content: content}).Error
}

ここでは、RDBアクセスをするための *gorm.DB 型の変数を引数で受け取るようにしています。

*gorm.DB 型の変数を手に入れる方法についても色々あるとは思います。
ただ、DI(Dependency Injection)的に受け取れるようにしておき、具体的なRDBや接続先などは外から指定できるようにしておくほうが、少なくともユニットテストの観点からは楽だと思います。

サンプルのユニットテスト

まず、インメモリDBを作成する関数です。
SQLiteドキュメントの指示どおりにファイル名の部分を:memory:と指定します。

crud_test.go
func createInMemoryDb(t *testing.T) *gorm.DB {
    db, err := gorm.Open("sqlite3", ":memory:")
    if err != nil {
        t.Fatalf("db open error: %v", err)
    }
    return db
}

このようにして具体性を与えた*gorm.DB型の変数を、テスト対象の関数に食わせてユニットテストをしていきます。

Read/Updateに対するユニットテストです。

crud_test.go
func TestRead(t *testing.T) {
    db := createInMemoryDb(t)
    defer db.Close()
    fatalIfError(t, db.AutoMigrate(&Todo{}).Error)
    fatalIfError(t, db.Create(&Todo{Category: "first", Content: "first todo"}).Error)
    fatalIfError(t, db.Create(&Todo{Category: "second", Content: "second todo"}).Error)

    var todo Todo
    fatalIfError(t, Read(db, &todo, 1))

    assertEqual(t, "first", todo.Category)
    assertEqual(t, "first todo", todo.Content)
}

func TestUpdate(t *testing.T) {
    db := createInMemoryDb(t)
    defer db.Close()
    fatalIfError(t, db.AutoMigrate(&Todo{}).Error)
    fatalIfError(t, db.Create(&Todo{Category: "first", Content: "first todo"}).Error)
    fatalIfError(t, db.Create(&Todo{Category: "second", Content: "second todo"}).Error)

    fatalIfError(t, Update(db, 1, "first:updated", "first todo:updated"))

    var first, second Todo
    fatalIfError(t, db.Find(&first, "id = 1").Error)
    fatalIfError(t, db.Find(&second, "id = 2").Error)
    assertEqual(t, "first:updated", first.Category)
    assertEqual(t, "first todo:updated", first.Content)
    assertEqual(t, "second", second.Category)
}

func fatalIfError(t *testing.T, err error) {
    if err != nil {
        t.Fatalf("fatal: %v\n", err)
    }
}

func assertEqual(t *testing.T, expected interface{}, actual interface{}) {
    if expected != actual {
        t.Errorf("test failure. expected %v, actual: %v\n", expected, actual)
    }
}

テスト関数には、コードの塊が3つあり、

  • DB/テーブルの準備
  • テスト対象の関数の実行
  • 実行結果の確認

をやっているのが見て取れると思います。

また、最初に挙げた、

  • テストに閉じた世界でデータを扱うことができる
    • テストの内容が1箇所にまとまっていて見やすい
    • 他のテストとの兼ね合いを気にしなくてよい
  • 環境構築不要

も感じることができると思います。

個人的な見解

この方法はもちろん万能ではない、です。

SQLiteを使っているので、RDBのとあるプロダクトにしかない機能や仕様に依存している場合には、この方法は使うことはできません。
また、プロジェクトによっては、ローカルPC上に1つ、ユニットテスト用のDBを立ててしまう方が楽な場合もあるかもしれません。
テストの興味の対象を考えると、モックを使ってやってしまったほうが断然早いこともあるでしょう。

しかし、必ずしもすべてを統一方針でやらなくてよい場合がほとんどだと思います。
その際は、適材適所の1つとして選択するのもありです。

また、これはGolang特有のものではなく、SQLiteの機能なのでいろいろな言語から使えるはずです。

最後に

最後まで読んでいただき、ありがとうございました!

今回のサンプルコードの全体はGithubに置いてあります。よろしければ参考にしてください。

また、この点がよくない、もっと良い方法がある等ありましたら、コメント歓迎です!

mrngsht
SIerを経てフリーランスに転身しました。WONDAモーニングショット(缶コーヒー)をほぼ毎日飲んでいましたが、健康のためにやめました。代わりにワインの勉強始めました。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away