[DynamoDB][Play framework] Specs2とDynamoDBLocalによる結合テスト

2015.10.12

DynamoDBを結合テストする

DynamoDBへのアクセスに際して、単純なCRUD操作やConditional expressionを用いた更新を行うDBクライアントをSDKを介して実装することになると思いますが、それらのテストを行う場合にはDynamoDBLocalを用いて擬似的なDB環境を展開した上で結合テストを行うと、開発環境にアプリケーションをデプロイした際にDBとの結合部分でトラブルが少なくなると考えられます。

本記事ではScalaのアプリケーションWebフレームワークであるPlayframeworkとScalaのテスティングフレームワークであるSpecs2を用いてDynamoDBとの結合テストの方法を記述します。

本記事のサンプルコードはGithubリポジトリにあります。是非ご活用ください。

DynamoDBLocal

DynamoDB Localは実際のDynamoDBサービスをエミュレートしたアプリケーションとして機能します。

Mac OSX環境ではhomebrewを用いて以下のコマンドでインストールできます。

$ brew install dynamodb-local

下記コマンドでプロセスが立ち上がります。

$ dynamodb-local

DynamoDB Localはデフォルトでポート8000が割り当てられ、エンドポイントとして以下のURLを指定することで各SDKから用いれます。

http://localhost:8000

DynamoDB Localは -sharedDB フラグによって単一のデータベースファイルを参照できます。デフォルトではリージョンとクレデンシャルによって別のデータベースファイルが作られるため、それに応じたSDKからの設定が必要になりますが、テスト用の設定とステージング時の設定の分岐を最小限に済ませるために、-sharedDB フラグは有用です。

AWS SDK for Java による接続

ScalaでDynamoDBにアクセスする際にはDynamoDBのラッパー等を用いる方法もありますが、今回は AWS SDK for Java を用います。AWS公式がサポートしており、ドキュメントも手厚いためです。AWS SDK Javaを用いるために下記をbuild.sbtに追加します。

build.sbt

libraryDependencies += "com.amazonaws" % "aws-java-sdk" % "1.10.1"

今回サンプルとしてDynamoDBに保存したいデータは以下のUserケースクラスです。

app/domain/User.scala

package domain

/**
 * user entity
 * @param id identifier
 * @param name user name
 * @param age age
 */
case class User (
  id: String,
  name: String,
  age: Int
)

このケースクラスに対応したDynamoDBテーブルの定義Jsonファイルはこちらです。

users.json

{
  "AttributeDefinitions": [
    {
      "AttributeName": "user_id",
      "AttributeType": "S"
    }
  ],
  "ProvisionedThroughput": {
    "WriteCapacityUnits": 5,
    "ReadCapacityUnits": 15
  },
  "TableName": "users",
  "KeySchema": [
    {
      "KeyType": "HASH",
      "AttributeName": "user_id"
    }
  ]
}

こちらのJsonに対応したテーブルを下記コマンドでDynamoDB Localに生成します。

$ aws dynamodb create-table --cli-input-json file://users.json --endpoint-url http://localhost:8000

下記コマンドで生成したテーブルを確認します。

$ aws dynamodb describe-table --table-name users --endpoint-url http://localhost:8000
{
    "Table": {
        "AttributeDefinitions": [
            {
                "AttributeName": "user_id",
                "AttributeType": "S"
            }
        ],
        "ProvisionedThroughput": {
            "NumberOfDecreasesToday": 0,
            "WriteCapacityUnits": 5,
            "LastIncreaseDateTime": 0.0,
            "ReadCapacityUnits": 15,
            "LastDecreaseDateTime": 0.0
        },
        "TableSizeBytes": 0,
        "TableName": "users",
        "TableStatus": "ACTIVE",
        "KeySchema": [
            {
                "KeyType": "HASH",
                "AttributeName": "user_id"
            }
        ],
        "ItemCount": 0,
        "CreationDateTime": 1442918260.161
    }
}

以上の下準備の上でようやくユーザーのDAO(Data Access Object)を定義できます。今回はDynamoDBにcreateのみを行うメソッドを定義します。

