Search code examples
akkaakka-httpakka-streamakka-testkit

Akka Http: how to test a route with a flow to 3rd party service?


I have a route in akka-http app which is integrated with a 3rd party service via Http().cachedHostConnectionPoolHttps. I want to test it in a right way. But not sure how it should be :(

Here is how this route looks like:

val routes: Route = pathPrefix("access-tokens") {
  pathPrefix(Segment) { userId =>
    parameters('refreshToken) { refreshToken =>
      onSuccess(accessTokenActor ? GetAccessToken(userId, refreshToken)) {
        case token: AccessToken => complete(ok(token.toJson))
        case AccessTokenError => complete(internalServerError("There was problems while retriving the access token"))
      }
    }
  }
}

Behind this route hides accessTokenActor where all logic happens, here it is:

class AccessTokenActor extends Actor with ActorLogging with APIConfig {

  implicit val actorSystem = context.system
  import context.dispatcher
  implicit val materializer = ActorMaterializer()

  import AccessTokenActor._

  val connectionFlow = Http().cachedHostConnectionPoolHttps[String]("www.service.token.provider.com")

  override def receive: Receive = {
    case get: GetAccessToken => {
      val senderActor = sender()
        Source.fromFuture(Future.successful(
          HttpRequest(
            HttpMethods.GET,
            "/oauth2/token",
            Nil,
            FormData(Map(
              "clientId" -> youtubeClientId,"clientSecret" -> youtubeSecret,"refreshToken" -> get.refreshToken))
              .toEntity(HttpCharsets.`UTF-8`)) -> get.channelId
          )
        )
        .via(connectionFlow)
        .map {
          case (Success(resp), id) => resp.status match {
            case StatusCodes.OK => Unmarshal(resp.entity).to[AccessTokenModel]
              .map(senderActor ! AccessToken(_.access_token))
            case _ => senderActor ! AccessTokenError
          }
          case _ => senderActor ! AccessTokenError
        }
    }.runWith(Sink.head)
    case _ => log.info("Unknown message")
  }

  }

So the question is how it's better to test this route, keeping in mind that the actor with the stream also exist under its hood.


Solution

  • Composition

    One difficulty with testing your route logic, as currently organized, is that it is hard to isolate functionality. It is impossible to test your Route logic without an Actor, and it is hard to test your Actor querying without a Route.

    I think you would be better served with function composition, that way you can isolate what it is you're trying to test.

    First abstract away the Actor querying (ask):

    sealed trait TokenResponse
    case class AccessToken() extends TokenResponse {...} 
    case object AccessTokenError extends TokenResponse
    
    val queryActorForToken : (ActorRef) => (GetAccessToken) => Future[TokenResponse] = 
      (ref) => (getAccessToken) => (ref ? getAccessToken).mapTo[TokenResponse]
    

    Now convert your routes value into a higher-order method which takes in the query function as a parameter:

    val actorRef : ActorRef = ??? //not shown in question
    
    type TokenQuery = GetAccessToken => Future[TokenResponse]
    
    val actorTokenQuery : TokenQuery = queryActorForToken(actorRef)
    
    val errorMsg = "There was problems while retriving the access token"
    
    def createRoute(getToken : TokenQuery = actorTokenQuery) : Route = 
      pathPrefix("access-tokens") {
        pathPrefix(Segment) { userId =>
          parameters('refreshToken) { refreshToken =>
            onSuccess(getToken(GetAccessToken(userId, refreshToken))) {
              case token: AccessToken => complete(ok(token.toJson))
              case AccessTokenError   => complete(internalServerError(errorMsg))
            }
          }
        }
      }
    
    //original routes
    val routes = createRoute()
    

    Testing

    Now you can test queryActorForToken without needing a Route and you can test the createRoute method without needing an actor!

    You can test createRoute with an injected function that always returns a pre-defined token:

    val testToken : AccessToken = ???
    
    val alwaysSuccceedsRoute = createRoute(_ => Success(testToken))
    
    Get("/access-tokens/fooUser?refreshToken=bar" ~> alwaysSucceedsRoute ~> check {
      status shouldEqual StatusCodes.Ok
      responseAs[String] shouldEqual testToken.toJson
    }
    

    Or, you can test createRoute with an injected function that never returns a token:

    val alwaysFailsRoute = createRoute(_ => Success(AccessTokenError))
    
    Get("/access-tokens/fooUser?refreshToken=bar" ~> alwaysFailsRoute ~> check {
      status shouldEqual StatusCodes.InternalServerError
      responseAs[String] shouldEqual errorMsg
    }