Prueba de su aplicación Go: Comience de la manera correcta

Publicado: 2022-03-11

Al aprender algo nuevo, es importante tener un estado de ánimo fresco.

Si es bastante nuevo en Go y proviene de lenguajes como JavaScript o Ruby, es probable que esté acostumbrado a usar marcos existentes que lo ayuden a simular, afirmar y realizar otras pruebas mágicas.

¡Ahora erradique la idea de depender de dependencias o marcos externos! La prueba fue el primer impedimento con el que me topé cuando aprendí este notable lenguaje de programación hace un par de años, una época en la que había muchos menos recursos disponibles.

Ahora sé que probar el éxito en Go significa viajar con dependencias ligeras (como con todas las cosas de Go), depender mínimamente de bibliotecas externas y escribir un buen código reutilizable. Esta presentación de las experiencias de Blake Mizerany aventurándose con bibliotecas de prueba de terceros es un gran comienzo para ajustar su mentalidad. Verá algunos buenos argumentos sobre el uso de bibliotecas y marcos externos en lugar de hacerlo "a la manera de Go".

¿Quieres aprender Ir? Consulte nuestro tutorial introductorio de Golang.

Puede parecer contrario a la intuición crear su propio marco de prueba y burlarse de los conceptos, pero es más fácil de lo que parece y es un buen punto de partida para aprender el idioma. Además, a diferencia de cuando estaba aprendiendo, tiene este artículo para guiarlo a través de escenarios de prueba comunes, así como para presentar técnicas que considero las mejores prácticas para probar de manera eficiente y mantener limpio el código.

Haga las cosas "a la manera Go", elimine las dependencias de marcos externos.
Pío

Pruebas de tablas en Go

La unidad de prueba básica, de la fama de 'prueba unitaria', puede ser cualquier componente de un programa en su forma más simple que toma una entrada y devuelve una salida. Echemos un vistazo a una función simple para la que nos gustaría escribir pruebas. No es perfecto ni completo, pero es lo suficientemente bueno para fines de demostración:

avg.go

 func Avg(nos ...int) int { sum := 0 for _, n := range nos { sum += n } if sum == 0 { return 0 } return sum / len(nos) }

La función anterior, func Avg(nos ...int) , devuelve cero o el promedio entero de una serie de números que se le dan. Ahora vamos a escribir una prueba para ello.

En Go, se considera una buena práctica nombrar un archivo de prueba con el mismo nombre que el archivo que contiene el código que se está probando, con el sufijo agregado _test . Por ejemplo, el código anterior está en un archivo llamado avg.go , por lo que nuestro archivo de prueba se llamará avg_test.go .

Tenga en cuenta que estos ejemplos son solo extractos de archivos reales, ya que la definición del paquete y las importaciones se han omitido por simplicidad.

Aquí hay una prueba para la función Avg :

avg__test.go

 func TestAvg(t *testing.T) { for _, tt := range []struct { Nos []int Result int }{ {Nos: []int{2, 4}, Result: 3}, {Nos: []int{1, 2, 5}, Result: 2}, {Nos: []int{1}, Result: 1}, {Nos: []int{}, Result: 0}, {Nos: []int{2, -2}, Result: 0}, } { if avg := Average(tt.Nos...); avg != tt.Result { t.Fatalf("expected average of %v to be %d, got %d\n", tt.Nos, tt.Result, avg) } } }

Hay varias cosas a tener en cuenta sobre la definición de la función:

  • Primero, el prefijo de 'Prueba' en el nombre de la función de prueba. Esto es necesario para que la herramienta lo tome como una prueba válida.
  • La última parte del nombre de la función suele ser el nombre de la función o el método que se está probando, en este caso, Avg .
  • También necesitamos pasar la estructura de prueba llamada testing.T , que permite el control del flujo de la prueba. Para obtener más detalles sobre esta API, visite la página de documentación.

