猫でもわかるWebプログラミング

試行錯誤しながらエンジニア(プログラマー)として働く猫のブログ。技術的な話や、働き方の話、読書録とか、試行錯誤している日常の話。

Scala playframework で、 ws のリクエスト先をモックしてテストする

f:id:yoshiki_utakata:20200929101907p:plain

はじめに

これは下記 playframework のドキュメントの翻訳です。

ws クライアントをテストする

ほとんどのアプリケーションは、他の Web サービスの API 等を利用するクライアントを実装する。

この API クライアント等は、JSON 等の単なる文字列でやり取りするため、型付けが弱くなりがちである。

そのため、テストは重要である。

このような API クライアントをテストする方法は、いくつかのパターンかある。

本当に Web サービスに対してリクエストを送る

テスト内で、本当に Web サービスの API に対してリクエストする方法がある。

この方法だと、実際にリクエストを送るので、テストの信頼度はかなり高い。

一方で、以下の理由により、あまり実用的ではないのが事実である。

  • API Limit などの制限に引っかかる事がある
  • Web サービスの挙動を自由にいじるのは難しいので、テストで確認したいデータが用意できないことがある

Web サービスのテスト環境に対してリクエストを送る

Web サービスによっては、開発用のテスト環境を用意してくれている場合があるので、テストではここに対してリクエストする。

先程の、実際の Web サービスでテストするよりはマシかもしれないが、以下のような弊害がある。

  • テスト用の環境を用意してくれていないサービスもある
  • テスト用の環境が落ちているとテストも落ちてしまう

HTTP クライアントをモックする

HTTP クライアント部分をモックに差し替える方法があるが、これだと

  • 単にコードが動くかどうかのテストになってしまいがち
  • 本当に正しいリクエストが送られているのかどうか、テストができていない

Web サービスのモックを作成する

自分たちで、 Web サービスのモックする方法もある。

Play には、このモックを簡単に作る仕組みが用意されている。

Web サービスのモックを立ち上げてテストを実行する方法には以下のメリットがある。

  • 正しく HTTP リクエストが行われていることを確認できる
    • JSON シリアライズ/デシリアライズも含めて動作確認できる
  • 外部サービスには一切依存しないでテストができる

最初に述べたように、シリアライス/デシリアライズの部分は、型の縛りが弱くて、バグが出やすい部分であるので、このテストは有用である。

GitHub Client をテストする例

例として、 GitHub Client をテストしてみる。

以下のような、GitHub の public repository を取得するだけのコードだ。

import javax.inject.Inject

import play.api.libs.ws.WSClient

import scala.concurrent.ExecutionContext
import scala.concurrent.Future

class GitHubClient(ws: WSClient, baseUrl: String)(implicit ec: ExecutionContext) {
  @Inject def this(ws: WSClient, ec: ExecutionContext) = this(ws, "https://api.github.com")(ec)

  def repositories(): Future[Seq[String]] = {
    ws.url(baseUrl + "/repositories").get().map { response =>
      (response.json \\ "full_name").map(_.as[String]).toSeq
    }
  }
}

ここで重要なのが、GitHub API の baseURL が外から注入できるようになっていることである。

これを上書きすることでモックサーバーにリクエストを向ける。

続いて、Web サービスのモックを実装する。 ServerwithRouter ヘルパーを使って、以下の様に実装する。

import play.api.libs.json._
import play.api.mvc._
import play.api.routing.sird._
import play.core.server.Server

