Quiero aclarar que con asesinar al mono en el lenguaje de programación Go nos referimos a evitar el uso de librerías como bouk/monkey debido a que su funcionamiento implica alterar el binario final del proyecto. Esto implica muchos riesgos como por ejemplo, comportamientos inesperados, código de pruebas “quemado” en código productivo, largas sesiones de debugging, entre muchas otras.

El uso de esta librería es desaconsejado por múltiples razones, entre ellas que la licencia del proyecto prohíbe su uso en cualquier tipo de proyecto (personales y comerciales). Si eso no es razón suficiente, el proyecto se encuentra archivado lo que significa que no recibirá actualizaciones de seguridad ni mejoras.

Entonces, ¿Cómo hacemos para que un método en Go tenga un retorno específico? En este artículo, exploraremos una forma diferente de conseguir esto utilizando las famosas interfaces de Go.

Problemática

Un caso típico de querer “mockear” la respuesta de un método es mockear el retorno del método Now en la librería time de Go. En caso de no mockear el retorno de este método expondremos nuestro código de pruebas a condiciones de carrera donde tendremos que correr y correr las pruebas hasta que por un milagro el time.Now productivo y el de pruebas den lo mismo. Esto no es digno de nosotros programadores de pecho peludo, espalda de leñador, barba de cabrito, cerebro de delfín.

Entonces, ¿Cómo se afronta este problema?

Muchos podrían pensar en omitir el tiempo de la prueba y no agregar ninguna validación. Esto la mayoría de las veces suele ser la solución, pero no es lo correcto; solo estamos posponiendo este dolor de cabeza hasta el día que nuestra lógica dependa del tiempo y este valor no se pueda ignorar.

Por poner un ejemplo, imaginemos que tenemos una aplicación que se encarga de procesar pagos y una de las reglas del negocio es que los pagos realizados después de las 23:00 no son válidos y deben ser rechazados. En el siguiente fragmento de código se expone la función que se encarga de validar esto.

func esPagoValido() bool {
    return time.Now().Hour() < 23
}

Y para probar esta funcionalidad se escribe la siguiente prueba:

func TestEsPagoValido(t *testing.T) {
    pagoValido := esPagoValido()
    assert.Equal(t, pagoValido, true)
}

El método TestEsPagoValido no cumple con el principio R de los principios F.I.R.S.T el cual establece que una prueba debe ser repetible sin importar cambios en el ambiente. Esto se debe a que ejecutar el método TestEsPagoValido en diferentes horarios hará que la prueba pase o falle; si se ejecuta antes de las 23:00, la prueba se ejecuta satisfactoriamente y si se ejecuta después de las 23:00, pero antes de las 00:00 entonces el escenario falla.

¿Qué podemos hacer para que esto no ocurra? Debe existir alguna manera que el método time.Now siempre devuelva una fecha y hora exacta sin importar la hora o lugar donde se ejecute la prueba.

Interfaces al rescate

Por suerte en Go podemos hacer uso de las interfaces para definir múltiples implementaciones de un método (en este caso time.Now) con una misma firma. Esto permite alternar entre nuestra implementación productiva (donde time.Now se comporta normalmente) y nuestra versión de pruebas (donde time.Now siempre responde una fecha y hora exacta).

Veamos entonces cómo convertir el código de más arriba en un código más amigable de probar.

Definición de interface

Debido a que no podemos modificar el comportamiento interno de la función time.Now debido a que hace parte de la librería estándar de Go y modificar su comportamiento en tiempo de ejecución ya vimos que es una mala práctica, debemos ser un poco más creativos. Es por eso que “crearemos” nuestra propia versión de la función Now. Para hacer esto, definiremos una interface llamada Clock la cual tendrá un solo método llamado Now, de la siguiente forma:

package clock

import "time"

type Clock interface {
    Now() time.Time
}

Ahora, deberemos implementar dos versiones de nuestra interface Clock, la primera será la implementación productiva. Esta es la más sencilla debido a que es un wrapper de la función time.Now la cual funciona de maravilla. Para esto, en otro archivo crearemos una implementación llamada ProdClock de la siguiente manera:

package clock

import "time"