Ahora hablemos de la forma en que está escrito el ejemplo. Se ejecuta un conjunto de pruebas (una serie de pruebas) a través de la función Avg() y cada prueba contiene una entrada específica y la salida esperada. En nuestro caso, cada prueba envía una porción de números enteros ( Nos ) y espera un valor de retorno específico ( Result ).

La prueba de tabla recibe su nombre de su estructura, fácilmente representada por una tabla con dos columnas: la variable de entrada y la variable de salida esperada.

Simulación de la interfaz de Golang

Una de las características más grandes y poderosas que ofrece el lenguaje Go se llama interfaz. Además del poder y la flexibilidad que obtenemos de la interfaz al diseñar nuestros programas, la interfaz también nos brinda oportunidades increíbles para desacoplar nuestros componentes y probarlos a fondo en su punto de encuentro.

Una interfaz es una colección de métodos con nombre, pero también un tipo de variable.

Tomemos un escenario imaginario donde necesitamos leer los primeros N bytes de un io.Reader y devolverlos como una cadena. Se vería algo como esto:

readn.go

 // readN reads at most n bytes from r and returns them as a string. func readN(r io.Reader, n int) (string, error) { buf := make([]byte, n) m, err := r.Read(buf) if err != nil { return "", err } return string(buf[:m]), nil }

Obviamente, lo principal a probar es que la función readN , cuando se le dan varias entradas, devuelve la salida correcta. Esto se puede hacer con pruebas de tabla. Pero hay otros dos aspectos no triviales que deberíamos cubrir, que son comprobar que:

  • r.Read se llama con un búfer de tamaño n.
  • r.Read devuelve un error si se lanza uno.

Para saber el tamaño del búfer que se pasa a r.Read , así como controlar el error que devuelve, necesitamos simular la r que se pasa a readN . Si miramos la documentación de Go en el tipo Reader, vemos cómo se ve io.Reader :

 type Reader interface { Read(p []byte) (n int, err error) }

Eso parece bastante fácil. Todo lo que tenemos que hacer para satisfacer a io.Reader es tener nuestro propio método de Read simulado. Así que nuestro ReaderMock puede ser el siguiente:

 type ReaderMock struct { ReadMock func([]byte) (int, error) } func (m ReaderMock) Read(p []byte) (int, error) { return m.ReadMock(p) }

Analicemos un poco el código anterior. Cualquier instancia de ReaderMock satisface claramente la interfaz io.Reader porque implementa el método Read necesario. Nuestro simulacro también contiene el campo ReadMock , lo que nos permite establecer el comportamiento exacto del método simulado, lo que hace que sea muy fácil para nosotros instanciar dinámicamente lo que necesitemos.

Un gran truco sin memoria para garantizar que la interfaz esté satisfecha en tiempo de ejecución es insertar lo siguiente en nuestro código:

 var _ io.Reader = (*MockReader)(nil)

Esto verifica la afirmación pero no asigna nada, lo que nos permite asegurarnos de que la interfaz se implemente correctamente en el momento de la compilación, antes de que el programa se ejecute realmente en cualquier funcionalidad que la use. Un truco opcional, pero útil.

Continuando, escribamos nuestra primera prueba, en la que se llama a r.Read con un búfer de tamaño n . Para hacer esto, usamos nuestro ReaderMock de la siguiente manera:

 func TestReadN_bufSize(t *testing.T) { total := 0 mr := &MockReader{func(b []byte) (int, error) { total = len(b) return 0, nil }} readN(mr, 5) if total != 5 { t.Fatalf("expected 5, got %d", total) } }

Como puede ver arriba, hemos definido el comportamiento de la función de Read de nuestro io.Reader "falso" con una variable de alcance, que luego se puede usar para afirmar la validez de nuestra prueba. Suficientemente fácil.

Veamos el segundo escenario que necesitamos probar, que requiere que simulemos Read para devolver un error:

 func TestReadN_error(t *testing.T) { expect := errors.New("some non-nil error") mr := &MockReader{func(b []byte) (int, error) { return 0, expect }} _, err := readN(mr, 5) if err != expect { t.Fatal("expected error") } }

