Miguel Vilá

Usando Reactive Extensions para ayudar tests e2e

Aug 15 2017

Este es un corto post mostrando algo que hice en mi trabajo y que me pareció interesante.

En mi trabajo estamos desarrollando pruebas end to end (e2e) de multiples servicios. Entre los varios features que queríamos probar estaba la opción de resetear la contraseña. Este feature, muy común en varios sitios web, sigue un patrón similar:

  1. El usuario olvida su contraseña. Se dirige a un formulario específico para esta situación e introduce la dirección de correo electrónico con la que se había registrado.

  2. Un correo es enviado al usuario conteniendo un enlace especial para resetear la contraseña.

  3. El usuario se loguea a su cuenta de correo y da click en el enlace contenido en el correo.

  4. El enlace lo dirige a un formulario dónde va a poder introducir una nueva contraseña.

  5. Una vez hecho esto la contraseña del usuario ha sido actualizada.

Nada fuera de lo ordinario.

La idea es hacer un test de todo el flujo, específicamente de los servicios del back-end que soportan esto. Estos son dos:

Pero para testear el flujo completo deberíamos usar el primer servicio para iniciar el “reseteo”, después de alguna forma “abrir” la bandeja de entrada del correo del usuario, extraer el token y usarlo para consumir el segundo servicio. Así que la parte no obvia es ¿cómo podemos lograr ese “abrir la bandeja de entrada”?

Voy a mostrar como lo hicimos y como encontramos que reactive extensions nos podría servir para lograrlo.

Primero cabe decir que para enviar correos usamos SES de Amazon. Nuestro código usa el SDK que realmente solo hace una solicitud HTTP a un servidor de Amazon. Además los tests e2e se ejecutan en un proceso distinto al servicio que queríamos testear.

Decidimos hacer lo siguiente: los tests levantan un servidor embebido que simula ser SES, a este lo llamarémos FakeSES. Digamos que este servicio se levantó en el puerto 9999. Entonces levantamos el servicio que querémos probar haciéndole pensar que SES se encuentra en <ip del _host_ que corre los tests>:9999.

Nada fuera del otro mundo hasta ahora.

Ahora, gracias a que los tests levantan FakeSES deberían poder inspeccionar las solicitudes de envíos de correos que le llegan. El problema sin embargo es que los tests se ejecutan por un lado mientras que FakeSES se debe ejecutar al mismo tiempo mientras atiende solicitudes. ¿Cómo conectar ambas cosas?

Aquí es dónde entra reactive extensions. Lo que deseamos hacer es ofrecerle a los tests una vista de las solicitudes de envío que le llegan a FakeSES. ¡Un Observable encajaría muy bien acá! Digamos que este es nuestro código del servidor falso:

case class SendEmailRequest(subject: String, body: String, destination: String)

class FakeSES {

    private def requestToSendEmailRequest(request: HTTPRequest): SendEmailRequest =
        ??? // no es relevante

    private val successResponse: HTTPResponse =
        ??? // no es relevante

    private def handleRequest(request: HTTPRequest, response: HTTPResponse): Response = {
        successResponse
    }

    // *************************
    // Alguna lógica que levanta 
    // el servidor embebido y
    // simula ser SES usando la
    // función `handleRequest`
    // *************************

}

Entonces idealmente queremos que además tenga una propiedad que nos permita leer las solicitudes de envíos de correos, sin importar en qué momento llegaron o llegarán:

class FakeSES {

    // ... lo mismo que antes

    val sendEmailRequests: Observable[SendEmailRequest] = ???
    
}

La pregunta es ¿como inicializar esta variable?

Podemos hacer lo siguiente:

class FakeSES {

    // ... lo mismo que antes
    
    private val sendEmailRequestsSubject = BehaviorSubject[SendEmailRequest]

    private def handleRequest(request: HTTPRequest, response: HTTPResponse): Response = {
        sendEmailRequestsSubject.onNext(requestToSendEmailRequest(request))
        successResponse
    }


    val sendEmailRequests: Observable[SendEmailRequest] = sendEmailRequestsSubject
    
}

Dado que BehaviorSubject es una subclase de Observable podemos hacer este último paso (si han visto los futuros y promesas sabrán que por ahí hay una idea similar)-

Ahora veamos como se escribiría un test:

import org.scalatest.concurrent.ScalaFutures
import org.scalatest._
import scala.concurrent.duration._
import scala.concurrent.Await
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future

class ResetPasswordTest 
    extends WordSpec 
    with MustMatchers 
    with WordSpec
    with BeforeAndAfterAll {

    val fakeSES = new FakeSES()
    
    def beforeAll() = fakeSES.start()
    
    def afterAll() = fakeSES.stop()
    
    val email = "some@email.com"
    val oldPassword = "asdfdgfad"
    val newPassword = "pas5w0rd"
    
    def firstResetPasswordEmail(): Future[SendEmailRequest] =
        fakeSES.sendEmailRequests
            .filter(_.destination == email)
            .filter(_.subject == "Forgot your password?")
            .first
            .toBlocking
            .toFuture
            
    // un cliente del servicio que queremos testear
    val service = ???
    
    def extractResetToken(email: SendEmailRequest): String =
        ??? // no es relevante
    
    "The client" should {
        "execute the request password flow" in {
            service.createUser(email, oldPassword)
            service.startPasswordReset(email)
            val resetEmail = Await.result(firstResetPasswordEmail(), 1.second)
            val resetToken = extractResetToken(resetEmail)
            service.resetPassword(email, resetToken, newPassword).statusCode mustBe 200
            service.login(email, newPassword).statusCode mustBe 200
            service.login(email, oldPassword).statusCode mustBe 403
        }
    }

}

Aquí la función firstResetPasswordEmail nos permite filtrar el primer correo que nos interesa. Cabe notar que este código funciona sin importar si llegan otros correos (como por ejemplo un correo de bienvenida apenas el usuario se registra), sin importar en qué orden lleguen, etc… . Esto gracias a la naturaleza declarativa de reactive extensions.

Y eso fue todo. Todavía no he encontrado un uso en “producción” de reactive extensions pero para esto me pareció útil.