はじめに
DI(Dependency Injection)のメリットはいくつかありますが、
Seasar - DI Container with AOP にあるように、
「お互いがインターフェースのみで会話する」という考え方が個人的に一番しっくりきます。
プログラムを作成する場合、1つの関数の中で処理が完結することは少なく、「他の関数の呼び出し」が必要になることが多いと思います。
ただ、Unitテストの時点では、テスト対象の関数の責務としては、
- 自分の責務を満たしているか
のみが観点となります。テスト対象の関数が「他の関数」を呼び出している時は
- 「他の関数」が呼び出させているか
- 呼び出し結果を元に適切な処理を行っているか
が観点として追加されます。ぶっちゃけて言えば、「他の関数」の内部実装はどうでも良いのです。インターフェース通りであればそれで問題ありません。
実際のところ、「他の関数」がまだ未完成だったり、外部システムにアクセスするような場合、自分の責務を満たしているか確認したいだけなら「他の関数」の実装をMockに置き換えるという手段が取られます。
DIを使用することでUnitテストの際には「他の関数」を「Mock」に差し替えることが簡単にできるようになります。実際にUnitテストするかどうかはさておき、テストしやすい状況を作るのは大事なことだと考えています。
ScalaでDIってどうなんでしょう
では、Scalaではどうか、という所ですが、私が知っている中ではWebアプリケーションのフレームワークでDIを推奨してそうなのは最近のPlay Frameworkくらいの認識です。後はCakeパターンを使う、というのが大部分のように見受けられました。
ただ、私の頭ではCakeパターンを「簡単だ」とも思えず、「まぁ、簡単なシステムだからDIとか考えなくて良いや」と逃げてきました。
ServiceやDaoはobjectにしてその場をしのいでいたのです。
ですが最近、第何次かはわかりませんがDIの話が盛り上がっている気がしますし、
「object直接呼び出しで本当にそれで良いの?」と心に問いかけてくるもう一人の自分の声が無視できなくなってきました。
**「テスタビリティが悪いまま放置してdeveloperを名乗っていいのか」**という気持ちが沸々と湧いてきたので勢いで書いてみた次第です。少し長いですが、お付き合いください。
DIコンテナの導入
SkinnyFrameworkを使用した今のままでもScalaMockを使うことでobjectのMockも作れなくは無さそうですがなんとなく面倒そうです。
Scalaやってる人がDIやる時の「普通」はCakeパターンなのかもしれませんが、私のような凡人にはDIコンテナを使った方がとっつきやすいです。
ということで、Guiceを使用することにしました。
私がシステムを作る時の前提条件
Webシステムのサーバサイドで考えた時、
- Controller
- ブラウザからのリクエストを受ける
- 呼べるのはService
- Service
- Daoを呼んだりデータをごにょごにょしてControllerへ返す
- 呼べるのはDao / Service
- Dao
- RDBMSへSQLを発行する
のように責務を限定して論理的にレイヤを設け、そのレイヤ間の呼び出しの際にDIを使用します1 。
traitと実装クラスは1:1の関係で状態を持たせず、Singletonでメモリを使わないように心がけます。
レイヤ呼び出し時の引数としては基本インスタンスが作りやすいものとし(POJO的な奴)、HttpRequest等は禁止という感じにします。
Scalaを使用していますが、「Javaでやるならこうなっちゃうかもね」という構成です2。
ソースコード
Dao
case class Staff(
id: Long,
staffName: String
)
trait StaffDao {
def create(entity:Staff)(implicit s:DBSession):Long
def update(entity:Staff)(implicit s:DBSession):Long
def findById(id:Long)(implicit s:DBSession):Option[Staff]
def deleteById(id:Long)(implicit s:DBSession):Int
}
object Staff extends SkinnyCRUDMapper[Staff] with StaffDao {
override lazy val tableName = "staff"
override lazy val defaultAlias = createAlias("s")
override def extract(rs: WrappedResultSet, rn: ResultName[Staff]): Staff = new Staff(
id = rs.get(rn.id),
staffName = rs.get(rn.staffName)
)
override def create(entity: Staff)(implicit session: DBSession): Long = {
Staff.createWithAttributes(
'staffName -> entity.staffName
)
}
override def update(entity: Staff)(implicit session: DBSession): Long = {
Staff.updateById(entity.id).withAttributes(
'staffName -> entity.staffName
)
}
}
Skinny-ORMを使用しているので、こんな感じになります。
定義したtraitの実装はclassではなくobjectになります。DIに関する記述はありません。
Service
trait StaffService {
@Inject
private val staffDao:StaffDao = null
def createStaff(name:String)(implicit session:DBSession) = {
staffDao.create(Staff(id = -1L, staffName = name))
}
def getStaff(id:Long)(implicit session:DBSession) = {
staffDao.findById(id)
}
}
class StaffServiceImpl extends StaffService
Daoのtraitがフィールドにいます。Field InjectionでGuiceにインジェクションしてもらいます。
trait側にデフォルト実装持たせたかったので、コンストラクタインジェクションは使っていません。好みの問題ですが、システム内で統一はした方が良いです。
Controller
class RootController extends ApplicationController with DiInjector {
def index = {
DB.localTx { implicit session =>
val staffService = injector.getInstance(classOf[StaffService])
val id = staffService.createStaff("DIも良いもんだぜ!" + new Date())
println(id)
render("/root/index")
}
}
}
SkinnyFrameworkのControllerです。DiInjectorというtraitをmixinしています。
val staffService = injector.getInstance(classOf[StaffService])
の箇所がGuiceから当該Serviceのインスタンスを取得する箇所になります。該当ServiceがDaoに依存する場合も芋づる式にInjectされます。
Module
class BindModule extends AbstractModule{
override def configure() = {
//Serviceはclassなのでto、Singleton
bind(classOf[StaffService]).to(classOf[StaffServiceImpl]).in(classOf[Singleton])
//Daoは、objectなので、インスタンスを設定
bind(classOf[StaffDao]).toInstance(Staff)
}
}
object BindModule {
val injector = Guice.createInjector(new BindModule())
}
Guiceに、「こんな関連でtraitとインスタンスを管理してね」と設定する箇所になります。
Serviceはclass、Daoはobjectなので設定方法が異なります。
Injector
trait DiInjector {
val injector = BindModule.injector
}
Moduleのインスタンスを管理するtraitです。Controllerでmixinすることを想定しています。
わざわざtrait化しているのはUnitテストしやすいようにです。
Daoテストクラス
class StaffSpec extends fixture.FunSpec with AutoRollback with Matchers with DBSettings with BeforeAndAfter {
@Inject
private val staffDao:StaffDao = null
before {
BindModule.injector.injectMembers(this)
}
describe("create") {
it("社員を作成し、更新して、削除するテスト") { implicit session =>
val createdId = staffDao.create(Staff(id=1L, staffName = "とーろく社員!!"))
staffDao.findById(createdId).get.staffName should be ("とーろく社員!!")
val staffDi = Staff(id=createdId, staffName = "更新後社員名!!")
staffDao.update(staffDi)
staffDao.findById(createdId).get.staffName should be ("更新後社員名!!")
staffDao.deleteById(createdId)
staffDao.findById(createdId) should be (None)
}
}
}
個人的にはDaoのテストは実際にSQLを発行させたほうが良いと思っているので、Mockは使いません。
before {
BindModule.injector.injectMembers(this)
}
の箇所で、Injectアノテーションが付与されているメンバ変数にInjectionしてもらい、それをテストメソッドで呼び出し、結果どうだったかの検証を行います。
Serviceテストクラス
class StaffServiceSpec extends fixture.FunSpec with AutoRollback with Matchers with DBSettings with BeforeAndAfter with MockitoSugar {
@Inject
private val staffService:StaffService = null
before {
Guice.createInjector(
Modules.`override`(new BindModule()).`with`(createMockModule())
).injectMembers(this)
}
describe("getStaff") {
it("DB取得") { implicit session =>
val actual = staffService.getStaff(2L).get
actual.id should be (2L)
actual.staffName should be ("Mock社員ン!")
staffService.getStaff(1L) should be (None)
}
}
private def createMockModule() = new AbstractModule() {
override def configure() = {
val m = mock[StaffDao]
when(m.findById(org.mockito.Matchers.eq(1L))(org.mockito.Matchers.anyObject())).thenReturn(None)
when(m.findById(org.mockito.Matchers.eq(2L))(org.mockito.Matchers.anyObject())).thenReturn(Option(
Staff(id=2L, staffName="Mock社員ン!")
))
bind(classOf[StaffDao]).toInstance(m)
}
}
}
このServiceの実装はDaoに依存するのですが、それをMockに置き換えることで、SQLの発行無しにテストを行っています。
createMockModule
でDaoをMock化し、traitとインスタンスの紐付けを行い、
before {
Guice.createInjector(
Modules.`override`(new BindModule()).`with`(createMockModule())
).injectMembers(this)
}
で、設定を上書きつつ、Injectionします。Service呼び出しの結果より、Dao処理がMockに置き換わっていることが確認できます。
Controllerテストクラス
class RootControllerSpec extends FunSpec with Matchers with MockitoSugar with DBSettings {
describe("RootController") {
it("shows top page") {
val controller = new RootController with MockController with MockDiInjector
controller.index
controller.contentType should equal("text/html; charset=utf-8")
controller.renderCall.map(_.path) should equal(Some("/root/index"))
}
}
trait MockDiInjector extends DiInjector {
override val injector = Guice.createInjector(
Modules.`override`(new BindModule()).`with`(createMockModule())
)
}
private def createMockModule() = new AbstractModule() {
override def configure() = {
val m = mock[StaffService]
when(m.createStaff(anyString())(anyObject())).thenReturn(123456L)
bind(classOf[StaffService]).toInstance(m)
}
}
}
createMockModule
でServiceをMock化し、traitとインスタンスの紐付けを行い、
trait MockDiInjector extends DiInjector {
override val injector = Guice.createInjector(
Modules.`override`(new BindModule()).`with`(createMockModule())
)
}
で、DiInjectorのModuleインスタンスを上書き、
val controller = new RootController with MockController with MockDiInjector
で、MockController
とMockDiInjector
をmixinしてテスト対象のControllerのインスタンスを生成し、呼び出します。
まとめ
素のSkinnyFramework + Guiceを使ってDIを実現してみました。
ここまで書いててなんですが、私は外部のシステムに接続する場合くらいしかMock化しないようにしています。
Mockだらけになると管理が大変ですし、複雑なMockを作るとメンテナンスコストもバカになりません。
Daoはテスト用のスキーマを用意して実際のSQLを発行させればいいじゃん、と思っているので、どこまで時間・リソースをかけられるか鑑みたうえで方針を決めた方が良いと思います3。
AWSのサービスを使っている場合でも疑似的にローカルで再現できるものもありますし、
テスト用にAWSのサービスを契約して実際に呼ぶでも良いかもしれません。
このあたりの方針は、プロジェクト/プロダクトでどうするかを事前に合意を取っておく必要があるでしょう。
ただ、DIの仕組みを前もって導入しておくことで、テストしたくなった時にすぐMockに差し替えることができます。それだけでも充分な気がします4。
最後に
SkinnyFramework、DIコンテナとの連携あるじゃん...5