En las pruebas anteriores, cualquier llamada a mr.Read (nuestro Lector simulado) devolverá el error definido, por lo que es seguro asumir que el correcto funcionamiento de readN hará lo mismo.

Burlarse de funciones con Go

No es frecuente que necesitemos simular una función, porque tendemos a usar estructuras e interfaces en su lugar. Estos son más fáciles de controlar, pero ocasionalmente podemos toparnos con esta necesidad, y con frecuencia veo confusión en torno al tema. Algunas personas incluso han preguntado cómo burlarse de cosas como log.Println . Aunque rara vez es el caso de que necesitemos probar la entrada dada a log.Println , aprovecharemos esta oportunidad para demostrarlo.

Considere esta declaración if simple a continuación que registra la salida según el valor de n :

 func printSize(n int) { if n < 10 { log.Println("SMALL") } else { log.Println("LARGE") } }

En el ejemplo anterior, asumimos el escenario ridículo en el que probamos específicamente que log.Println se llama con los valores correctos. Para que podamos burlarnos de esta función, primero tenemos que envolverla dentro de la nuestra:

 var show = func(v ...interface{}) { log.Println(v...) }

Declarar la función de esta manera, como una variable, nos permite sobrescribirla en nuestras pruebas y asignarle el comportamiento que queramos. Implícitamente, las líneas que hacen referencia a log.Println se reemplazan con show , por lo que nuestro programa se convierte en:

 func printSize(n int) { if n < 10 { show("SMALL") } else { show("LARGE") } }

Ahora podemos probar:

 func TestPrintSize(t *testing.T) { var got string oldShow := show show = func(v ...interface{}) { if len(v) != 1 { t.Fatalf("expected show to be called with 1 param, got %d", len(v)) } var ok bool got, ok = v[0].(string) if !ok { t.Fatal("expected show to be called with a string") } } for _, tt := range []struct{ N int Out string }{ {2, "SMALL"}, {3, "SMALL"}, {9, "SMALL"}, {10, "LARGE"}, {11, "LARGE"}, {100, "LARGE"}, } { got = "" printSize(tt.N) if got != tt.Out { t.Fatalf("on %d, expected '%s', got '%s'\n", tt.N, tt.Out, got) } } // careful though, we must not forget to restore it to its original value // before finishing the test, or it might interfere with other tests in our // suite, giving us unexpected and hard to trace behavior. show = oldShow }

Nuestra comida para llevar no debería ser 'mock log.Println ', pero en esos escenarios muy ocasionales cuando necesitamos simular una función a nivel de paquete por razones legítimas, la única forma de hacerlo (hasta donde yo sé) es declarándolo como una variable de nivel de paquete para que podamos tomar el control de su valor.

Sin embargo, si alguna vez necesitamos simular cosas como log.Println , se puede escribir una solución mucho más elegante si tuviéramos que usar un registrador personalizado.

Ir a las pruebas de representación de plantillas

Otro escenario bastante común es probar que la salida de una plantilla renderizada está de acuerdo con las expectativas. Consideremos una solicitud GET a http://localhost:3999/welcome?name=Frank , que devuelve el siguiente cuerpo:

 <html> <head><title>Welcome page</title></head> <body> <h1 class="header-name"> Welcome <span class="name">Frank</span>! </h1> </body> </html>

En caso de que no fuera lo suficientemente obvio, no es una coincidencia que el name del parámetro de consulta coincida con el contenido del span clasificado como "nombre". En este caso, la prueba obvia sería verificar que esto suceda correctamente cada vez en múltiples salidas. Encontré que la biblioteca de GoQuery es inmensamente útil aquí.

GoQuery utiliza una API similar a jQuery para consultar una estructura HTML, que es indispensable para probar la validez de la salida de marcado de sus programas.

Ahora podemos escribir nuestra prueba de esta manera:

welcome__test.go

 func TestWelcome_name(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } doc, err := goquery.NewDocumentFromResponse(resp) if err != nil { t.Fatal(err) } if v := doc.Find("h1.header-name span.name").Text(); v != "Frank" { t.Fatalf("expected markup to contain 'Frank', got '%s'", v) } }