app/infrastructure/dbclients/UserDBClient.scala

package infrastructure.dbclients

import javax.inject.Inject

import com.amazonaws.services.dynamodbv2.document.spec.PutItemSpec
import com.amazonaws.services.dynamodbv2.document.{Item, DynamoDB}

import domain.User
import infrastructure.dbclients.DynamoDBExecutionContext.context // -- (0)

import scala.concurrent.Future
import scala.Function.const

/**
 * user db client
 */
class UserDBClient @Inject() (dynamoDB: DynamoDB) { // -- (1)

  private val table = dynamoDB.getTable("users") // -- (2)

  /**
   * create item of user to dynamodb
   * @param user user entity
   * @return empty Future
   */
  def createIfNotExist(user: User): Future[Unit] = { // -- (3)
    Future {
      table.putItem( // -- (4)
        new PutItemSpec() // -- (5)
          .withItem(toItem(user)) // -- (6)
          .withConditionExpression("attribute_not_exists(user_id)") // -- (7)
      )
    }.transform(const(Unit), identity) // -- (8)
  }

  def toItem(user: User): Item = // -- (9)
    new Item()
      .withPrimaryKey("user_id", user.id) 
      .withString("name", user.name)
      .withInt("age", user.age)
}
  • -- (0) : DynamoDB用のExecutionContextを別途定義しています。
  • -- (1) : Userエンティティに対応するDynamoDBへのDAOです。外部からPlay2.4で導入されたDI(依存性注入)の仕組みを用いて AWS SDKのDynamoDBクラスの注入を行っています。注入されるDynamoDBクラスは環境ごとにエンドポイントを設定する必要があり、専用のProviderを定義します。その説明は次の節で行います。
  • -- (2) : DynamoDBに定義されたテーブルを取得します。
  • -- (3) : DynamoDBのput操作はcreate or updateのため、操作をcreateのみにしぼったメソッドを作りたいという要求をかなえるためにcreateIfNotExistメソッドを定義します。
  • -- (4) : テーブルに対してputの操作を行うSDKのメソッドです。
  • -- (5) : putItemメソッドは引数としてPutItemSpecと呼ばれるput操作の詳細を定義したデコレータオブジェクトをとるため、それを渡します。
  • -- (6) : PutItemSpecオブジェクトにユーザーに対応したアイテムを渡します。toItemメソッドでUserオブジェクトから対応アイテムの変換を行っています。
  • -- (7) : DynamoDBのConditional Expressionを定義し、user_idフィールドが存在しないアイテムに対してのみ(≒ user_idフィールドがキーのため、Userアイテムそのものがないときのみ)put操作を行うようにします。
  • -- (8) : putItemメソッドの返り値は結果を表すオブジェクトのため、const関数によってSuccessの時にはどのようなときもUnit型にFutureの中身を変換するようにします。identity関数によってFailureの時はそのままです。
  • -- (9) : ユーザーエンティティからアイテムに変換を行います。

今回はこちらのUserDBClientをテスト対象とします。

環境ごとのエンドポイント設定

ローカル環境、ステージング環境でエンドポイントを振り分けるために各環境ごとに別のconfファイルを用意します。その中でのdynamoDB設定の抜粋は以下の通りです。

conf/application.conf(ローカル環境用)

# DynamoDB configulation
dynamoDB {

  # Connection endpoint for DynamoDB local
  endPoint = "http://localhost:8000"
}

ローカル環境用にはDynamoDBのエンドポイントとして、"http://localhost:8000"を指定してます。 対してステージングでのエンドポイントはアプリケーションのデプロイされるリージョンに対応したものを指定する必要があります。

conf/application-stage.conf(ステージング環境用)

# DynamoDB configulation
dynamoDB {

  # Connection endpoint for DynamoDB in Japan region
  endPoint = "http://dynamodb.ap-northeast-1.amazonaws.com"
}

これらの設定ファイルに応じたDBへのアクセス機能を持つDynamoDBオブジェクトのプロバイダを次のように記述します。

app/infrastructure/dbclients/DynamoDBProvider.scala

package infrastructure.dbclients