type ProdClock struct {}

func (c *ProdClock) Now() time.Time {
    return time.Now()
}

Eso sería todo. En serio.

La estructura ProdClock cumple con la definición de la interface Clock debido a que implementa un método llamado Now que retorna un time.Time. Esta es la implementación que se utilizará en el código productivo.

Por otro lado, ahora debemos crear una implementación “falsa” de nuestra interface Clock la cual no importa cuantas veces sea invocada ni a qué hora sea invocada, siempre retornará lo mismo.

El procedimiento es muy similar; en otro archivo crearemos una implementación llamada FakeClock que tendrá la siguiente forma:

package clock

import "time"

type FakeClock struct {
    now time.Time
}

func (c *FakeClock) Now() time.Time {
    return c.now
}

func (c *FakeClock) SetTime(t time.Time) {
    c.now = t
}

Esta implementación es un poco diferente y un poco más larga, pero no es nada del otro mundo. Dentro de la estructura FakeClock se define un atributo llamado now el cual guardará el valor que siempre queremos que el reloj retorne. De esta forma garantizamos que nuestro reloj falso sea configurable a través del método SetTime.

Como se puede apreciar, la estructura FakeClock también cumple con la interface Clock debido a que implementa un método Now que retorna un time.Time.

Utilizando las interfaces

Lo primero que debemos hacer es modificar nuestro método esPagoValido el cual recordemos estaba definido de la siguiente manera:

func esPagoValido() bool {
    return time.Now().Hour() < 23
}

Ahora, nuestro método en vez de utilizar time.Now deberá utilizar alguna implementación de nuestro reloj. Para hacer esto se puede hacer de diferentes maneras, una de ellas es pasar una interface de tipo Clock como parámetro del método esPagoValido. Otra sería definir una estructura con un atributo de tipo Clock y asignarle a este atributo un reloj productivo o uno falso. Existen otras maneras pero dejaré que juegues con tu imaginación.

Por cuestiones de sencillez, emplearemos la opción de enviar el reloj a través de un parámetro en el método esPagoValido. Para ello, convertiremos a nuestro método a lo siguiente:

func esPagoValido(reloj clock.Clock) bool {
    return reloj.Now().Hour() < 23
}

Eso es todo… Sí, en serio.

Ahora, cuando se utilice el método esPagoValido en código productivo, el parámetro reloj deberá ser de tipo ProdClock, de la siguiente forma:

clock := clock.ProdClock{}
if esPagoValido(clock) {
    fmt.Println("el pago ha sido aprobado")
} else {
    fmt.Println("el pago deberá ser trámitado mañana")
}

En el caso de nuestro escenario de pruebas definido anteriormente:

func TestEsPagoValido(t *testing.T) {
    pagoValido := esPagoValido()
    assert.Equal(t, pagoValido, true)
}

Ahora se vería así:

func TestEsPagoValido(t *testing.T) {
    t.Run("el pago se hizo antes de las 23:00", func(t *testing.T) {
        clock := clock.FakeClock{}

    	fechaFalsa := time.Date(2021, time.December, 30, 22, 58, 33, 0, nil)

    	clock.SetTime(fechaFalsa)

    	pagoValido := esPagoValido(clock)
    	assert.Equal(t, pagoValido, true)
    })

    t.Run("el pago se hizo despues de las 23:00", func(t *testing.T) {
    	clock := clock.FakeClock{}

    	fechaFalsa := time.Date(2021, time.December, 30, 23, 1, 33, 0, nil)

    	clock.SetTime(fechaFalsa)

    	pagoValido := esPagoValido(clock)
    	assert.Equal(t, pagoValido, false)
    })
}

Como se ve en la prueba, hay dos escenarios: uno donde el pago se hace antes de las 23:00 y otro donde se hace después de las 23:00. No importa a qué hora, en qué computadora o en qué zona horaria se ejecute la prueba, siempre retornará lo mismo.

Consejos

  • Tener en cuenta los principios F.I.R.S.T cuando se esté escribiendo pruebas.
  • Antes de empezar a implementar código productivo, pensar en cómo será probado y si requerirá mockear retornos.
  • Tomar agua.

Bibliografía