Specs2 から ScalaTest への移行ガイド
Scalaプログラムのユニットテストを記述する場合、これら2つが候補にあがると思います。本記事では両者の比較や長所・短所への言及はしません。ユニットテストのシチュエーションごとに、Specs2 と ScalaTest のテストコード例を示し、おたがい書き換える際のたすけとなることを目的としています。
バージョン情報
ライブラリ | バージョン |
---|---|
Specs2 | PlayFramework 付属のもの |
PlayFramework | 2.5.12 |
org.scalatestplus.play | 1.5.1 |
Specs2 と ScalaTest の大局的な観点での違い
典型的なユニットテストを記述する分には、両者ともその能力にほとんど違いがありません。私が Specs2 から ScalaTest へ移行する際に感じた違いは以下のようなものです:
- Specs2 では同梱されている mock コンポーネントを利用するのに対し、ScalaTest ではJava製の Mockito を利用する
- Specs2 では同梱されている matcher コンポーネントを利用するのに対し、ScalaTest では Mockito の Matchers を利用する
なので、モックオブジェクトを作成する際や、値のアサーションを行う際に書き方が違うことを感じる程度でした。そういう意味ではまだまだ ScalaTest のパワーを引き出していないとは感じるものの、移行という点で同じテストが実行できればまずは十分と考えています。
なお、少なくともSBTプロジェクトにおいてはSpecs2とScalaTestの共存が可能です。テストコマンドもそのまま使えるので、CIツールのためのスクリプトファイルも修正する必要がありません。PlayFramework もその例に違わず共存できます。この特性を利用して、移行を考える場合は、一気にガツっと書き換えるのではなく、改修の都度書き換えていくようにすれば負担も少なく進められるでしょう。
それでは実際にテストコードを見ていきます。
想定するユニットテスト
まず大前提として、本記事のテストはすべてScalaTestのWordSpecに相当する書き方で記述します。こういうやつです。
"XMLからポイント情報への変換処理" should { "ステータスが正常な場合" should { "更新後のポイント情報が返却される" in { val res = parseResponse(mockResponse, mockRequest) res.right.get.value mustEqual (responseXml \\ "POINT").text.toLong } } }
それで、以下のケースに相当するユニットテストの書き方について述べます。
- 外部(データベースやサービスなど)へのアクセスが発生するコンポーネントに対するテスト
- ビジネスロジックのテスト
- アプリケーションのテスト
外部アクセスが発生するコンポーネントのテスト
外部APIへリクエストを送信してその結果をアプリケーションで利用したり、データベースにデータを保存したりといったことは、もはや当たり前のように実装される機能のひとつかと思います。いろいろなロジックから利用されるものであり、それでいて、外部サービスに依存することからユニットテストしにくい箇所であるともいえます。実装しているアプリケーションが本番で稼働するためには、非常に大事なテストといえます。このようなタイプのユニットテストのポイントは、
- なるべく実際に利用するサービス(データベースサーバやキャッシュサーバなど)をローカルに立ち上げた状態でテストを行う
- データベースに対するCRUDなど、ロジック自体はシンプルになるはずである。データのシリアライズ、デシリアライズ、エラー処理などが主な観点になる
- どうしてもローカルに用意することが難しい外部サービスなどはローカルサーバを立ち上げる
といった点があります。記述例を見ていきましょう。
キャッシュサーバに対するデータ保存のテスト
ローカルマシンにRedisを立ち上げて、キャッシュへの書き込みテストを書きます。ここでは、「メールアドレスをキャッシュに保存する」クラスがあるとして、そのクラスに対するテストを作成しています。
Specs2
class MailDAOSpec extends Specification with Mockito { trait WithRedis extends BeforeAfter { val pool = new RedisClientPool("localhost", 6379, database = DataBaseNumber.Registration) // --- ① override def before: Any = () override def after: Any = pool.withClient { redis => redis.flushdb // --- ② } } trait WithRedisError extends WithRedis { val mockRedis = mock[RedisClient] // --- ③ val mockException = mock[RedisConnectionException] mockRedis.set(any, any)(any) throws mockException class MockPool extends RedisClientPool("localhost", 6379, database = DataBaseNumber.Registration) { override def withClient[T](body: (RedisClient) => T): T = body(mockRedis) } override val pool = new MockPool override def before: Any = () override def after: Any = () } trait CommonBefore extends WithRedis { val mockMailAddressHash = "mockMailAddressHash" val mockMail = TemporaryMail( "mockConfirmationCode", "mockMailAddress" ) val mockTTL = 1122344 val mockConfig = MailDAOConfig(mockTTL) val dao = new MailDAO(mockConfig, pool) // --- ④ } "メールアドレス仮登録情報の新規作成・更新" should { "空のテーブル" should { class Context extends BeforeAfter with WithRedis with CommonBefore "追加は成功する" in new Context { dao.insertOrUpdate(mockMailAddressHash, mockMail) must not(throwA[Exception]) // --- ⑤ } "Redisに要素がひとつ保存される" in new Context { pool.withClient { redis => dao.insertOrUpdate(mockMailAddressHash, mockMail) val allKeys = redis.keys("*").get val count = allKeys.length count mustEqual 1 // --- ⑥ } } "メールアドレス仮登録情報に対応するJsonが保存される" in new Context { pool.withClient { redis => dao.insertOrUpdate(mockMailAddressHash, mockMail) val json = Json.parse(redis.get(mockMailAddressHash).get) (json \ "confirmationCode").as[String] mustEqual mockMail.confirmationCode //* --- ⑦ (json \ "email").as[String] mustEqual mockMail.mail } } } "Redisの処理が失敗する" should { class Context extends BeforeAfter with WithRedisError with CommonBefore "予期せぬエラーが発生する" in new Context { dao.insertOrUpdate(mockMailAddressHash, mockMail) must throwAn[Exception] } } } }
- ① RedisClientPoolの作成: 実際にローカルで起動しているRedisサーバを利用するようオブジェクトを定義します。
- ② データのクリーン: それぞれのテストが終わった際は、Redisサーバのデータを削除するようにします。
- ③ Redisがエラーを吐くことを再現するためのモックオブジェクト: mockとして定義し、その後にメソッドの戻り値を定義することで、常にエラーを吐くRedisを再現することができます。
- ④ 本体の定義: 定義したオブジェクトを使って、実処理となるクラスのインスタンスを作成します。本番のアプリケーションでは、ここはDIやファクトリメソッドなどしかるべき方法によって生成され、その接続先は本番のRedisサーバとなっているはずです。
- ⑤ 例外のテスト: 「例外が発生しないこと」はこのように書きます。
not()
を外せば逆に例外が発生することを確認するテストが書けます。 - ⑥ キャッシュテスト: 意図通り本体のメソッドを使ってキャッシュが保存されたことをテストしています。Redisから確認のためにデータを引っ張ってくる際は、本体のメソッドを使うのではなく、純正のメソッドを使っていることがポイントです。
- ⑦ 保存されたデータのテスト: シリアライザが正常に動いていることを確認するために、キャッシュされた中身まで確認しています。
ScalaTest
import org.mockito.Mockito._ import org.mockito.Matchers._ class MailDAOSpec extends PlaySpec with MockitoSugar { // --- ① trait WithRedis { val pool = new RedisClientPool("localhost", 6379, database = DataBaseNumber.Registration) def withClean(test: => Unit): Unit = { // --- ② try { test } finally pool.withClient { redis => redis.flushdb } } } trait WithRedisError extends WithRedis { val mockRedis = mock[RedisClient] val mockException = mock[RedisConnectionException] when(mockRedis.set(any[Any], any[Any])(any[Format])) thenThrow mockException // --- ③ class MockPool extends RedisClientPool("localhost", 6379, database = DataBaseNumber.Registration) { override def withClient[T](body: (RedisClient) => T): T = body(mockRedis) } override val pool = new MockPool } trait CommonBefore extends WithRedis { val mockMailAddressHash = "mockMailAddressHash" val mockMail = TemporaryMail( "mockConfirmationCode", "mockMailAddress" ) val mockTTL = 1122344 val mockConfig = MailDAOConfig(mockTTL) val dao = new MailDAO(mockConfig, pool) } "メールアドレス仮登録情報の新規作成・更新" should { "空のテーブル" should { class Context extends WithRedis with CommonBefore "追加は成功する" in new Context { withClean { noException must be thrownBy dao.insertOrUpdate(mockMailAddressHash, mockMail) } } "Redisに要素がひとつ保存される" in new Context { withClean { pool.withClient { redis => dao.insertOrUpdate(mockMailAddressHash, mockMail) val allKeys = redis.keys("*").get val count = allKeys.length count mustEqual 1 } } } "メールアドレス仮登録情報に対応するJSONが保存される" in new Context { withClean { pool.withClient { redis => dao.insertOrUpdate(mockMailAddressHash, mockMail) val json = Json.parse(redis.get(mockMailAddressHash).get) (json \ "confirmationCode").as[String] mustEqual mockMail.confirmationCode (json \ "email").as[String] mustEqual mockMail.mail } } } } "Redisの処理が失敗する" should { class Context extends WithRedisError with CommonBefore "予期せぬエラーが発生する" in new Context { an[Exception] must be thrownBy dao.insertOrUpdate(mockMailAddressHash, mockMail) //* --- ④ } } } }
- ① PlaySpec: PlayFrameworkに同梱されているScalaTestのためのクラスです。実体はテストに利用するクラスの詰め合わせです。
- ② 事前処理・事後処理: ScalaTest では、各テストの事前処理・事後処理を定義するためには、トップレベルのクラス(ここでは
MailDAOSpec
)にBeforeAndAfterAll
などをミックスインする必要があるようです。テストケースによって事前データを微妙に変える、ということをSpecs2で行っていたため、ScalaTestではFixtureと言う形でメソッドを使って再現するようにしました。ここは、もっとスマートなやり方があったら教えて欲しいです。 - ③ モックレスポンス:
when(mockObject.method) thenThrow Exception
またはwhen(mockObject.method) thenReturn "テスト戻り値"
のように書きます - ④ 例外のテスト: Specs2版と若干書き方が異なりますね。ただ、やっていることは同じです。
ビジネスロジックのテスト
先でテストした外部アクセスコンポーネントなどを組み合わせて、仕様を実現するレイヤのテストです。このレイヤのテストのポイントは以下です。
- 実際の外部アクセスのやりかたは関心ごとではないので、ビジネスロジックにかかわるところ以外は積極的にモック化する
- モック化したコンポーネントが「呼び出されること」「呼び出されないこと」の確認を積極的にこのテストで行う
if, while を始めとした制御構造が一番多く入りやすい箇所であるため、ホワイトボックスを意識したテストを行うことになると思います。
お知らせファイルをロードしてファイル内の「公開日時」が一番新しいものを返すモジュールのテスト
この場合、ファイルロード部分は関心事ではないので、うまくいった/失敗した前提でモック化します。そして、取得したデータがロジック通り組み合わせられていることを確認します。
Specs2
class UserNoticesLoaderSpec extends PlaySpecification with Mockito { trait CommonFixture { val mockAppMemberId = 123456789L // --- ① val mockWholeRepository = mock[WholeNoticeRepository] val mockWholeCache = mock[WholeNoticeCacheStore] val mockSegmentRepository = mock[SegmentNoticeRepository] val mockSegmentCache = mock[SegmentNoticeCacheStore] val mockSpecialDayRepository = mock[SpecialDayNoticeRepository] val mockSpecialDayCache = mock[SpecialDayNoticeCacheStore] val mockMetaRetriever = mock[ContentMetadataRetriever] val wholeLoader = new WholeNoticeLoader(mockWholeRepository, mockWholeCache) val segmentLoader = new SegmentNoticeLoader(mockSegmentRepository, mockSegmentCache) val specialDayLoader = new SpecialDayNoticeLoader(mockSpecialDayRepository, mockSpecialDayCache) val service = new UserNoticesLoader(wholeLoader, segmentLoader, specialDayLoader, mockMetaRetriever) { override protected def now(): ZonedDateTime = ZonedDateTime.parse("2016-09-30T08:40:51+09:00") } } "ログイン後ユーザお知らせ取得サービス" should { "セグメント情報が取得でき、全体お知らせ、セグメントお知らせ、スペシャルデーお知らせがキャッシュから取得できる" should { trait AllCacheContext extends Scope with CommonFixture { // --- ② mockWholeCache.find returns Some(mockWholeNotices) mockSegmentCache.find returns Some(mockSegmentNotices) mockSpecialDayCache.find returns Some(mockSpecialDayNotice) mockMetaRetriever.retrieve(anyLong) returns Future.successful(Some(metadata)) } "日時の降順、IDの降順にソートされている" in new AllCacheContext { val future = service.load(mockAppMemberId) val result = await(future) result.notices.map(_.id) must containTheSameElementsAs(List(150, 100, 7, 5, 6, 10, 9)) // --- ③ } "キャッシュ保存処理は呼び出されない" in new AllCacheContext { val future = service.load(mockAppMemberId) val result = await(future) there was no(mockWholeRepository).find // --- ④ no(mockSegmentRepository).find no(mockSpecialDayRepository).find no(mockSegmentCache).insertOrUpdate(any[Notices]) no(mockWholeCache).insertOrUpdate(any[Notices]) no(mockSpecialDayCache).insertOrUpdate(any[SpecialDay]) } } } }
- ① 外部アクセスコンポーネントのモック化: 関心事ではないのですべてモック化します
- ② モックコンポーネントのレスポンス定義: モック化したコンポーネントはすべて成功する前提で戻り値を定義します
- ③ ソート結果の確認: テストデータに対して意図通り合成とソートができているかを確認しています。
containTheSameElementsAs
はそのメソッド名が指し示すとおり、左のリスト(正確にはTraversableな)値が右のリストの要素およびその並び順と完全に一致していることを確認します。 - ④ 呼び出しの確認:
there was
メソッドで呼び出しを確認できます。以下に続く記述で呼び出されること/呼び出されないこと を指定することができます。
ScalaTest
class UserNoticesLoaderSpec extends PlaySpecApplication with MockitoSugar { trait CommonFixture { val mockAppMemberId = 123456789L val mockWholeRepository = mock[WholeNoticeRepository] val mockWholeCache = mock[WholeNoticeCacheStore] val mockSegmentRepository = mock[SegmentNoticeRepository] val mockSegmentCache = mock[SegmentNoticeCacheStore] val mockSpecialDayRepository = mock[SpecialDayNoticeRepository] val mockSpecialDayCache = mock[SpecialDayNoticeCacheStore] val mockMetaRetriever = mock[ContentMetadataRetriever] val wholeLoader = new WholeNoticeLoader(mockWholeRepository, mockWholeCache) val segmentLoader = new SegmentNoticeLoader(mockSegmentRepository, mockSegmentCache) val specialDayLoader = new SpecialDayNoticeLoader(mockSpecialDayRepository, mockSpecialDayCache) val service = new UserNoticesLoader(wholeLoader, segmentLoader, specialDayLoader, mockMetaRetriever) { override protected def now(): ZonedDateTime = ZonedDateTime.parse("2016-09-30T08:40:51+09:00") } } "ログイン後ユーザお知らせ取得サービス" should { "セグメント情報が取得でき、全体お知らせ、ユーザお知らせ、スペシャルデーお知らせがキャッシュから取得できる" should { trait AllCacheContext extends CommonFixture { when(mockWholeCache.find) thenReturn Some(mockWholeNotices) when(mockSegmentCache.find) thenReturn Some(mockSegmentNotices) when(mockSpecialDayCache.find) thenReturn Some(mockSpecialDayNotice) when(mockMetaRetriever.retrieve(anyLong)) thenReturn Future.successful(Some(metadata)) } "日時の降順、IDの降順にソートされている" in new AllCacheContext { val future = service.load(mockAppMemberId) val result = await(future) result.notices.map(_.id) must contain theSameElementsInOrderAs List(150, 100, 7, 5, 6, 10, 9) //_ --- ① } "キャッシュ保存処理は呼び出されない" in new AllCacheContext { val future = service.load(mockAppMemberId) val result = await(future) verify(mockWholeRepository, times(0)).find // --- ② verify(mockSegmentRepository, times(0)).find verify(mockSpecialDayRepository, times(0)).find verify(mockSegmentCache, times(0)).insertOrUpdate(any[Notices]) verify(mockWholeCache, times(0)).insertOrUpdate(any[Notices]) verify(mockSpecialDayCache, times(0)).insertOrUpdate(any[SpecialDay]) } } } }
- ① ソート結果の確認: テストデータに対して意図通り合成とソートができているかを確認しています。ScalaTest では
theSameElementsInOrderAs
を使います。 - ② 呼び出しの確認:
verify()
メソッドで呼び出しを確認できます。第二引数で呼び出されること/呼び出されないこと を指定することができます。
なお、ここで継承しているPlaySpecApplication
は、自作クラスです。Futureの結果を扱う場合や、HTTPレスポンスのステータスを抽出する場合はお決まりのクラス機能を使うことになるため、それらをミックスインした便利クラスとしてひとつ用意しています。
class PlaySpecApplication extends PlaySpec with PlayRunners with HeaderNames with Status with HttpProtocol with DefaultAwaitTimeout with ResultExtractors with Writeables with RouteInvokers with FutureAwaits with HttpVerbs
アプリケーションのテスト
DIモジュールや外部サービスをすべて動作させ、一気通貫で動きを確認するテストです。ここでは、ロジックや例外処理の細かい挙動よりも、「アプリケーションが動くこと」を確認することに重点を置きます。このテストをやっておくと、デプロイ後にしょうもないミスで動かなかった…修正してデプロイしなおし…といった事態を避けられる確率が上がります。
- アプリケーション実行時ならではの確認を行う。DIや、レスポンスコードが意図どおり帰ってくるか、など
- 基本的には実際の動作環境にのっとり外部サーバなど起動した状態でテストする。どうしても難しい場合はモックサーバを立てる
- 認証など必要な処理は事前にセッションを保存する処理を入れる
お知らせ最新日時を取得するAPIのテスト
Specs2
class ShowLatestNoticeTimestampAppSpec extends PlaySpecification with Mockito with GuiceApplicationSetup { sequential trait WithRedis { self: BeforeAfter => val pool = new RedisClientPool("localhost", 6379, database = DataBaseNumber.ContentsCache) override def before: Any = () override def after: Any = pool.withClient { redis => redis.flushdb } } trait CommonBefore extends WithRedis with BeforeAfter { import BasicTokenAuthorizationSettings._ import CommonSettings._ def runStub[T](result: => Result)(test: Application => T): T = { MockExternalServer.withRouter { case SirdGet(p"/notice.json") => Action(result) // --- ① } { implicit port => WsTestClient.withClient { client => val guiceApp = bindMailerModules(new GuiceApplicationBuilder()) .configure("notice.baseUrl" -> "") // --- ② .overrides(bind[WSClient].toInstance(client)) // --- ③ .build() running(app = guiceApp) { test(guiceApp) } } } } // --- ④ val fakeRequest = FakeRequest(GET, controllers.notice.routes.NoticeController.showLatestTimestamp().url) .withHeaders(ACCEPT -> MimeTypes.JSON, BasicTokenHeaderPair) } "お知らせ最新日時取得API" should { "お知らせが一件以上存在している" in { trait Before { val mockJson = MockWebNoticeJson.standardResponseJson val mockResult = Results.Ok(mockJson) } class Context extends CommonBefore with Before "ステータスコードは200" in new Context { runStub(mockResult) { app => val result = route(app, fakeRequest).get status(result) mustEqual OK } } "レスポンスJSONはdateフィールドを持つ" in new Context { runStub(mockResult) { app => val result = route(app, fakeRequest).get (contentAsJson(result) \ "date").asOpt[String] must beSome } } "レスポンスJSONはお知らせ情報リストの先頭要素と等価なdateフィールドを持つ" in new Context { val expectedExpression = (mockJson \ "notices" \\ "date").map(_.as[String]).head runStub(mockResult) { app => val result = route(app, fakeRequest).get (contentAsJson(result) \ "date").as[String] mustEqual expectedExpression // --- ⑤ } } } } }
- ① お知らせのファイルエミュレート処理: 実行環境においては、お知らせファイルは Amazon S3 上にあり、HTTP経由で取得することになっているのですが、これをローカル再現することが難しいため、テスト時だけ起動するモックサーバをここで定義しています。特定のパスに対して反応して特定のレスポンスを返すことになります。
- ② 設定の上書き: 本来ここはS3の Website Hosting URL にあるドメイン名が入るのですが、①で特定パスへのエミュレート処理を入れているためホスト名を削除しています。
- ③ WSClientのオーバーライド: 外部URLへアクセスする
WSClient
は、/notices.json
に反応して固定のレスポンスを返す特別製です。ゆえにこれを使うようDI設定をオーバーライドします。 - ④ リクエスト: テストで利用するリクエストを定義しています。
- ⑤ レスポンスの確認: 実際にアプリケーションを起動して、リクエストを送り、レスポンスが意図通りかテストをしています。ここではレスポンスJSONの特定の値が期待通りであることを確認しています。
ScalaTest
class ShowLatestNoticeTimestampAppSpec extends PlaySpecApplication with MockitoSugar with GuiceApplicationSetup { trait WithRedis { val pool = new RedisClientPool("localhost", 6379, database = DataBaseNumber.ContentsCache) def withCleanup(test: => Unit): Unit = { try { test } finally { pool.withClient { redis => redis.flushdb } } } trait CommonBefore extends WithRedis { import BasicTokenAuthorizationSettings._ import CommonSettings._ def runStub[T](result: => Result)(test: Application => T): T = { MockExternalServer.withRouter { case SirdGet(p"/notice.json") => Action(result) } { implicit port => WsTestClient.withClient { client => val guiceApp = bindMailerModules(new GuiceApplicationBuilder()) .configure("notice.baseUrl" -> "") .overrides(bind[WSClient].toInstance(client)) .build() running(app = guiceApp) { test(guiceApp) } } } } val fakeRequest = FakeRequest(GET, controllers.notice.routes.NoticeController.showLatestTimestamp().url) .withHeaders(ACCEPT -> MimeTypes.JSON, BasicTokenHeaderPair) } "お知らせ最新日時取得API" should { "お知らせが一件以上存在している" in { trait Before { val mockJson = MockWebNoticeJson.standardResponseJson val mockResult = Results.Ok(mockJson) } class Context extends CommonBefore with Before "ステータスコードは200" in new Context { withCleanup { runStub(mockResult) { app => val result = route(app, fakeRequest).get status(result) mustEqual OK } } } "レスポンスJSONはdateフィールドを持つ" in new Context { withCleanup { runStub(mockResult) { app => val result = route(app, fakeRequest).get (contentAsJson(result) \ "date").asOpt[String] mustBe defined } } } "レスポンスJSONはお知らせ情報リストの先頭要素と等価なdateフィールドを持つ" in new Context { withCleanup { val expectedExpression = (mockJson \ "notices" \\ "date").map(_.as[String]).head runStub(mockResult) { app => val result = route(app, fakeRequest).get (contentAsJson(result) \ "date").as[String] mustEqual expectedExpression } } } } } } }
事前・事後処理の使い方や、結果比較処理の書き方が異なるものの、Specs2版とほとんど違いがないことがわかります。その理由は、アプリケーションのテストはユニットテストから一歩外に出たレベルのテストであり、実行のためのモジュールはユニットテストツールではなくアプリケーションフレームワークやDIの仕組みなどに依存するから、と言えそうです。
まとめ
3パターンのテストにおいて、Specs2とScalaTestでの記述例を示しました。このレベルのテストであれば、能力的な差分はほぼなく、書き方の違いがほとんどです。とはいえ、プロパティベースのテストなど考え始めると、これからはドキュメントや利用例が充実しているScalaTestのほうが進めやすいかもしれません。皆様がユニットテストツールを選定する一助となれば幸いです。