はじめに
本記事は連載形式で執筆しています。ここまでの記事は以下の通り
①の記事 ~howとwhatの分離~
②の記事 ~トランザクションモナド~
③の記事 ~ScalikeJDBCによる実装~
今回はScalatest(3.0.1)を使って、アプリケーション層のテストを書いていきます。
本連載の例だとUserServiceのテストを書いていく形ですね。
今まで使っていた create3Timesメソッド
だとエラーが置きづらくテストがつまらないので、今回はID検索による findByIdメソッド
を使って解説していきます。
IDで検索して、ヒットしたらユーザー情報を返し、なかったらNotFoundエラーを返すような仕組みです。
最終ゴール
このような形でテストすることをゴールに解説していきます。
Futureをawaitしたりもせず、O/Mマッパー特有のモックも無理に作らなくていいので楽ですね。
class UserServiceSpec extends FunSpec with MustMatchers {
val service = new UserService(
// テスト用のMockクラスをDIする
userRepository = new MockUserRepository
)(
// テスト用のMockクラスをDIする
transactionRunner = new MockTransactionRunner
)
describe("findById") {
describe("when a user exists") {
it("should return a user") {
val expected = MockTransaction(\/-(MockUser()))
service.findById("1") mustBe expected
}
}
describe("when a user does not exist") {
it("should return a NotFound error") {
val expected = MockTransaction(-\/(DomainError.NotFound))
service.findById("NotFoundId") mustBe expected
}
}
}
}
MockTransactionの実装
まずはTransactionクラスのMockクラスを作成します。
case class MockTransaction[A](
execute: () => DomainError \/ A
) extends Transaction[A] {
override def map[B](f: A => B): Transaction[B] = {
val exec = () => execute().map(f)
MockTransaction(exec)
}
override def flatMap[B](f: A => Transaction[B]): Transaction[B] = {
val exec = () => execute().map(f).flatMap(_.asInstanceOf[MockTransaction[B]].execute())
MockTransaction(exec)
}
}
object MockTransaction {
def from[A](execute: () => A): MockTransaction[A] = {
val exec = () => \/-(execute())
MockTransaction(exec)
}
}
Transactionの実装によって評価タイミングをずらしたくないので、MockTransactionもScalikeJDBCTransaction同様に、関数を内包するようにします。
MockTransactionRunner
ScalikeJDBCなどの実装とほぼ同様でTransactionRunnerクラスを実装していきます。
class MockTransactionRunner extends TransactionRunner {
override def run[A](transaction: Transaction[A]) = SyncResult(transaction.asInstanceOf[MockTransaction[A]].execute())
}
MockUserRepository
Mockなので中身は適当に作っていきます。
ここではIDが"NotFoundId"ならNoneを返すようことにします。
class MockUserRepository extends UserRepository {
// Idクラスについての実装は省略します。Stringの値を持つ値クラスです。
override def find(userId: Id[User]): Transaction[Option[User]] = {
userId match {
case Id("NotFoundId") => MockTransaction(\/-(None))
case _ => MockTransaction(\/-(Some(MockUser())))
}
}
}
// Userクラスのモックインスタンスを返してくれるオブジェクトクラス
object MockUser {
def apply(
// 中略
): User = {
// 中略
}
}
テスト対象
今回はこのコードをテストします。
class UserService @Inject()(
userRepository: UserRepository
)(
implicit runner: TransactionRunner
) extends ErrorHandler {
def find(userId: Id[User]): Result[User] = {
userRepository.findById(userId) ifNotExists DomainError.NotFound
}
}
色々コードをはしょっていますが、ちょっとだけ補足↓
// scalazのToOptionOpsを使用しています。
trait ErrorHandler extends ToOptionOps {
implicit class TransactionOptionErrorHandler[A](transactionOpt: Transaction[Option[A]]) {
def ifNotExists(f: => DomainError)(implicit builder: TransactionBuilder): Transaction[A] = {
transactionOpt.flatMap(opt => builder.build(opt \/> f))
}
}
}
sealed trait DomainError {
val code: String
val message: String
}
object DomainError {
case object NotFound {
val code = "error.NotFound"
val message = "見つからんでソレ"
}
}
いざテスト!
class UserServiceSpec extends FunSpec with MustMatchers {
val service = new UserService(
userRepository = new MockUserRepository
)(
transactionRunner = new MockTransactionRunner
)
describe("findById") {
describe("when a user exists") {
it("should return a user") {
val expected = MockTransaction(\/-(MockUser()))
service.findById("1") mustBe expected
}
}
describe("when a user does not exist") {
it("should return a NotFound error") {
val expected = MockTransaction(-\/(DomainError.NotFound))
// "NotFoundId"を引数にとったのでNoneを返す。
// 説明不足で申し訳ないですが、ここはString <=> IdでISO変換してますs。
service.findById("NotFoundId") mustBe expected
}
}
}
}
いかかがでしたでしょうか?
O/Rマッパーの処理を隠蔽することによって純粋なロジックだけをテストできるようになりました。
一気に書き上げるまではここらへんまでにして、また需要がありそうなら、Slickなどの他のO/Rマッパーの実装や、S3などの他データベースの実装なども書いていきたいです。
また、応用すればマイクロサービス化した別サービスへのアクセスも可能です。
ではでは、ここまで読んでいただいた方、ありがとうございました!
今後とも宜しくお願い致します!