Primero, verificamos que el código de respuesta sea 200/OK antes de continuar.

Creo que no es demasiado descabellado suponer que el resto del fragmento de código anterior se explica por sí mismo: recuperamos la URL usando el paquete http y creamos un nuevo documento compatible con goquery a partir de la respuesta, que luego usamos para consultar el DOM que fue devuelto. Verificamos que span.name dentro h1.header-name encapsule el texto 'Frank'.

Probar las API de JSON

Go se usa con frecuencia para escribir API de algún tipo, por lo que, por último, pero no menos importante, veamos algunas formas de alto nivel de probar las API JSON.

Considere si el punto final devolvió previamente JSON en lugar de HTML, por lo que desde http://localhost:3999/welcome.json?name=Frank esperaríamos que el cuerpo de la respuesta se viera como:

 {"Salutation": "Hello Frank!"}

Afirmar respuestas JSON, como uno ya habrá adivinado, no es muy diferente de afirmar respuestas de plantilla, con la excepción de que no necesitamos bibliotecas ni dependencias externas. Las bibliotecas estándar de Go son suficientes. Aquí está nuestra prueba que confirma que se devuelve el JSON correcto para los parámetros dados:

welcome__test.go

 func TestWelcome_name_JSON(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome.json?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } var dst struct{ Salutation string } if err := json.NewDecoder(resp.Body).Decode(&dst); err != nil { t.Fatal(err) } if dst.Salutation != "Hello Frank!" { t.Fatalf("expected 'Hello Frank!', got '%s'", dst.Salutation) } }

Si se devuelve algo que no sea la estructura contra la que decodificamos, json.NewDecoder devolverá un error y la prueba fallará. Teniendo en cuenta que la respuesta se descodifica contra la estructura con éxito, verificamos que el contenido del campo sea el esperado, en nuestro caso "¡Hola, Frank!".

Configuración y desmontaje

Probar con Go es fácil, pero hay un problema tanto con la prueba JSON anterior como con la prueba de representación de plantillas anterior. Ambos asumen que el servidor se está ejecutando y esto crea una dependencia poco confiable. Además, no es una gran idea ir en contra de un servidor "en vivo".

Nunca es una buena idea realizar pruebas con datos "en vivo" en un servidor de producción "en vivo"; active las copias locales o de desarrollo para que no se produzcan daños si las cosas salen terriblemente mal.

Afortunadamente, Go ofrece el paquete httptest para crear servidores de prueba. Las pruebas activan su propio servidor separado, independiente del nuestro principal, por lo que las pruebas no interferirán con la producción.

En estos casos, es ideal crear funciones genéricas de setup y teardown para que las llamen todas las pruebas que requieran un servidor en ejecución. Siguiendo este patrón nuevo y más seguro, nuestras pruebas terminarían luciendo así:

 func setup() *httptest.Server { return httptest.NewServer(app.Handler()) } func teardown(s *httptest.Server) { s.Close() } func TestWelcome_name(t *testing.T) { srv := setup() url := fmt.Sprintf("%s/welcome.json?name=Frank", srv.URL) resp, err := http.Get(url) // verify errors & run assertions as usual teardown(srv) }

Tenga en cuenta la referencia app.Handler() . Esta es una función de mejores prácticas que devuelve el http.Handler de la aplicación, que puede instanciar su servidor de producción o un servidor de prueba.

Conclusión

Probar en Go es una gran oportunidad para asumir la perspectiva externa de su programa y ponerse en el lugar de sus visitantes o, en la mayoría de los casos, de los usuarios de su API. Brinda la gran oportunidad de asegurarse de que está entregando un buen código y una experiencia de calidad.

Siempre que no esté seguro de las funcionalidades más complejas de su código, las pruebas son útiles como garantía y también garantizan que las piezas seguirán funcionando bien juntas al modificar partes de sistemas más grandes.

Espero que este artículo te haya sido útil, y puedes comentar si conoces otros trucos de prueba.