Server.withRouterFromComponents() { components =>
  import Results._
  import components.{ defaultActionBuilder => Action }
  {
    case GET(p"/repositories") =>
      Action {
        Ok(Json.arr(Json.obj("full_name" -> "octocat/Hello-World")))
      }
  }
} { implicit port =>
   // ここからテストコード

withRouter メソッドには、リクエストに対する処理のコードを渡してやる。サーバーを立ち上げるポートを渡すこともできるが、特に何も指定しなければ、自動で開いてるポートで起動してくれる。

最終的に implicit port で、立ち上がったポート番号が返ってくるので、これをテストコードに伝えてあげる。

これで準備ができたので、テストコードを書く。

WsTestClient.withClient というメソッドで、特殊な WsClient を生成できる。

先程、GitHub Client の baseUrl が外から注入できるのが重要だ、という話をした。テスト用の WsClient は、 host を指定しなければ、 localhost の適切なポートに対してリクエストしてくれるため、 baseUrl を空にして、PATH の部分だけでリクエストするようにしてやる。

import play.core.server.Server
import play.api.routing.sird._
import play.api.mvc._
import play.api.libs.json._
import play.api.test._

import scala.concurrent.Await
import scala.concurrent.duration._

import org.specs2.mutable.Specification

class GitHubClientSpec extends Specification {
  import scala.concurrent.ExecutionContext.Implicits.global

  "GitHubClient" should {
    "get all repositories" in {
      Server.withRouterFromComponents() { components =>
        import Results._
        import components.{ defaultActionBuilder => Action }
        {
          case GET(p"/repositories") =>
            Action {
              Ok(Json.arr(Json.obj("full_name" -> "octocat/Hello-World")))
            }
        }
      } { implicit port =>
        WsTestClient.withClient { client =>
          val result = Await.result(new GitHubClient(client, "").repositories(), 10.seconds)
          result must_== Seq("octocat/Hello-World")
        }
      }
    }
  }
}

レスポンスをファイルで用意する

先程は最低限のレスポンスを用意したが、実際のレスポンスと同じ形式のレスポンスでテストしたい場合がある。

Play にある sendResource メソッドを使えば、ファイルからレスポンスを生成して返すことができる。

まずはファイルを作成する。play デフォルトのディレクトリ構造であれば test/resources ディレクトリ、 sbt デフォルトのディレクトリ構造を使っているなら src/test/resources に配置する。

github/repositories.json というファイルを以下の内容で作成するとする

[
  {
    "id": 1296269,
    "owner": {
      "login": "octocat",
      "id": 1,
      "avatar_url": "https://github.com/images/error/octocat_happy.gif",
      "gravatar_id": "",
      "url": "https://api.github.com/users/octocat",
      "html_url": "https://github.com/octocat",
      "followers_url": "https://api.github.com/users/octocat/followers",
      "following_url": "https://api.github.com/users/octocat/following{/other_user}",
      "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
      "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
      "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
      "organizations_url": "https://api.github.com/users/octocat/orgs",
      "repos_url": "https://api.github.com/users/octocat/repos",
      "events_url": "https://api.github.com/users/octocat/events{/privacy}",
      "received_events_url": "https://api.github.com/users/octocat/received_events",
      "type": "User",
      "site_admin": false
    },
    "name": "Hello-World",
    "full_name": "octocat/Hello-World",
    "description": "This your first repo!",
    "private": false,
    "fork": false,
    "url": "https://api.github.com/repos/octocat/Hello-World",
    "html_url": "https://github.com/octocat/Hello-World"
  }
]

テストコードは以下のようになる

import play.api.mvc._
import play.api.routing.sird._
import play.api.test._
import play.core.server.Server

Server.withApplicationFromContext() { context =>
  new BuiltInComponentsFromContext(context) with HttpFiltersComponents {
    override def router: Router = Router.from {
      case GET(p"/repositories") =>
        Action { req =>
          Results.Ok.sendResource("github/repositories.json")(executionContext, fileMimeTypes)
        }
    }
  }.application
} { implicit port =>

.json 拡張子がついていると、 play は自動的に application/json でレスポンスを返す。

クライアントのセットアップの部分のコードを分ける

以下のように withGitHubClient メソッドを準備しておくと、テストが増えた時に再利用ができる。

import play.api.mvc._
import play.api.routing.sird._
import play.core.server.Server
import play.api.test._

def withGitHubClient[T](block: GitHubClient => T): T = {
  Server.withApplicationFromContext() { context =>
    new BuiltInComponentsFromContext(context) with HttpFiltersComponents {
      override def router: Router = Router.from {
        case GET(p"/repositories") =>
          Action { req =>
            Results.Ok.sendResource("github/repositories.json")(executionContext, fileMimeTypes)
          }
      }
    }.application
  } { implicit port =>
    WsTestClient.withClient { client =>
      block(new GitHubClient(client, ""))
    }
  }
}

このメソッドはこのように使える。

withGitHubClient { client =>
  val result = Await.result(client.repositories(), 10.seconds)
  result must_== Seq("octocat/Hello-World")
}