9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ウェブクルーAdvent Calendar 2023

Day 4

Scala, PlayFramework, Mockitoを使った自動テスト導入

Last updated at Posted at 2023-12-03

この記事はウェブクルー Advent Calendar 2023 4日目の記事です。
昨日は@reon_kunishi_wc さんのChart.jsで作った円形グラフの値をスライダーで操作するでした!

はじめに

この記事では、ScalaとPlayFrameworkを使用した開発プロジェクトで自動テストを導入した経験を共有します。
具体的には、PlaySpecとMockitoを使用したテストコードの書き方や、自動テストを書く際に遭遇した問題とその解決策について詳しく説明します。

プロジェクトの背景

私たちのチームでは、フロントエンドとバックエンドを一体化したWebアプリケーションの開発・運用・保守を行っています。
開発言語はScala、フレームワークはPlayFrameworkを使用し、CI/CDツールとしてCircleCIを利用して、Dockerコンテナをビルド・実行し、クラウドにデプロイしています。

環境

Scala 2.12.9
PlayFramework 2.8
sbt 1.4.3

自動テスト導入の必要性

自動テストの導入は、開発プロセスを効率化し、品質を確保するために重要です。
しかし、自動テストがない状態では、新たな機能の追加や既存機能の修正が困難になります。
そのため、私たちは自動テストの導入を決定しました。
テストスイートにはPlaySpec、モッキングにはMockitoを使用します。

自動テスト導入の流れ

自動テストの導入は、以下のステップで行いました。

  1. リファクタリング:依存関係を一方向にするため、入出力を再定義しました
  2. テストケースの作成:仕様に対して適切なテストを行うため、必要なデータを考えました
  3. テストコードの作成:MockitoとPlaySpecを使用してテストコードを作成しました
  4. CircleCIでの自動実行設定:sbt runをCircleCIのワークフローに組み込みました

今回は「テストコードの作成」の部分について重点的に説明していこうと思います。

テストコードの書き方

テストコードの作成には、以下のステップを踏みました。

  1. メソッドごとに仕様を定義
  2. テストケースの作成:仕様について、正常系・境界値を確認
  3. テストデータの作成:Entityを生成
  4. Mockの作成:RepositoryのMockを作成し、入出力を定義
    • Mockについては後述します
  5. テストコードの作成:Mockが正しく設定できていれば、メソッドを呼び出して結果を確認

テスト作成で詰まったところ

テスト作成中には、MockitoによるMockの書き方やverifyでの回数測定など、いくつかの問題に遭遇しました。それぞれの問題と解決策について詳しく説明します。

Mockの基本的な設定方法

テストについて触れるのが初めてで、概念を学ぶところからスタートしたため、そもそもMockは何をするものなのか分からなくて設定に苦労した。
Mockとは、「今テストしようとしてるメソッドのみにテスト範囲を絞るために、メソッド内で利用してる他のクラスのメソッドを、『絶対に正しい値を返す』ように固定値で設定する」ための仕組みです。

具体的なコードで書き方を見てみましょう。

doReturn(Future.successful(Some(1L)), Nil: _*).when(orderRepositoryMock).insert(any[Order])
// orderRepository.insert(order: Order): Future[Option[Long]] に対するモック
// テスト中でorderRepository.insert(order: Order)が呼ばれた時、Future.successful(Some(1L))を返す、という読み方

コード内のコメントにも書きましたが、上記は

「テスト中でorderRepository.insert(order: Order)が呼ばれた時、Future.successful(Some(1L))を返す」

という設定をしています。
このorderRepository.insertは、今テストしようとしているメソッドの中で呼ばれている、OrderRepositoryという別クラスのメソッドです。
実際のアプリケーションで動く際は、入力に対してなんらかの処理をした結果Future.successful(Some(1L))を返しますが、実際のアプリケーションではなんらかの処理が正しく動作する保証はありません。
すると、今回のテストで失敗したとき、このメソッドのバグなのか依存先のメソッドのバグなのかが分からなくなります。
そのため、今回このテストだけ、なんらかの処理は考えずに固定値を返すことで「絶対に正しい値を返す」を実現します。

Mockの設定でエディタではエラーにならないが実行するとエラーになるものがある

Mockitoでは、モックの設定は2通りの書き方があります。

  1. doReturn(返り値).when(mockエンティティ).method
  2. when(Mockエンティティ.method).thenReturn(返り値)

methodを()内に含めるかどうかが微妙に違っているのでtypoには気を付けてください。
後者の書き方では返り値が無い(Unitを返す)メソッドに対するモッキングができないようだったので、前者のdoReturn方式(仮称)を採用しました。
しかし、実際に上記の記法で書くと、エラーが発生します。

doReturn(Future.successful(Some(1L))).when(orderRepositoryMock).insert(any[DocumentRequestOrder])

// errorが発生