import javax.inject.{Provider, Inject}

import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient
import com.amazonaws.services.dynamodbv2.document.DynamoDB
import play.api.Configuration

/**
 * DynamoDB provider
 */
class DynamoDBProvider @Inject()(config: Configuration) extends Provider[DynamoDB] { // -- (1)

  /**
   * create DynamoDB instance
   */
  override def get: DynamoDB = {
    val client = new AmazonDynamoDBClient()
    client.setEndpoint(config.getString("dynamoDB.endPoint").get) // -- (2)
    new DynamoDB(client)
  }
}
  • -- (1) : DI用のDynamoDBプロバイダクラスです。Configurationを注入していますが、注入すべきConfigurationはPlayが自動で解決してくれます。
  • -- (2) : Configurationのエンドポイントに応じた設定をAmazonDynamoDBClientクラスに設定します。この設定によって、Configurationファイルに記述されたエンドポイントに応じて参照されるDynamoDBが決められます。

さらに次のように依存解決用クラスを定義します。

app/modules/AppDependencyModule.scala

package modules

import com.amazonaws.services.dynamodbv2.document.DynamoDB
import com.google.inject.AbstractModule
import controllers.user.UserController
import infrastructure.dbclients.{UserDBClient, DynamoDBProvider}

/**
 * resolves DI
 */
class AppDependencyModule extends AbstractModule {

  def configure() = {
    bind(classOf[DynamoDB]).toProvider(classOf[DynamoDBProvider]) // -- (1)
  }
}
  • -- (1) : プロバイダがどのクラスを生成すべきかの定義を行います。

次のようにconfファイルに追記することで実行時DIが動作するようにします。

conf/application.conf(ローカル環境用)

play.modules.enabled += "modules.AppDependencyModule"

これらを定義することで、アプリケーション内のAWS SDK使用部分が正しいDynamoDBを見に行くようになります。

POST エンドポイントの用意

UserDBClientをControllerで使用して、Userに対応したJsonをPostするエンドポイントを定義してみます。

app/controllers/user/UserController.scala

package controllers.user

import javax.inject.Inject
import domain.User
import infrastructure.dbclients.UserDBClient
import play.api.mvc.{BodyParsers, Action, Controller}

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
import scala.Function.const

/**
 * controller for user endpoint
 * @param dbClient user db client
 */
class UserController @Inject() (dbClient: UserDBClient) extends Controller {

  import RequestConverter._ // -- (1)

  /**
   * action for creating user
   * @return
   */
  def post = Action.async(BodyParsers.parse.json) { req =>
    req.body.validate[User].fold( // -- (2)
      invalid => Future(BadRequest), // -- (3)
      user => dbClient.createIfNotExist(user).map(const(Ok)) // -- (4)
    )
  }
}
  • -- (1) : implicitなRead[User]型の変数が定義されたユーザー用リクエストコンバータオブジェクトをインポートします。
  • -- (2) : リクエストのJsonにUserエンティティに対応したバリデーションをかけ、以降の処理でどのようなレスポンスを返却するかを決定します。
  • -- (3) : バリデーションが失敗した時は 400 BadRequest を返却するようにします。
  • -- (4) : バリデーションが成功した時はDynamoDBにユーザーに対応したアイテムを生成し、その処理が成功した場合は 200 OK を返却するようにします。Condition Expressionの失敗時の挙動が以降の処理で定義されていませんが、必要になったら対応するConditionalCheckFailedExceptionをrecoverします。

このコントローラに定義したアクションに対応するエンドポイントを宣言します。

conf/routes

# User
POST       /api/v1/users        controllers.user.UserController.post

これらがアプリケーションの実装概要です。続いてこれらの実装に対してDynamoDBLocalを用いた結合テストを記述していきます。

Specs2によるDBクライアントの結合テスト

まず、DynamoDBLocalとUserDBClientを結合してテストしてみます。

test/infrastructure/dbclients/UserDBClientSpec.scala

package infrastructure.dbclients

