2024 y seguimos sin inyectar dependencias

En pleno 2024 es posible seguir encontrado ciertas “prácticas” en recursos oficiales que a mí personalmente me sorprenden bastante. Hoy os voy a hablar de una en concreto que me he encontrado leyendo el libro The Go Programming Language

Disclamer

No hace falta decir que todo esto es MI opinión, basada en mi experiencia, nada más.

Los autores del libro Alan A. A. Donovan y Brian W. Kernighan son unas personas brillantes y mucho más inteligente que yo. Por lo que seguro que para ellos existe un motivo para todo esto que voy a hablar a continuación.

Hace unos días estaba buscando algo de información “oficial” sobre como hacer testing en Go y así refrescar un poco lo que había mirado en su día. La primera fuente que se me vino a la mente fue el libro “oficial” del lenguaje, el cual me dejo un poco desconcertado, ya que proponía abiertamente el uso y modificación de variables globales para hacer testing.

Testing modificando variables globales

Como vamos a ver a continuación el ejemplo es muy tonto, implementar y testear un echo. Lo que me gusta es que trata sobre una casuística muy particular como puede ser testear fechas o prints por consola.

En el libro dice abiertamente, para validar que la función echo ha hecho un print por consola con un mensaje concreto debemos crear una variable global y pisarla en los tests. Además añade:

From the test, we will call echo with a variety of arguments and flag settings and check that it prints
the correct output in each case, so we’ve added parameters to echo to reduce its dependence on global
variables.

Y se dejan fuera lo más importate, la variable global echo que vamos a testear 🤣.

Vamos a ver el ejemplo:

    package main

    ...

    var out io.Writer = os.Stdout // OVERRIDE DURING TESTING

    ...

    func echo(newline bool, sep string, args []string) error {
        fmt.Fprint(out, strings.Join(args, sep)) // WRITE USING GLOBAL VARIABLE
        if newline {
            fmt.Fprintln(out)
        }
        return nil
    }
    package main

    ...

    for _, test := range tests {
        descr := fmt.Sprintf("echo(%v, %q, %q)", test.newline, test.sep, test.args)
        out = new(bytes.Buffer) // OVERRIDE OUT VARIABLE
        if err := echo(test.newline, test.sep, test.args); err != nil {
            t.Errorf("%s failed: %v", descr, err)
            continue
        }
        got := out.(*bytes.Buffer).String() // GET VALUE
        if got != test.want {
            t.Errorf("%s = %q, want %q", descr, got, test.want)
        }
    }

El código en cuestión está disponible aquí

Como decía el ejemplo es tontísimo. Lo que me preocupa más es el trasfondo de esta práctica.

Vamos a hacer una lista rápida de los pros y contras de esta forma de testear:

PROS

  • ¿Es más rápido de implementar? (Lo pregunto porque sinceramente no lo tengo nada claro).
  • Para un ejemplo simple y aislado funciona (aunque no escale).

CONTRAS

  • El test está modificando una variable global.
  • La clase y el test están muy acoplados a nivel interno:
    • El test tiene que saber que se está usando una variable global llamada out, la cual tiene que pisar.
  • El test es muy frágil:
    • Si en la clase que se testea se cambia el nombre de la variable out, el test empezará a fallar.
    • Si otro test modifica la variable global out podría afectar a otros tests.
    • El orden en el que se define la variable out en el test no es trivial, una línea después y el test fallaría.
    • El test funciona porque en Go el test y la case testeada pertenecen temporalmente al mismo paquete y por ello la variable global a nivel de paquete está disponible.

Testing usando inyección de dependencias

Lo que yo propongo es algo mucho más limpio, extensible y sobre todo sin andar pisando variables globales.

Simplemente, inyectar la variable out en la función echo que queremos testear:

    func main() {
        flag.Parse()
        var out io.Writer = os.Stdout // CREATE A LOCAL WRITER CLASS INSTEAD OF USING A GLOBAL VARIABLE
        if err := echo(out, !*n, *s, flag.Args()); err != nil { // INJECT IT INTO THE ECHO FUNCTION
            fmt.Fprintf(os.Stderr, "echo: %v\n", err)
            os.Exit(1)
        }
    }

    // RECIEVE OUT VARIABLE AS PARAMETER
    func echo(out io.Writer, newline bool, sep string, args []string) error {
        ...
    }
    for _, test := range tests {
        descr := fmt.Sprintf("echo(%v, %q, %q)", test.newline, test.sep, test.args)
        var out = new(bytes.Buffer) // CREATE A WRITER LIKE CLASS JUST FOR THIS TEST
        if err := echo(out, test.newline, test.sep, test.args); err != nil { // INJECT INTO ECHO FUNCTION
            t.Errorf("%s failed: %v", descr, err)
            continue
        }
        got := out.String() // READ OUTPUT
        if got != test.want {
            t.Errorf("%s = %q, want %q", descr, got, test.want)
        }
    }

El código en cuestión está disponible aquí

Como podéis ver el test y la clase están acoplados lo justo y necesario, el test no modifica nada y es mucho más fácil de leer y entender.

Vamos a repetir la lista rápida de los pros y contras de esta forma de testear:

PROS

  • El test no modifica nada fuera del mismo.
  • El test y la clase están muy poco acoplados.
  • Cambios en la clase no afectan al test.
  • Al inyectar un objeto que implemente la interfaz Writer a la función echo podemos usar diferentes clases según el escenario que estemos cubriendo:
    • Para el código de producción usamos la clase os.Stdout, que no dejar de ser una clase de tipo file y que implementa la interfaz Writer.
    • Para el test usamos la clase bytes.Buffer que implementa la interfaz Writer directamente.
  • Cada test tiene su instancia del Writer y, por tanto, son independientes entre sí.

CONTRAS

  • Requiere de un poco más de esfuerzo (completamente asumible y que a largo plazo estara más que amortizado).
  • La función echo tiene muchos parámetros.
    • Para esto podemos empaquetar los parámetros en un objeto y pasarlo como parámetro, reduciendo el arity de la función.

Conclusiones

Este post es más una forma de desahogo que una receta mágica.

Es igual de posible que lo hayas leído y te aporte algo como que no estés para nada de acuerdo conmigo. No pasa nada, no pretendo poseer la verdad universal ni mucho menos.

Lo que sí que creo es que es bueno formase ideas propias con base en nuestras experiencias y no aceptar las cosas porque si.

No tengo ninguna duda de que esta gente es mucho más lista y tiene muchísima más experiencia que yo, pero no por ello voy a aceptar todo lo que digan como si fuera la verdad absoluta. En este caso, y como decía siempre basándome en mi propia experiencia, no creo que recomendar este tipo de práctica a la hora de testear para alguien nuevo en un lenguaje como puede ser Go sea lo mejor.

!Un saludo!