UseCaseSpec.scala:113:5: ambiguous reference to overloaded definition,
[error] both method doReturn in class Mockito of type (x$1: Any, x$2: Object*)org.mockito.stubbing.Stubber
[error] and  method doReturn in class Mockito of type (x$1: Any)org.mockito.stubbing.Stubber
[error] match argument types (scala.concurrent.Future[Some[Long]])   

エディタではエラーにならないけどコンパイルエラーになって困りました。

対処法は、具体的なコードの例のように、doReturnの引数にNilを含めます。
これはもうおまじないだと思うのが良いと思います。

doReturn(Future.successful(Some(1L)), Nil: _*).when(orderRepositoryMock).insert(any[Order])
// orderRepository.insert(order: Order): Future[Option[Long]] に対するモック

Futureの扱い方

モックというよりScalaそのものの話です。
DB操作ライブラリとしてSlickを利用していて、これは非同期でDB操作を行うものです。
そのため、DBとの通信が入るメソッドは基本的にすべてFutureが返り値になっています。
Future型ですぐに返したい場合はFuture.successful(値)とすると楽に返せます。
また、テストでFutureメソッドの返り値を待つ場合は、Await.resultを使うか、ScalaFuturesというtraitをミックスインして.futureValueというメソッドを使うと良いようです。

verifyで正しくメソッド使用回数をカウントできない

Mockitoのverifyは、メソッド中で何回そのモックメソッドが呼ばれたかを計測できる機能です。
これにより外部との通信の回数やログ出力の回数などをテストできます。

verifyの使い方は以下の記事に詳しく載っていました。(その他、基本的な使い方も非常に詳しく載っています。)
記事には載っていませんが、メソッドが呼ばれる順序も検証するMockito.inOrderという機能もありました。興味がある方は調べてみてください。

しかし、あるテストで1回ずつ呼ばれてほしいメソッドが、6回ずつ呼ばれている、というようなことが起こりました。
具体的には以下のようなコードです。

    "必要な処理が適切な回数呼ばれている" in {
      // モックの設定、テストデータの設定など(省略)

      // 処理の実行
      val result = Await.result(useCase.handle(input), 20.seconds)

      // 呼ばれた回数を確認(簡略化しています)
      // atLeastOnceは1回以上、times(1)は1回のみ
      verify(orderRepositoryMock, atLeastOnce).method1(anyLong, any[Seq[Long]])
      verify(orderRepositoryMock, atLeastOnce).method2(anyLong, any[Order])
      verify(orderRepositoryMock, atLeastOnce).method3(any[Seq[Long]])
      verify(orderRepositoryMock, times(1)).method4(any[Order])
      verify(orderProgressRecordRepository, times(1)).method5(any[Order])
    }
  }

メソッドの実行は1回だけにもかかわらず、1回しか呼ばれないはずのテストが何回も呼ばれるという課題が発生しました。

これは、Mockを作成するときに、全てのテストで1つのエンティティを使いまわしていたことが原因でした。
つまり、最初にモックを生成・モックの設定を行い、それを使って上記のようにテストコード本体を書いていましたが、これにより「同一エンティティがメソッド内で呼ばれた回数」が増えてしまった、ということです。

なので、「各テストごとにエンティティを再生成する」という処理で解決できました。
その際にBeforeAndAfterEachというtraitをミックスインし、beforeEach()というメソッドをオーバーライドして設定することで、逐一手動でモック再生成・モック設定を行う必要が無く便利でした。
以下に例を載せておきます。

class UseCaseSpec extends PlaySpec with BeforeAndAfterEach {
  // テストデータの実体化など(省略)

  override def beforeEach(): Unit = {
    super.beforeEach()

    // モックエンティティの生成
    orderRepositoryMock = mock[OrderRepository]
    schoolRepositoryMock = mock[SchoolRepository]
    // 以下、依存しているすべてのモックを生成する

    // モックのうち引数または返り値が変化しない部分を設定する
    doReturn(Future.successful(Some(1L)), Nil: _*).when(orderRepositoryMock).insert(any[DocumentRequestOrder])
    doReturn(Future.successful(0), Nil: _*).when(schoolRepositoryMock).updateStatusesByIds(any[Seq[Long]], any[SchoolStatus])
    doNothing().when(mailManagerMock).sendOrderMail(anyLong)
    // 以下、モックの設定を行う

  }

  "UseCase" must {
      // テスト本体を書いていく
  }

その他、Mockitoやテストの基本的な使い方に関しては以下の記事などが詳しいです。
コードはJavaですが、雰囲気は同じなので、適宜読み替えていただければと思います。

まとめ

自動テストの導入により、開発プロセスが効率化され、品質も向上しました。
しかし、テストカバレッジの確認やE2Eテスト、DBテストの導入など、まだ改善すべき点があります。
今後はこれらの課題を解決し、さらに品質の高い開発を目指していきます。

9
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?