import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient
import com.amazonaws.services.dynamodbv2.document.DynamoDB
import com.amazonaws.services.dynamodbv2.document.spec.ScanSpec
import domain.User
import org.junit.runner.RunWith
import org.specs2.mock.Mockito
import org.specs2.mutable.BeforeAfter
import org.specs2.runner.JUnitRunner
import play.api.test.PlaySpecification

import scala.collection.JavaConversions._

/**
 * test for user db client
 */
@RunWith(classOf[JUnitRunner])
class UserDBClientSpec extends PlaySpecification with Mockito {

  trait DBClients {
    val dynamoDBClient = new AmazonDynamoDBClient()
    dynamoDBClient.setEndpoint("http://localhost:8000") // -- (1)
    val dynamoDB = new DynamoDB(dynamoDBClient)
    val userTable = dynamoDB.getTable("users") // -- (2)
    val userDBClient = new UserDBClient(dynamoDB)
  }

  trait DBClientBeforeAfter extends BeforeAfter { // -- (3)
    self: DBClients =>

    override def before = ()

    override def after = { // -- (4)
      userTable.scan(new ScanSpec).toSeq.foreach { item =>
        userTable.deleteItem("user_id", item.getString("user_id")) // -- (5)
      }
    }
  }

  trait UserArgs {
    val user = User("userKey", "John", 50)
  }

  "Creating user" >> {

    "if table has no item with the same key as user" should { // -- (6)

      class Context extends DBClientBeforeAfter with UserArgs with DBClients // -- (7)

      "succeed" in new Context { // -- (8)
        val future = userDBClient.createIfNotExist(user)
        await(future) must not(throwA[Throwable])
      }

      "create one item to dynamodb" in new Context { // -- (9)
        val future = userDBClient.createIfNotExist(user)
        await(future)
        userTable.scan(new ScanSpec).toSeq.length mustEqual 1
      }

      "create item that matches with user" in new Context { // -- (10)
        val future = userDBClient.createIfNotExist(user)
        await(future)
        val item = userTable.getItem("user_id", user.id)

        item.getString("name") mustEqual user.name
        item.getInt("age") mustEqual user.age
      }
    }

    "if table has item with the same key as user" should { // -- (11)

      class OneItemContext extends DBClientBeforeAfter with UserArgs with DBClients { // -- (12)
        override def before = {
          val otherUser = User(user.id, "Chris", 40)
          val future = userDBClient.createIfNotExist(otherUser)
          await(future)
        }
      }

      "fail" in new OneItemContext { // -- (13)
        val future = userDBClient.createIfNotExist(user)
        await(future) must throwA[Throwable]
      }
    }
  }
}
  • -- (1) : DBClient生成時にInjectするDynamoDBオブジェクトのエンドポイントをDynamoDBLocal用にセットします。
  • -- (2) : テスト毎にアイテムを削除するため、ユーザーエンティティ用のDynamoDBLocalのテーブルを取得しておきます。
  • -- (3) : DynamoDBLocalに対して、コード化された結合テスト毎に必要な事前処理、事後処理を行うためにorg.specs2.mutable.BeforeAfter trait をExtendsします。このtraitをExtendsしたclass/traitについては各テストケースで -- (8) の通りに in new Context の形式で用いてテストケースの前にはbeforeメソッド、後にはafterメソッドが呼ばれるようになります。また、自分型アノテーションによって、DBClientsトレイトとミックスインされることを型レベルで保証しておきます。
  • -- (4) : 各テストケースの事後に実行されるテーブルのアイテム削除処理を定義します。
  • -- (5) : userテーブルの全アイテムを削除しています。
  • -- (6) : まず事前条件として、同一キーのアイテムがDynamoDBLocalのテーブルにない場合の振る舞いをテストします。
  • -- (7) : 同一キーアイテムがない場合の共通コンテキストを宣言しておきます。これは先ほど宣言したDBClientBeforeAfterを継承しています。
  • -- (8) : 非同期の保存処理が成功するテストケースです。
  • -- (9) : DynamoDBにアイテムが一つ追加されるテストケースです。
  • -- (10) : 引数に渡したuserインスタンスに対応したアイテムがDynamoDBに保存されるテストケースです。
  • -- (11) : 事前条件として同一キーアイテムがある場合の振る舞いをテストします。
  • -- (12) : 同一キーのある場合の共通コンテキストを宣言しておきます。
  • -- (13) : 同一キーがある場合の保存処理が失敗するテストケースです。

