sttpのSttpBackendStubを試してみた

ScalaのHTTPクライアントsttpのスタブ機能を試してみました。
2020.10.30

はじめに

ScalaのHTTPクライアントはHammokを使っていたのですが、scala 1.13 に対応していないということで代わりになるものを探していました。Hammokと同じようにバックエンドが選択可能で複数のEffectコンテナと組み合わせて使えるsttpがよさそうだったので試していたら、クライアントのスタブ化ができるのを見つけたので試してみました。

sttpとは

GitHubのREADMEでは以下のように説明されています。

ざっとみただけでもかなり多くの機能があったりカスタマイズができそうで、まさに俺たちの欲しかったクライアントって感じを受けました。

The Scala HTTP client that you always wanted! sttp client is an open-source library which provides a clean, programmer-friendly API to describe HTTP requests and how to handle responses. Requests are sent using one of the backends, which wrap other Scala or Java HTTP client implementations. The backends can integrate with a variety of Scala stacks, providing both synchronous and asynchronous, procedural and functional interfaces.

バージョンなど

今回使ったbuild.sbtは以下の通りです(抜粋)

スタブに関していえばバックエンドはどれを使っても同じです。

scalaVersion := "2.13.3"
libraryDependencies += "com.softwaremill.sttp.client3" %% "core" % "3.0.0-RC9"
libraryDependencies += "com.softwaremill.sttp.client3" %% "async-http-client-backend-cats" % "3.0.0-RC9"

スタブ

スタブについてはドキュメントのTestingのページに記載があります。

まずスタブは以下のように定義されています(抜粋)。

class SttpBackendStub[F[_], +P](
    monad: MonadError[F],
    matchers: PartialFunction[Request[_, _], F[Response[_]]],
    fallback: Option[SttpBackend[F, P]]
) extends SttpBackend[F, P] {

  def whenRequestMatches(p: Request[_, _] => Boolean): WhenRequest =
    new WhenRequest(p)

  def whenAnyRequest: WhenRequest = whenRequestMatches(_ => true)

  def whenRequestMatchesPartial(
      partial: PartialFunction[Request[_, _], Response[_]]
  ): SttpBackendStub[F, P] = {
    val wrappedPartial: PartialFunction[Request[_, _], F[Response[_]]] =
      partial.andThen((r: Response[_]) => monad.unit(r))
    new SttpBackendStub[F, P](monad, matchers.orElse(wrappedPartial), fallback)
  }

インスタンス化する時には各バックエンドの#stubメソッドで生成します。例えば以下のようになります。

import sttp.client3.asynchttpclient.cats.AsyncHttpClientCatsBackend

implicit val backend: SttpBackendStub[IO, Any] = AsyncHttpClientCatsBackend.stub[IO]

セットアップ

リクエストとレスポンスのセットアップをします。以下のようにリクエストに対する述語とそれに対応する応答を指定していきます。セットアップに使うメソッドは新たなスタブを返すのでメソッドチェーンで記述する必要があります。これに気づかずに少しはまりました。

//スタブのセットアップ
implicit val backend: SttpBackendStub[IO, Any] = AsyncHttpClientCatsBackend.stub[IO]
//annは見つかる
	.whenRequestMatches(req => req.uri.params.get("name").fold(false)(_==="ann")).thenRespond("Ann")
//それ以外は404
  .whenAnyRequest.thenRespondNotFound()

実際に使ってみる

いつもAPIクライアントを作っているのと同じ要領でHTTPクライアントとして使用APIクライアントの実装に使用して、implicit パラメータでテスト時にスタブに差し替えるのをイメージした例を書いてみました。

package example

import cats.data.OptionT
import cats.effect.{Effect, ExitCode, IO, IOApp}
import sttp.client3.asynchttpclient.cats.AsyncHttpClientCatsBackend
import sttp.client3.{SttpBackend, basicRequest}
import sttp.client3.testing.SttpBackendStub
import sttp.model.Uri
import cats.implicits._
import io.circe.syntax._
import io.circe.generic.auto._

//ウロコインコリソース
final case class Conure(name:String)

//APIクライアント
trait APIClient[F[_]] {
  def findBirdy(name:String): OptionT[F, Conure]
}

object APIClient {
  //implicit パラメータでバックエンドを差し替える
  //テスト時にはスタブを指定する
  implicit def onHttpClient[F[_]: Effect](implicit backend:SttpBackend[F, Any]):APIClient[F] = (name: String) => OptionT {
    basicRequest
      .get(Uri.unsafeParse(s"http://localhost/birdy/_by-name").withParam("name", name))
      .send(backend)
      .flatMap { res =>
        res.code.code match {
          case 404 => Effect[F].pure(none)
          case _ => res.body
            .leftMap((s: String) => new RuntimeException(s))
            .flatMap(io.circe.parser.parse(_).flatMap(_.as[Conure]))
            .fold(Effect[F].raiseError[Option[Conure]], b => Effect[F].pure(b.some))
        }
      }
  }
}

object StubTest extends IOApp {
  //テスト
  override def run(args: List[String]): IO[ExitCode] = {
    import APIClient._
    //スタブのセットアップ
    implicit val backend: SttpBackendStub[IO, Any] = AsyncHttpClientCatsBackend.stub[IO]
      //annは見つかる
      .whenRequestMatches(req => req.uri.params.get("name").fold(false)(_==="ann")).thenRespond(Conure("ann").asJson.noSpaces)
      //それ以外は404
      .whenAnyRequest.thenRespondNotFound()

    val client:APIClient[IO] = implicitly[APIClient[IO]]
    for {
      //annは見つかる
      _ <- client.findBirdy("ann").getOrElseF(IO.raiseError(new AssertionError("ann should be found.")))
      //それ以外は404
      _ <- client.findBirdy("piyopiyo").foldF(IO.unit)(_ => IO.raiseError(new AssertionError("piyopiyo should not be found")))
    } yield ExitCode.Success
  }
}

まとめ

sttpのスタブを試してみました、HTTPクライアントのスタブを作る時には汎用的なスタブライブラリだと指定することが多くなりがちなのでクライアントのAPIレベルでスタブ機能が揃っているのは記述量が少なく済むので便利そうだと思います。