aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoradamw <adam@warski.org>2017-10-09 12:01:33 +0200
committeradamw <adam@warski.org>2017-10-09 12:01:33 +0200
commitbb4f2d27fa56ced1f565d9326a08fd5a87e2202b (patch)
treebc5c72029bb4757bcb0f92ca02a3e6820c81cec9
parentc426790198c32dd7ee181c46cf1a9be96978397d (diff)
downloadsttp-bb4f2d27fa56ced1f565d9326a08fd5a87e2202b.tar.gz
sttp-bb4f2d27fa56ced1f565d9326a08fd5a87e2202b.tar.bz2
sttp-bb4f2d27fa56ced1f565d9326a08fd5a87e2202b.zip
Testing backend
-rw-r--r--README.md30
-rw-r--r--core/src/main/scala/com/softwaremill/sttp/testing/SttpBackendStub.scala90
-rw-r--r--core/src/test/scala/com/softwaremill/sttp/testing/SttpBackendStubTests.scala51
3 files changed, 171 insertions, 0 deletions
diff --git a/README.md b/README.md
index f795e53..ae61a44 100644
--- a/README.md
+++ b/README.md
@@ -530,6 +530,36 @@ parameter. See [akka-http docs](http://doc.akka.io/docs/akka-http/current/scala/
* OkHttp: create a custom client modifying the SSL settings as described
[on the wiki](https://github.com/square/okhttp/wiki/HTTPS)
+## Testing
+
+If you need a stub backend for use in tests instead of a "real" backend (you
+probably don't want to make HTTP calls during unit tests), you can use the
+`SttpBackendStub` class. It allows specifying how the backend should respond
+to requests matching given predicates.
+
+A backend stub can be created using an instance of a "real" backend, or by
+explicitly giving the response wrapper monad and supported streams type.
+
+For example:
+
+```scala
+implicit val testingBackend = SttpBackendStub(HttpURLConnectionBackend())
+ .whenRequestMatches(_.uri.path.startsWith(List("a", "b")))
+ .thenRespond("Hello there!")
+ .whenRequestMatches(_.method == Method.POST)
+ .thenRespondServerError()
+
+val response1 = sttp.get(uri"http://example.org/a/b/c").send()
+// response1.body will be Right("Hello there")
+
+val response2 = sttp.post(uri"http://example.org/d/e").send()
+// response2.code will be 500
+```
+
+However, this approach has one caveat: the responses are not type-safe. That
+is, the backend cannot match on or verify that the type included in the
+response matches the response type requested.
+
## Notes
* the encoding for `String`s defaults to `utf-8`.
diff --git a/core/src/main/scala/com/softwaremill/sttp/testing/SttpBackendStub.scala b/core/src/main/scala/com/softwaremill/sttp/testing/SttpBackendStub.scala
new file mode 100644
index 0000000..5724264
--- /dev/null
+++ b/core/src/main/scala/com/softwaremill/sttp/testing/SttpBackendStub.scala
@@ -0,0 +1,90 @@
+package com.softwaremill.sttp.testing
+
+import com.softwaremill.sttp.testing.SttpBackendStub._
+import com.softwaremill.sttp.{MonadError, Request, Response, SttpBackend}
+
+import scala.language.higherKinds
+
+/**
+ * A stub backend to use in tests.
+ *
+ * The stub can be configured to respond with a given response if the
+ * request matches a predicate (see the [[whenRequestMatches()]] method).
+ *
+ * Note however, that this is not type-safe with respect to the type of the
+ * response body - the stub doesn't have a way to check if the type of the
+ * body in the configured response is the same as the one specified by the
+ * request. Hence, the predicates can match requests basing on the URI
+ * or headers. A [[ClassCastException]] might occur if for a given request,
+ * a response is specified with the incorrect body type.
+ */
+class SttpBackendStub[R[_], S] private (rm: MonadError[R],
+ matchers: Vector[Matcher[_]])
+ extends SttpBackend[R, S] {
+
+ /**
+ * Specify how the stub backend should respond to requests matching the
+ * given predicate. Note that the stubs are immutable, and each new
+ * specification that is added yields a new stub instance.
+ */
+ def whenRequestMatches(p: Request[_, _] => Boolean): WhenRequest =
+ new WhenRequest(p)
+
+ override def send[T](request: Request[T, S]): R[Response[T]] = {
+ val response = matchers
+ .collectFirst {
+ case matcher if matcher(request) => matcher.response
+ }
+ .getOrElse(DefaultResponse)
+
+ rm.unit(response.asInstanceOf[Response[T]])
+ }
+
+ override def close(): Unit = {}
+
+ override def responseMonad: MonadError[R] = rm
+
+ class WhenRequest(p: Request[_, _] => Boolean) {
+ def thenRespondOk(): SttpBackendStub[R, S] =
+ thenRespondWithCode(200)
+ def thenRespondNotFound(): SttpBackendStub[R, S] =
+ thenRespondWithCode(404, "Not found")
+ def thenRespondServerError(): SttpBackendStub[R, S] =
+ thenRespondWithCode(500, "Internal server error")
+ def thenRespondWithCode(code: Int,
+ msg: String = ""): SttpBackendStub[R, S] =
+ thenRespond(Response[Nothing](Left(msg), code, Nil, Nil))
+ def thenRespond[T](body: T): SttpBackendStub[R, S] =
+ thenRespond(Response[T](Right(body), 200, Nil, Nil))
+ def thenRespond[T](resp: Response[T]): SttpBackendStub[R, S] =
+ new SttpBackendStub(rm, matchers :+ Matcher(p, resp))
+ }
+}
+
+object SttpBackendStub {
+
+ /**
+ * Create a stub backend for testing, which uses the same response wrappers
+ * and supports the same stream type as the given "real" backend.
+ *
+ * @tparam S2 This is a work-around for the problem described here:
+ * [[https://stackoverflow.com/questions/46642623/cannot-infer-contravariant-nothing-type-parameter]].
+ */
+ def apply[R[_], S, S2 <: S](c: SttpBackend[R, S]): SttpBackendStub[R, S2] =
+ new SttpBackendStub[R, S2](c.responseMonad, Vector.empty)
+
+ /**
+ * Create a stub backend using the given response monad (which determines
+ * how requests are wrapped), and any stream type.
+ */
+ def apply[R[_], S](responseMonad: MonadError[R]): SttpBackendStub[R, S] =
+ new SttpBackendStub[R, S](responseMonad, Vector.empty)
+
+ private val DefaultResponse = Response[Nothing](Left(""), 404, Nil, Nil)
+
+ private case class Matcher[T](p: Request[T, _] => Boolean,
+ response: Response[T]) {
+ def apply(request: Request[_, _]): Boolean =
+ p(request.asInstanceOf[Request[T, _]])
+ }
+}
diff --git a/core/src/test/scala/com/softwaremill/sttp/testing/SttpBackendStubTests.scala b/core/src/test/scala/com/softwaremill/sttp/testing/SttpBackendStubTests.scala
new file mode 100644
index 0000000..538dd35
--- /dev/null
+++ b/core/src/test/scala/com/softwaremill/sttp/testing/SttpBackendStubTests.scala
@@ -0,0 +1,51 @@
+package com.softwaremill.sttp.testing
+
+import com.softwaremill.sttp._
+import org.scalatest.concurrent.ScalaFutures
+import org.scalatest.{FlatSpec, Matchers}
+
+class SttpBackendStubTests extends FlatSpec with Matchers with ScalaFutures {
+ val testingStub = SttpBackendStub(HttpURLConnectionBackend())
+ .whenRequestMatches(_.uri.path.startsWith(List("a", "b")))
+ .thenRespondOk()
+ .whenRequestMatches(_.uri.paramsMap.get("p").contains("v"))
+ .thenRespond(10)
+ .whenRequestMatches(_.method == Method.GET)
+ .thenRespondServerError()
+
+ it should "use the first rule if it matches" in {
+ implicit val b = testingStub
+ val r = sttp.get(uri"http://example.org/a/b/c").send()
+ r.is200 should be(true)
+ r.body should be('left)
+ }
+
+ it should "use subsequent rules if the first doesn't match" in {
+ implicit val b = testingStub
+ val r = sttp
+ .get(uri"http://example.org/d?p=v")
+ .response(asString.map(_.toInt))
+ .send()
+ r.body should be(Right(10))
+ }
+
+ it should "use the first specified rule if multiple match" in {
+ implicit val b = testingStub
+ val r = sttp.get(uri"http://example.org/a/b/c?p=v").send()
+ r.is200 should be(true)
+ r.body should be('left)
+ }
+
+ it should "use the default response if no rule matches" in {
+ implicit val b = testingStub
+ val r = sttp.post(uri"http://example.org/d").send()
+ r.code should be(404)
+ }
+
+ it should "wrap responses in the desired monad" in {
+ import scala.concurrent.ExecutionContext.Implicits.global
+ implicit val b = SttpBackendStub(new FutureMonad())
+ val r = sttp.post(uri"http://example.org").send()
+ r.futureValue.code should be(404)
+ }
+}