Specs2によるAPIシステム全体の結合テスト

次にDynamoDBLocalをバックで動かしながらのAPIシステム全体に対するE2Eテストを実施します。

test/appspecs/user/PostAppSpec.scala

package appspecs.user

import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient
import com.amazonaws.services.dynamodbv2.document.DynamoDB
import com.amazonaws.services.dynamodbv2.document.spec.ScanSpec
import org.junit.runner.RunWith
import org.specs2.mock.Mockito
import org.specs2.mutable.After
import org.specs2.runner.JUnitRunner
import play.api.inject._
import play.api.inject.guice.GuiceApplicationBuilder
import play.api.libs.json.Json
import play.api.http.MimeTypes.JSON
import play.api.test.{FakeRequest, PlaySpecification}

import scala.collection.JavaConversions._

/**
 * user post endpoint integration e2e test
 */
@RunWith(classOf[JUnitRunner])
class PostAppSpec extends PlaySpecification with Mockito {

  trait UserTable {
    val dynamoDBClient = new AmazonDynamoDBClient()
    dynamoDBClient.setEndpoint("http://localhost:8000")
    val dynamoDB = new DynamoDB(dynamoDBClient)
    val userTable = dynamoDB.getTable("users")
  }

  trait ApplicationWithTable {
    self: UserTable with After =>

    val application = new GuiceApplicationBuilder().build() // -- (1)

    override def after: Any = {
      userTable.scan(new ScanSpec).toSeq.foreach { item => 
        userTable.deleteItem("user_id", item.getString("user_id"))
      }
    }
  }

  class Context extends After with UserTable with ApplicationWithTable

  "user post endpoint" should {

    val userJson = Json.obj("id" -> "someFakeKey", "name" -> "Chris", "age" -> 20)
    val fakeRequest = // -- (2)
      FakeRequest(POST, controllers.user.routes.UserController.post().url)
        .withHeaders(CONTENT_TYPE -> JSON)
        .withJsonBody(userJson)

    "create response with 200 status code" in new Context { // -- (3)
      running(application) {
        val res = route(fakeRequest).get
        status(res) mustEqual OK
      }
    }

    "create user item" in new Context { // -- (4)
      running(application) { 
        val res = route(fakeRequest).get
        await(res) // -- (5)
        val item = userTable.getItem("user_id", "someFakeKey")
        item.getString("name") mustEqual "Chris"
        item.getInt("age") mustEqual 20
      }
    }
  }
}
  • -- (1) : ローカル環境下でテスト用アプリケーションを立ち上げ、テスト対象とします。
  • -- (2) : エンドポイントに投げるダミーのリクエストを構成します。以下、POSTメソッドで、UserControllerのpostメソッドに割り当てられたエンドポイントURLにContentType: application/jsonなリクエストとしてuserJsonに入った以下のJsonが投げられます
{
  "id" : "someFakeKey", 
  "name" : "Chris", 
  "age" : 20 
}
  • -- (3) : 200のステータスコードがリクエストで返される振る舞いをテストします。
  • -- (4) : リクエストによってDynamoDBにリクエストJsonに対応したアイテムが作られる振る舞いをテストします。
  • -- (5) : route(fakeRequest).get文で得られる返り値がFutureのため、awaitで実行の終了を待ってから以降のテスト成功条件を記述します。

デバグ用DBとの併用

前の二節にわたってのコードはローカルでデバグする際にはテスト用のDBとデバグ用のDBを分ける処理を書いていない為に、テストする度にデバグ時に生成したデータは消えてしまうことになります。

デバグ用データをテスト用とは独立して管理したい場合にはDynamoDBLocalの立ち上げの際に -port フラグ -dbPath フラグを駆使して、次のようにデバグ用のDynamoプロセスを立ち上げを行う方法が考えられます。

$ dynamodb-local -port ポート番号 -dbPath DBファイルパス

参考URL