13. Testing

Asi como en la estructura y la organizacion de nuestra app no hay una manera correcta, los test tampoco tiene una , pero tienen convenciones, patrones y buenas practicas.

Aprenderemos lo siguiente:

  • Como crear y correr table-driven and sub-test en Go.
  • Como hacer test unitarios en nuestros handlers HTTP y middlewares.
  • Como realizar end to end testing en nuestra app web, middlewares y handlers.
  • Como crear mocks de nuestro modelo de base de datos y usarlos en test unitarios.
  • Un patron para testear CSRF-protected para los formularios enviados mediante HTML.
  • Como usar una instancia de test de MySQL para hacer test de integracion.
  • Como calcular facilmente la covertura de los test.

13.1 Test unitarios y sub-test

Crearemos un test unitarios para estar serguros de que la funcion humanDate() esta devolviendo valores time.Time en el formato que nosotros queremos.

El codigo original se ve asi:

func humanDate(t time.Time) string {
	return t.Format("02 Jan 2006 at 15:04")
}

13.1 Creado el test unitario

En Go , el estandar para nombrar a los archivos test es *_test.go el cual vive directamente justo al codigo que estas testeando. En nuestro caso vamos a crearlo en cmd/web/templates_test.go.

$ touch cmd/web/templates_test.go

Luego podemos crear un nuevo test unitario para la funcion _humanDate asi:

package main

import (
	"testing"
	"time"
)

func TestHumanDate(t *testing.T) {
	// Initialize a new time.Time object and pass it to the humanDate function.
	tm := time.Date(2023, 3, 17, 10, 15, 0, 0, time.UTC)
	hd := humanDate(tm)
	// Check that the output from the humanDate function is in the format we
	// expect. If it isn't what we expect, use the t.Errorf() function to
	// indicate that the test has failed and log the expected and actual
	// values.
	if hd != "17 Mar 2023 at 10:15" {
		t.Errorf("got %q; want %q", hd, "17 Mar 2023 at 10:15")
	}
}

Este patron es el basico para pobrar casi todas las pruebas en Go.Lo importante es:

  • El test es codigo regular de GO, El cual llama a la funcion humanDate y revisa que el resultado haga match con lo esperado.
  • Tu test unitario esta contenido en una funcion normal de Go con la firma func(*test.T).
  • Para ser un test unitario valido, el nombre de la funcion debe comenzar con Test, Generalmente seguido del nombre de la funcion, metodo o tipo que estas testeando para ayudar a que sea facil saber que estas testeando.
  • Podes usar la funcion t.Errorf() para marcar que los test fallaron e imprimir un menasaje descriptivo sobre el fallo.Es importante notar que llamara t.Errorf() no detiene la ejecucion del test, luego de que lo llames continuara con los siguientes normalmente.

Guaremos y pongamos en la linea de comandos go test para correr todos los test en nuestro paquete cmd/web.

$ go test ./cmd/web
ok github.com/nahueldev23/cmd/web
0.005s

Si quisieras ver mas detalles usarias el flag -v.

$ go test -v ./cmd/web
=== RUN
TestHumanDate
--- PASS: TestHumanDate (0.00s)
PASS
ok github.com/nahueldev23/cmd/web
0.007s

13.1 Table driven test

Vamos a expandir los test para la funcion TestHumanDate() para que cubra casos adicionales como por ejemplo:

  1. Si el input de humanDate() es cero, entonces que retorne un string vacio "".
  2. La salida de humanDate() siempre tiene que ser tiempo de zona UTC.

En Go una manera de tener muchos casos para test es usando table-driven.

Esencialmente, la idea detras de los table-driven es crear una tabla con casos de test que contienen los inputs y los outputs esperados , y hacer un loop sobre ellos,corriendo cada test en un sub-test.Hay algunas maneras que en las que podriamso configurarlas , pero un enfoque comun es definir nuestros test en un slice que sea parte de una estrucura anonima.

package main

import (
	"testing"
	"time"
)

func TestHumanDate(t *testing.T) {
	// Create a slice of anonymous structs containing the test case name,
	// input to our humanDate() function (the tm field), and expected output
	// (the want field).
	tests := []struct {
		name string
		tm   time.Time
		want string
	}{
		{
			name: "UTC",
			tm:   time.Date(2023, 3, 17, 10, 15, 0, 0, time.UTC),
			want: "17 Mar 2023 at 10:15",
		},
		{
			name: "Empty",
			tm:   time.Time{},
			want: "",
		},
		{
			name: "CET",
			tm:   time.Date(2023, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
			want: "17 Mar 2023 at 09:15",
		},
	}
	// Loop over the test cases.
	for _, tt := range tests {
		// Use the t.Run() function to run a sub-test for each test case. The
		// first parameter to this is the name of the test (which is used to
		// identify the sub-test in any log output) and the second parameter is
		// and anonymous function containing the actual test for each case.
		t.Run(tt.name, func(t *testing.T) {
			hd := humanDate(tt.tm)
			if hd != tt.want {
				t.Errorf("got %q; want %q", hd, tt.want)
			}
		})
	}
}

Si corremos el test ahora nos arrojara los errores pertinentes, ya que el caso de EMTPY y CET no estan cubiertos.

Vamos a corregir esto en cmd/web/templates.go

func humanDate(t time.Time) string {
	// Return the empty string if time has the zero value.
	if t.IsZero() {
		return ""
	}
	// Convert the time to UTC before formatting it.
	return t.UTC().Format("02 Jan 2006 at 15:04")
}

Si corremos los test ahora deberian dar todos ok.

13.1 Helpers para aserciones en los test

Como mencionamos antes el siguiente codigo lo usaremos mucho, por lo que haremos una abstraccion.

if actualValue != expectedValue {
  t.Errorf("got %v; want %v", actualValue, expectedValue)
}

Creemos una funcion de ayuda .

Vamos a internal/asset y pongamos el siguiente codigo.

package assert

import (
	"testing"
)

func Equal[T comparable](t *testing.T, actual, expected T) {
	t.Helper()
	if actual != expected {
		t.Errorf("got: %v; want: %v", actual, expected)
	}
}

Fijate que Equal() es una funcion generica, esto significa que estara habilitada para su uso cualquiera sea el tipo de los argumentos, mientras que actual y expected sean del mismo tipo y puedan comparrse con el operador != , por ejemplo dos strings o dos int, nuestro test deberia compilar y funcionar cuando llamemos a Equal() .

La funcion t.Helper que estamos usando le indica a Go test runner que Equal() es un test helper, esto significa que cuadno t.Errorf() es llamado desde Equal() Go test runner reportara el nombre del archivo y el numero de linea donde se llamo a nuestro Equal() .

Con eso en su lugar simplificamos TestHumanDate asi :

package main

import (
	"testing"
	"time"

	"github.com/nahueldev23/snippetbox/internal/assert"
)

func TestHumanDate(t *testing.T) {
	tests := []struct {
		name string
		tm   time.Time
		want string
	}{
		{
			name: "UTC",
			tm:   time.Date(2023, 3, 17, 10, 15, 0, 0, time.UTC),
			want: "17 Mar 2023 at 10:15",
		},
		{
			name: "Empty",
			tm:   time.Time{},
			want: "",
		},
		{
			name: "CET",
			tm:   time.Date(2023, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
			want: "17 Mar 2023 at 09:15",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			hd := humanDate(tt.tm)
			// Use the new assert.Equal() helper to compare the expected and
			// actual values.
			assert.Equal(t, hd, tt.want)
		})
	}
}

13.1 Informacion adicional

13.1 Sub-test sin una tabla de test cases

Es importante decir que no necesitamos sub-test en conjunto con table-driven test, como hicimos antes. Es perfectamente valido ejecutar sub test llamando a t.Run() consecutivamente
en nuestras funciones de test, asi:

func TestExample(t *testing.T) {
	t.Run("Example sub-test 1", func(t *testing.T) {
		// Do a test.
	})
	t.Run("Example sub-test 2", func(t *testing.T) {
		// Do another test.
	})
	t.Run("Example sub-test 3", func(t *testing.T) {
		// And another...
	})
}

13.2 Testeadno handlers HTTP y middleware

Vamos a discutir algunas tecnicas especificas para los test unitarios de nuestros handles HTTP.

Todos los handlers que hemos escrit para este proyecto son un poco complicados de testear. Prefiero empezar con algo mas simple.

Vamos a handlers.go y crea un nuevo handler llamado ping el cual retornara 200 OK como codigo y una respuesta en su body con “OK”

func ping(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("OK"))
}

En esta secciokn crearemos un test unitario TestPing el cual:

  • Revisa el estado de la respuesta del handler ping para ver si es 200.
  • Revisa el cuerpo de la respuesta del handler ping para ver so es “OK”.

13.2 Grabando la respuesta

Go tiene varias herramientas utiles en el paquete net/http/httptest para ayudar a testear los handlers HTTP.

Uno de esas herramientas es el tipo httptest.ResponseRecorder. Este escencialmente es una implementarcion de http.ResponseWriter el cual graba el estado de la respuesta , headers y body en vez de escribirlos en una conexion HTTP.

Una manera facil de hacer un test unitario a nuestros handlers es crear un nuevo httptest.ResponseRecorde y pasarle el handler, luego revisarlo despues de que haya dado la respuesta.

Hagamoslo con el handler ping.

Primero, siguiendo las convenciones de Go crearemos un nuevo archivo llamado handlers_test.go que contendra los test.

package main

import (
	"bytes"
	"io"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/nahueldev23/snippetbox/internal/assert"
)

func TestPing(t *testing.T) {
	// Initialize a new httptest.ResponseRecorder.
	rr := httptest.NewRecorder()
	// Initialize a new dummy http.Request.
	r, err := http.NewRequest(http.MethodGet, "/", nil)
	if err != nil {
		t.Fatal(err)
	}
	// Call the ping handler function, passing in the
	// httptest.ResponseRecorder and http.Request.
	ping(rr, r)
	// Call the Result() method on the http.ResponseRecorder to get the
	// http.Response generated by the ping handler.
	rs := rr.Result()
	// Check that the status code written by the ping handler was 200.
	assert.Equal(t, rs.StatusCode, http.StatusOK)
	// And we can check that the response body written by the ping handler
	// equals "OK".
	defer rs.Body.Close()
	body, err := io.ReadAll(rs.Body)
	if err != nil {
		t.Fatal(err)
	}
	body = bytes.TrimSpace(body)
	assert.Equal(t, string(body), "OK")
}

En el codigo anterior usamos t.Fatal() en un par de lugares para manejar situaciones donde haya un error inesperado en nuestro codigo de testing.Cuando llamamos t.Fatal marcara que el test fallo, mostrara el error y lugo detendra completamente la ejecucion del test o sub-test.

Por lo general llamamos a t.Fatal() cuando no tiene sentido continuar el test actual, como un error durante los pasos de configuracion o algun error inesperado de las librerias estandar de Go , lo que significa que no podes proceder con el test.

Guardamos el archivo y corremos el test.Deberiamos ver todo OK!

13.2 Testeando el middleware

Tambien es posible usar el mismo patron general para test unitarios en nuestro middlewares.

Crearemoos un TestSecureHeaders para testear el midleware secureHeaders(), en este test queremos:

  • Que secureHeaders() setee todos los headers como espeamos en la respuesta.
  • Que secureHeaders() llame correctamente al siguiente handler en la cadena.

Comenzamos creando cmd/web/middleware_test.go.

$ touch cmd/web/middleware_test.go

Agregamos el siguiente codigo:

package main

import (
	"bytes"
	"io"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/nahueldev23/snippetbox/internal/assert"
)

func TestSecureHeaders(t *testing.T) {
	// Initialize a new httptest.ResponseRecorder and dummy http.Request.
	rr := httptest.NewRecorder()
	r, err := http.NewRequest(http.MethodGet, "/", nil)
	if err != nil {
		t.Fatal(err)
	}
	// Create a mock HTTP handler that we can pass to our secureHeaders
	// middleware, which writes a 200 status code and an "OK" response body.
	next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("OK"))
	})
	// Pass the mock HTTP handler to our secureHeaders middleware. Because
	// secureHeaders *returns* a http.Handler we can call its ServeHTTP()
	// method, passing in the http.ResponseRecorder and dummy http.Request to
	// execute it.
	secureHeaders(next).ServeHTTP(rr, r)
	// Call the Result() method on the http.ResponseRecorder to get the results
	// of the test.
	rs := rr.Result()
	// Check that the middleware has correctly set the Content-Security-Policy
	// header on the response.
	expectedValue := "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com"
	assert.Equal(t, rs.Header.Get("Content-Security-Policy"), expectedValue)
	// Check that the middleware has correctly set the Referrer-Policy
	// header on the response.
	expectedValue = "origin-when-cross-origin"
	assert.Equal(t, rs.Header.Get("Referrer-Policy"), expectedValue)
	// Check that the middleware has correctly set the X-Content-Type-Options
	// header on the response.
	expectedValue = "nosniff"
	assert.Equal(t, rs.Header.Get("X-Content-Type-Options"), expectedValue)
	// Check that the middleware has correctly set the X-Frame-Options header
	// on the response.
	expectedValue = "deny"
	assert.Equal(t, rs.Header.Get("X-Frame-Options"), expectedValue)
	// Check that the middleware has correctly set the X-XSS-Protection header
	// on the response
	expectedValue = "0"

	assert.Equal(t, rs.Header.Get("X-XSS-Protection"), expectedValue)
	// Check that the middleware has correctly called the next handler in line
	// and the response status code and body are as expected.
	assert.Equal(t, rs.StatusCode, http.StatusOK)
	defer rs.Body.Close()
	body, err := io.ReadAll(rs.Body)
	if err != nil {
		t.Fatal(err)
	}
	body = bytes.TrimSpace(body)
	assert.Equal(t, string(body), "OK")
}

En resumen la manera rapida y facil para hacer unit test a los
handlers HTTP y middleware es llamando a httptest.ResponseRecorder. Luego podes examinar el status code , headers y cuerpo de la respuesta para asegurarte que todo funciona como esperabas.

13.3 Testing End-to-end

Vamos a explicar en esta seccion como correr test E2E o end-to-end en nuestra app web para que abarque nuestras rutas,middleware y handlers, en muchos casos el test E2E te deberia dar mas confianza de que te app funciona correctamente que un test unitario de manera aislada.

Para demostrar esto adaparemos nuestra funcion TestPing paa que corra en test E2E, especialmente queremos estar serguros de que la peticion a GET /ping llame al handler ping y que el resultado del codigo de respuesta sea 200 y el body sea “OK”.

Basicamente queremos testear que nuestra app tiene una ruta asi:

MethodPatternHandlerAction
GET/pingpingReturn a 200 OK response

13.3 Usando httptest.Server

La clave para testear de manera E2E nuestra app es usando la funcion httptest.NewTLSServer(), la cual crea una instancia a la que le podemos hacer solicitudes HTTPS.

El patron es un poco complicado para explicarlo al vuelo. Asi que probablemente sea mejor primero escribir el codigo y hablar de los detalles despues.

Con esto en mente vamos a handlers_test.go y actualicemos TestPing para que se vea asi:

func TestPing(t *testing.T) {
	// Create a new instance of our application struct. For now, this just
	// contains a structured logger (which discards anything written to it).
	app := &application{
		logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
	}
	// We then use the httptest.NewTLSServer() function to create a new test
	// server, passing in the value returned by our app.routes() method as the
	// handler for the server. This starts up a HTTPS server which listens on a
	// randomly-chosen port of your local machine for the duration of the test.
	// Notice that we defer a call to ts.Close() so that the server is shutdown
	// when the test finishes.
	ts := httptest.NewTLSServer(app.routes())
	defer ts.Close()
	// The network address that the test server is listening on is contained in
	// the ts.URL field. We can use this along with the ts.Client().Get() method
	// to make a GET /ping request against the test server. This returns a
	// http.Response struct containing the response.
	rs, err := ts.Client().Get(ts.URL + "/ping")
	if err != nil {
		t.Fatal(err)
	}
	// We can then check the value of the response status code and body using
	// the same pattern as before.
	assert.Equal(t, rs.StatusCode, http.StatusOK)
	defer rs.Body.Close()
	body, err := io.ReadAll(rs.Body)
	if err != nil {
		t.Fatal(err)
	}
	body = bytes.TrimSpace(body)
	assert.Equal(t, string(body), "OK")
}
  • Cuando llamamos a httptest.NewTLSServer() para inicializar el test server necesitamos pasar un http.Handler como parametro, este handler es llamado en cada peticion https que llegue al test server. En nuestro caso hemos pasado el valor de retorno de nuestrro app.Routes lo que significa que la peticion al test server usara todas las rutas reales,middlewares y handlers.

Esta es una gran ventaja del trabajo que hicimos antes para aislar todas nuestras rutas en el metodo app.routes().

  • Si estas testeando un HTTP (no HTTPS), deberias usar la funcion httptest.NewServer().
  • El metodo ts.Client() retoena el test server client,el cual es de tipo http.Client y deberiamos usar siempre este cliente para enviar peticiones al test server. Es posible configurar el cliente para modificar su comportamiento,y explicare como hacerlo al final de este capitulo.
  • Te estaras preguntando , por que tenemos seteado el campo logger en nuestra estrucura application, pero no los otros campos. La razon para esto es que logger es necesario por los middleware logRequest y recoverPanic , lo cuales son usados por nuetra app en cada ruta. Si intentas correr este test sin eso veras en la consola un panic.

Si intenas correr el test ahora vas recibir un 404 en vez del 200 que estamos esperando, esto es asi porque no registramos GET /ping en las rutas.Hagamoslo.

package main

import (
	"net/http"

	"github.com/julienschmidt/httprouter"
	"github.com/justinas/alice"
	"github.com/nahueldev23/snippetbox/ui"
)

func (app *application) routes() http.Handler {

	router := httprouter.New()

	router.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		app.notFound(w)
	})

	fileServer := http.FileServer(http.FS(ui.Files))

	router.Handler(http.MethodGet, "/static/*filepath", fileServer)

	// Add a new GET /ping route.
	router.HandlerFunc(http.MethodGet, "/ping", ping)

	dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)

	router.Handler(http.MethodGet, "/", dynamic.ThenFunc(app.home))
	router.Handler(http.MethodGet, "/snippet/view/:id", dynamic.ThenFunc(app.snippetView))
	router.Handler(http.MethodGet, "/user/signup", dynamic.ThenFunc(app.userSignup))
	router.Handler(http.MethodPost, "/user/signup", dynamic.ThenFunc(app.userSignupPost))
	router.Handler(http.MethodGet, "/user/login", dynamic.ThenFunc(app.userLogin))
	router.Handler(http.MethodPost, "/user/login", dynamic.ThenFunc(app.userLoginPost))

	protected := dynamic.Append(app.requireAuthentication)
	router.Handler(http.MethodGet, "/snippet/create", protected.ThenFunc(app.snippetCreate))
	router.Handler(http.MethodPost, "/snippet/create", protected.ThenFunc(app.snippetCreatePost))
	router.Handler(http.MethodPost, "/user/logout", protected.ThenFunc(app.userLogoutPost))

	standard := alice.New(app.recoverPanic, app.logRequest, sercureHeaders)

	return standard.Then(router)
}

Si corremos el test ahora pasara.

13.3 Usando test helpers

Nuestro test TestPing esta funcionando correctamente. Pero es una oportunidad dividir un poco de este codigo en una funcion helper, la cual se pueda reusar cuando hagamos mas test E2E en nuestro proyecto.

Si el helper solo es usado en un *_test.go especifico, entonces tiene sentido incluirlo en ese archivo y usarlo en los test pertinentes. Por otro lado si vas a usar ese helper a traves de varios paquetes, puede que quieras ponerlo enun paquete reusable llamado internal/testutils (o similar), el cual podes importar en tus archivos de test.

En neustro caso,los helpers seran usados para testear codigo a traves del paquete cmd/web y en ningun otro lugar.A si que tiene sentido ponerlos en un nuevo archivo cmd/web/testutils_test.go.

Creemoslo.

$ touch cmd/web/testutils_test.go

Y agregamos el siguiente codigo.

package main

import (
	"bytes"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"testing"
)

// Create a newTestApplication helper which returns an instance of our
// application struct containing mocked dependencies.
func newTestApplication(t *testing.T) *application {
	return &application{
		logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
	}
}

// Define a custom testServer type which embeds a httptest.Server instance.
type testServer struct {
	*httptest.Server
}

// Create a newTestServer helper which initalizes and returns a new instance
// of our custom testServer type.
func newTestServer(t *testing.T, h http.Handler) *testServer {
	ts := httptest.NewTLSServer(h)
	return &testServer{ts}
}

// Implement a get() method on our custom testServer type. This makes a GET
// request to a given url path using the test server client, and returns the
// response status code, headers and body.
func (ts *testServer) get(t *testing.T, urlPath string) (int, http.Header, string) {
	rs, err := ts.Client().Get(ts.URL + urlPath)
	if err != nil {
		t.Fatal(err)
	}
	defer rs.Body.Close()
	body, err := io.ReadAll(rs.Body)
	if err != nil {
		t.Fatal(err)
	}
	body = bytes.TrimSpace(body)
	return rs.StatusCode, rs.Header, string(body)
}

Escencialmente es una generalizacion del codigo que ya habiamos escrito.

Pongamos los nuevos helpers en TestPing

package main

import (
	"net/http"
	"testing"

	"github.com/nahueldev23/snippetbox/internal/assert"
)

func TestPing(t *testing.T) {
	app := newTestApplication(t)
	ts := newTestServer(t, app.routes())
	defer ts.Close()
	code, _, body := ts.get(t, "/ping")
	assert.Equal(t, code, http.StatusOK)
	assert.Equal(t, body, "OK")
}

Si corremos los test, todos pasaran.

13.3 Cookies y redirecciones

Hasta ahora hemos estado usando las opciones por default test server client. Pero hay un par de cambios que me gustaria hacer para que se adapete mejor a las pruebas de nuestra app.

  • Queremos que el cliente automaticamente almacene cualquier cookie enviada en una respuesta HTTPS. De modo que podamos incluirlos (si corresponde) en cualquier solicitud posterior al servidor de pruebas , esto sera util luego cuando necesitemos las cookies sea compatibles a traves de multiples peticiones para probar nuestras medidas anti CSRF.
  • No queremos que el cliente automaticamente siga las redirecciones. En su lugar queremos retornar la primer respuesta HTTPS enviada a nuestro server, de manera que podamos testear la respuesta para esa peticion especifica.

Vamos a testutils_test.go y actualicemos newTestServer() asi:

func newTestServer(t *testing.T, h http.Handler) *testServer {
	// Initialize the test server as normal.
	ts := httptest.NewTLSServer(h)
	// Initialize a new cookie jar.
	jar, err := cookiejar.New(nil)
	if err != nil {
		t.Fatal(err)
	}
	// Add the cookie jar to the test server client. Any response cookies will
	// now be stored and sent with subsequent requests when using this client.
	ts.Client().Jar = jar
	// Disable redirect-following for the test server client by setting a custom
	// CheckRedirect function. This function will be called whenever a 3xx
	// response is received by the client, and by always returning a
	// http.ErrUseLastResponse error it forces the client to immediately return
	// the received response.
	ts.Client().CheckRedirect = func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}
	return &testServer{ts}
}

13.4 Personalizar como corren los test

Antes de continuar mas test, quiero hacer un corte y hablar sobre algunas flags utiles y opciones que podemos personalizar al momento de correr los test.

13.4 Controlando que test corremos

Antes hemos corrido los test en un paquete especifico (cmd/web) asi:

$ go test ./cmd/web

Pero es posible correr todos los test en el proyecto actual usando el comodin ./… asi:

$ go test ./...

O por el contrario, es posible solo correr un test especifco, usando el flag -run, que permite pasar una expresion regular y solo testear a quienes hagan match con el.

Por ejemplo para testear TestPing:

$ go test -v -run="^TestPing$" ./cmd/web/
=== RUN   TestPing
--- PASS: TestPing (0.00s)
PASS
ok

E incluso podes correr un subtest con -run y el formato {test regexp}/{sub-test regexp}.Por ejemplo para correr el subtest UTC del test TestHumanDate.

$ go test -v -run="^TestHumanDate$/^UTC$" ./cmd/web
=== RUN   TestHumanDate
=== RUN   TestHumanDate/UTC
--- PASS: TestHumanDate (0.00s)
    --- PASS: TestHumanDate/UTC (0.00s)
PASS
ok

Por el contrario podemos prevenir corre ciertos test con el flag -skip. Este tambien nos permite pasarle una expresion regular y testear cualquier archivo que no haga match. Por ejemplo para saltarnos el test de TestHumanDate.

$ go test -v -skip="^TestHumanDate$" ./cmd/web/
=== RUN   TestPing
--- PASS: TestPing (0.00s)
=== RUN   TestSecureHeaders
--- PASS: TestSecureHeaders (0.00s)
PASS
ok

13.4 Cacheando el test

Quizas hayas notado que si corres el mismo test dos veces, sin haber hecho cambios en el paquete que estas testeando, entonces una version cacheada sera mostrada, indicada con la notacion (cached) despues del nombre del paquete.

$ go test ./cmd/web
ok      github.com/nahueldev23/cmd/web      (cached)

En la mayoria de los casos. tener en cache los resultados es realmente util, porque ayuda a reducir el total del tiempo de ejecucion del test. Pero si queres forzar a que los test corran sin usar el cache podes usar el flag -count=1.

$ go test -count=1 ./cmd/web

Como alternativa podemos usar go clean para borrar el cache de todas las pruebas.

$ go clean -testcache

13.4 Falla rapida (Fast failure)

Cuando usamos t.Errorf() para marcar que un test fallo, esto no causa que go test salga inmediatamente. Todos tus test y sub-test continuaran corriendo luego del fallo.

Si preferis que los test terminen inmediatamente despues del primer error ,podes usar el flag -failfast.

$ go test -failfast ./cmd/web

Es importante decir que -failfast solo detendra los test en el paquete en el que fallo. Si estas corriedno test en multiples paquete con go test ./… esos test en otros paquetes continuaran corriendo.

13.4 Testing paralelo

Por default go test ejecuta todos los test uno detras de otro. Cuando tenes pocos test como es nuestro caso, que esto sea asi es bueno.

Pero si tenes miles de test el total del tiempo en run time puede crecer de manera significativa. En esos casos podes ahorrar tiempo haciedo los test de manera paralela.

Podes indicar que queres que un test corra de manera paralela con la funcion t.Parallel() al principio del test:

func TestPing(t *testing.T) {
	t.Parallel()

}

Es importante saber que :

  • Los test que usen t.Parallel() correran solo con otros test paralelos.
  • Por default el maximo de numeros de test que podemos correr simultaneamente es el valor actual de GOMAXPROCS. Podes sobrescribit esto seteando un valor especifico mediante el flag -parallel.
$ go test -parallel=4 ./...
  • No todos los tests son aptos para correr en paralelo. Por ejemplo, si tenés un test de integración que necesita que una tabla de base de datos esté en un estado específico conocido, entonces no querrías ejecutarlo en paralelo con otros tests que manipulan la misma tabla de base de datos.

13.4 Habilitando el detector de carrera

El comando go test tiene el flag -race el cual activa el race detector cuando corre los test.

Si el codigo que estas testeando usa concurrencia o si estas usando test en paralelo, activar esto puede ser una buena idea para ayudar a las race conditions que exista en tu app.

$ go test -race ./cmd/web/

Tenes que tener en cuenta que el race detector tiene una utilidad limitada. no garantiza que su código esté libre de condiciones de carrera.

Activar race detector incrementara el tiempo de tus test.

13.5 Mocking de dependencias

Vmos a explicar un patron general para testear nuestras app web, En esta seccion escribiremos algunos test para nuestra routa GET /snippet/view/:id.

Pero primero, vamos a hablar sobre las dependencias.

En este proyecto estuvimos injectando dependencias en nuestros handlers mediante la estructura application, la cual actualmente se ve asi:

type application struct {
	logger         *slog.Logger
	snippets       *models.SnippetModel
	users          *models.UserModel
	templateCache  map[string]*template.Template
	formDecoder    *form.Decoder
	sessionManager *scs.SessionManager
}

Cuando tesetamos, algunas veces tiene sentido hacer un mock de las dependencias en vez de usar las reales.

Por ejemplo, en el capitulo anterior hicimos un mock de la dependecia logger que escribe mensages hacia io.Discard en vez de os.Stdout como lo hace realmente en la app.

func newTestApplication(t *testing.T) *application {
	return &application{
		logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
	}
}

La razon por la que hacemos un mock y escribimos en io.Discard es para evitar saturar la salida de nuestros tests con mensajes de registro innecesarios cuando ejecutamos go test -v (con el modo verbose habilitado).

Las otras dos dependencias que tiene sentido hacerles un mock son los modelos de basdes de datos models.SnippetModel y models.UserModel. Al crear simulaciones de estas es posible probar el comportamiento de nuestros controladores sin necesidad de configurar una instancia completa de la DB MySQL para las pruebas.

13.5 Mocking de los modelos de las DB

Crea un nuevo paquete internal/models/mocks que contenga los archivos snippets.go y user.go para almacenar las simulaciones de los modelos de base de datos, de la siguiente manera:

$ mkdir internal/models/mocks
$ touch internal/models/mocks/snippets.go
$ touch internal/models/mocks/users.go

Comencemos creando un mock de nuestro models.SnippetModel. Para hacer esto, vamos a crear un estructura simple, la cual implemente los mismos metodos que models.SnippetModel de produccion, pero estos metodos devolveran datos puestos a mano pr nosotros.

package mocks

import (
	"time"

	"github.com/nahueldev23/snippetbox/internal/models"
)

var mockSnippet = models.Snippet{
	ID:      1,
	Title:   "An old silent pond",
	Content: "An old silent pond...",
	Created: time.Now(),
	Expires: time.Now(),
}

type SnippetModel struct{}

func (m *SnippetModel) Insert(title string, content string, expires int) (int, error) {
	return 2, nil
}
func (m *SnippetModel) Get(id int) (models.Snippet, error) {
	switch id {
	case 1:
		return mockSnippet, nil
	default:
		return models.Snippet{}, models.ErrNoRecord
	}
}
func (m *SnippetModel) Latest() ([]models.Snippet, error) {
	return []models.Snippet{mockSnippet}, nil
}

Y lo mismo con models.UserModel.

package mocks

import "github.com/nahueldev23/snippetbox/internal/models"

type UserModel struct{}

func (m *UserModel) Insert(name, email, password string) error {
	switch email {
	case "dupe@example.com":
		return models.ErrDuplicateEmail
	default:
		return nil
	}
}
func (m *UserModel) Authenticate(email, password string) (int, error) {
	if email == "alice@example.com" && password == "pa$$word" {
		return 1, nil
	}
	return 0, models.ErrInvalidCredentials
}
func (m *UserModel) Exists(id int) (bool, error) {
	switch id {
	case 1:
		return true, nil
	default:
		return false, nil
	}
}

13.5 Inicializando los mocks

Para el siguiente paso, vamos a volver a testutils_test y actualicemos la funcion newTestApplication() para que cree una estructura application con todas las dependecias necesarias para el testing.

func newTestApplication(t *testing.T) *application {
	// Create an instance of the template cache.
	templateCache, err := newTemplateCache()
	if err != nil {
		t.Fatal(err)
	}
	// And a form decoder.
	formDecoder := form.NewDecoder()
	// And a session manager instance. Note that we use the same settings as
	// production, except that we *don't* set a Store for the session manager.
	// If no store is set, the SCS package will default to using a transient
	// in-memory store, which is ideal for testing purposes.
	sessionManager := scs.New()
	sessionManager.Lifetime = 12 * time.Hour
	sessionManager.Cookie.Secure = true
	return &application{
		logger:         slog.New(slog.NewTextHandler(io.Discard, nil)),
		snippets:       &mocks.SnippetModel{}, // Use the mock.
		users:          &mocks.UserModel{},    // Use the mock.
		templateCache:  templateCache,
		formDecoder:    formDecoder,
		sessionManager: sessionManager,
	}

}

Si corremos los test ahora vamos a tener este error :

$ go test ./cmd/web
# https://github.com/nahueldev23/cmd/web [https://github.com/nahueldev23/cmd/web.test]
cmd/web/testutils_test.go:40:19: cannot use &mocks.SnippetModel{} (value of type *mocks.SnippetModel) as type *models.SnippetModel in struct litecmd/web/testutils_test.go:41:19: cannot use &mocks.UserModel{} (value of type *mocks.UserModel) as type *models.UserModel in struct literal
FAIL https://github.com/nahueldev23/cmd/web [build failed]
FAIL

Esto sucede porque nuetra estrucura application esta esperadno punteros a instancias models.SnippetModel y models.UserModel , pero estamos usando punteros mocks.SnippetModel y mocks.UserModel.

Una manera de arreglar esto es cambiar en la estructura application para que use interfaces las cuales satisfagan ambos modelos, los de mock y los de produccion.

Si no estas familiarizado con las interfaces en Go mira esto

Volvamos a internal/models/snippets.go y creemos un nuevo SnippetModelInterface que describa los metodos que tenemos actualmente en la estructura SnippetModel.

type SnippetModelInterface interface {
	Insert(title string, content string, expires int) (int, error)
	Get(id int) (*Snippet, error)
	Latest() ([]*Snippet, error)
}

y lo mismo para la estructura userModel

type UserModelInterface interface {
	Insert(name, email, password string) error
	Authenticate(email, password string) (int, error)
	Exists(id int) (bool, error)
}

Ahora que definimos esas interfaces vamos a actualizar la estructura application para que las use , en vez de las concreciones SnippetModel y UserModel.

type application struct {
		logger         *slog.Logger
+		snippets       models.SnippetModelInterface
+		users          models.UserModelInterface
		templateCache  map[string]*template.Template
		formDecoder    *form.Decoder
		sessionManager *scs.SessionManager
	}

Si corres el test ahora deberia dar todo OK.

Hemos actualizado la estructura de la aplicación para que en lugar de que los campos snippets y users tengan los tipos concretos models.SnippetModel y models.UserModel, sean interfaces en su lugar. Siempre y cuando un tipo tenga los métodos necesarios para satisfacer la interfaz, podemos usarlo en nuestra estructura de aplicación. Tanto nuestros modelos de base de datos “reales” (como models.SnippetModel) como los modelos de base de datos simulados (como mocks.SnippetModel) satisfacen las interfaces, por lo que ahora podemos usarlos de manera intercambiable.

13.5 Testeando el handler snippetView

Con todo configurado continuemos escribiendo un test E2E para nuestros handler snippetView el cual usa esos mocks como dependencias.

Como parte de este test,el codigo de nuestro handler snippetView llamara al metodo mock.SnippetModel.Get(). Solo para recordarte el metodo retorna models.ErrNoRecord al menos que el snippet ID sea 1. Tendremos como retorno lo siguiente.

var mockSnippet = models.Snippet{
	ID:      1,
	Title:   "An old silent pond",
	Content: "An old silent pond...",
	Created: time.Now(),
	Expires: time.Now(),
}

Nosotros queremos testear:

  1. Para la peticion GET /snippet/view/1 recibir la respuesta 200 OK con los datos del mock en el cuerpo de la respusta.
  2. Para todas las demas peticiones GET /snippet/view/* deberiamos recibir 404 Not Found.

Para la primera parte, queremos revisar si el cuerpo de la respuesta contiene un algunos datos especificos, mas que sean exactamente iguales. Creemos la funcion StringContains() para nuestro paquete assert que nos ayudara a hacer esto.

func StringContains(t *testing.T, actual, expectedSubstring string) {
	t.Helper()
	if !strings.Contains(actual, expectedSubstring) {
		t.Errorf("got: %q; expected to contain: %q", actual, expectedSubstring)
	}
}

Luego vamos a cmd/web/handlers_test.go y creemos el test TestSnippetView con lo siguiente:

func TestSnippetView(t *testing.T) {
	// Create a new instance of our application struct which uses the mocked
	// dependencies.
	app := newTestApplication(t)
	// Establish a new test server for running end-to-end tests.
	ts := newTestServer(t, app.routes())
	defer ts.Close()
	// Set up some table-driven tests to check the responses sent by our
	// application for different URLs.
	tests := []struct {
		name     string
		urlPath  string
		wantCode int
		wantBody string
	}{
		{
			name:     "Valid ID",
			urlPath:  "/snippet/view/1",
			wantCode: http.StatusOK,
			wantBody: "An old silent pond...",
		},
		{
			name:     "Non-existent ID",
			urlPath:  "/snippet/view/2",
			wantCode: http.StatusNotFound,
		},
		{
			name:     "Negative ID",
			urlPath:  "/snippet/view/-1",
			wantCode: http.StatusNotFound,
		},
		{
			name:     "Decimal ID",
			urlPath:  "/snippet/view/1.23",
			wantCode: http.StatusNotFound,
		},
		{
			name:     "String ID",
			urlPath:  "/snippet/view/foo",
			wantCode: http.StatusNotFound,
		},
		{
			name:     "Empty ID",
			urlPath:  "/snippet/view/",
			wantCode: http.StatusNotFound,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			code, _, body := ts.get(t, tt.urlPath)
			assert.Equal(t, code, tt.wantCode)
			if tt.wantBody != "" {
				assert.StringContains(t, body, tt.wantBody)
			}
		})
	}
}

13.6 Testing HTML forms

En este capitulo vamos a agregar un test E2E para la ruta POST /user/signup el cual maneja al handler userSignupPost.

Testear esta ruta es un poco mas coplicado, por que tenemos el anti-CSRF. Cualquier peticion que vaya a POST /user/signup siempre retornara un 400 Bad Request almenos que la peticion tenga un token CSRF valido y una cookie. Para testear esto tenemos que simular el flujo real del usuario.

  1. Realizar una peticion GET /user/signup. Esto retornara una respuesta la cual contiene una cookie CSRF en los headers y el token CSRF para la pagina de registro en el cuerpo de la respuesta.
  2. Extraer el token CSRF del cuerpo de la respueta.
  3. Hacer un POST user/signin , usando el mismo http.Client que usamos en el paso 1 ( automaticamente pasa la cookie CSRF con la peticion POST) e incluir el token junto con otros datos POST que queremos probar.

Comencemos agregando una nueva funcion helper a nuestro cmd/web/testutils_test.go para extraer el token CSFR (si existe) del cuerpo del HTML.

// Define a regular expression which captures the CSRF token value from the
// HTML for our user signup page.
var csrfTokenRX = regexp.MustCompile(`<input type='hidden' name='csrf_token' value='(.+)'>`)

func extractCSRFToken(t *testing.T, body string) string {
	// Use the FindStringSubmatch method to extract the token from the HTML body.
	// Note that this returns an array with the entire matched pattern in the
	// first position, and the values of any captured data in the subsequent
	// positions.
	matches := csrfTokenRX.FindStringSubmatch(body)
	if len(matches) < 2 {
		t.Fatal("no csrf token found in body")
	}
	return html.UnescapeString(string(matches[1]))
}

Puede que te estés preguntando por qué estamos utilizando la función html. UnescapeString() antes de devolver el token CSRF. La razón de esto es porque el paquete html/template de Go automáticamente escapa todos los datos renderizados dinámicamente… incluyendo nuestro token CSRF. Dado que el token CSRF es una cadena codificada en base64, potencialmente incluirá el carácter +, y esto será escapado como +. Por lo tanto, después de extraer el token del HTML, necesitamos ejecutarlo a través de html.UnescapeString() para obtener el valor original del token.

Ahora con eso en su lugar volvamos a cmd/web/handlers_test.go y crea un nuevo test TestUserSignup.

Para empezar vamos a realizar una peticion a GET /user/signup y luego extraeremos y mostraremos en terminal el CSFR token del HTML.

func TestUserSignup(t *testing.T) {
	// Create the application struct containing our mocked dependencies and set
	// up the test server for running an end-to-end test.
	app := newTestApplication(t)
	ts := newTestServer(t, app.routes())
	defer ts.Close()
	// Make a GET /user/signup request and then extract the CSRF token from the
	// response body.
	_, _, body := ts.get(t, "/user/signup")
	csrfToken := extractCSRFToken(t, body)
	// Log the CSRF token value in our test output using the t.Logf() function.
	// The t.Logf() function works in the same way as fmt.Printf(), but writes
	// the provided message to the test output.
	t.Logf("CSRF token is: %q", csrfToken)
}

Es importante que corras el test con el flag -v para que puedas ver la consola lo que devuelve t.Logf(). Todo deberia darte OK.

Si ejecutas esta prueba por segunda vez inmediatamente después, sin cambiar nada en el paquete cmd/web, obtendrás el mismo token CSRF en la salida de la prueba porque los resultados de la prueba han sido almacenados en caché.

13.6 Testeando peticiones post

Volvamos a cmd/web/testutils_test.go y creemos un nuevo metodo postForm() para la estrucura testServer, el cual puede ser usado para enviar peticiones POST a nuestro test server con data especifica en la peticion del body.

// Create a postForm method for sending POST requests to the test server. The
// final parameter to this method is a url.Values object which can contain any
// form data that you want to send in the request body.
func (ts *testServer) postForm(t *testing.T, urlPath string, form url.Values) (int, http.Header, string) {
	rs, err := ts.Client().PostForm(ts.URL+urlPath, form)
	if err != nil {
		t.Fatal(err)
	}
	// Read the response body from the test server.
	defer rs.Body.Close()
	body, err := io.ReadAll(rs.Body)
	if err != nil {
		t.Fatal(err)
	}
	body = bytes.TrimSpace(body)
	// Return the response status, headers and body.
	return rs.StatusCode, rs.Header, string(body)
}

Y ahora, finalmente, estamos listos para agregar algunas subpruebas basadas en tablas para probar el comportamiento de la ruta POST /user/signup de nuestra aplicación. Específicamente, queremos probar que:

  • Un registro valido devuelve una respuesta 303 See Other.

  • Un formulario sin un token CSFR valido resulte en una respuesta 400 Bad Request.

  • Un envio del formulario invalido devuelve una respuesta 422 Unprocessable Entity y el formulario de registro es re renderizado. Esto deberia ocurrir cuando:

  • Los campos de nombre, correo electrónico o contraseña están vacíos.

  • El correo electrónico no tiene un formato válido.

  • La contraseña tiene menos de 8 caracteres de longitud.

  • La dirección de correo electrónico ya está en uso.

Actualicemos TestUserSignup para que haga esos test.

func TestUserSignup(t *testing.T) {
	app := newTestApplication(t)
	ts := newTestServer(t, app.routes())
	defer ts.Close()

	_, _, body := ts.get(t, "/user/signup")
	validCSRFToken := extractCSRFToken(t, body)

	const (
		validName     = "Bob"
		validPassword = "validPa$$word"
		validEmail    = "bob@example.com"
		formTag       = "<form action='/user/signup' method='POST' novalidate>"
	)

	tests := []struct {
		name         string
		userName     string
		userEmail    string
		userPassword string
		csrfToken    string
		wantCode     int
		wantFormTag  string
	}{
		{
			name:         "Valid submission",
			userName:     validName,
			userEmail:    validEmail,
			userPassword: validPassword,
			csrfToken:    validCSRFToken,
			wantCode:     http.StatusSeeOther,
		},
		{
			name:         "Invalid CSRF Token",
			userName:     validName,
			userEmail:    validEmail,
			userPassword: validPassword,
			csrfToken:    "wrongToken",
			wantCode:     http.StatusBadRequest,
		},
		{
			name:         "Empty name",
			userName:     "",
			userEmail:    validEmail,
			userPassword: validPassword,
			csrfToken:    validCSRFToken,
			wantCode:     http.StatusUnprocessableEntity,
			wantFormTag:  formTag,
		},
		{
			name:         "Empty email",
			userName:     validName,
			userEmail:    "",
			userPassword: validPassword,
			csrfToken:    validCSRFToken,
			wantCode:     http.StatusUnprocessableEntity,
			wantFormTag:  formTag,
		},
		{
			name:         "Empty password",
			userName:     validName,
			userEmail:    validEmail,
			userPassword: "",
			csrfToken:    validCSRFToken,
			wantCode:     http.StatusUnprocessableEntity,
			wantFormTag:  formTag,
		},
		{
			name:         "Invalid email",
			userName:     validName,
			userEmail:    "bob@example.",
			userPassword: validPassword,
			csrfToken:    validCSRFToken,
			wantCode:     http.StatusUnprocessableEntity,
			wantFormTag:  formTag,
		},
		{
			name:         "Short password",
			userName:     validName,
			userEmail:    validEmail,
			userPassword: "pa$$",
			csrfToken:    validCSRFToken,
			wantCode:     http.StatusUnprocessableEntity,
			wantFormTag:  formTag,
		},
		{
			name:         "Duplicate email",
			userName:     validName,
			userEmail:    "dupe@example.com",
			userPassword: validPassword,
			csrfToken:    validCSRFToken,
			wantCode:     http.StatusUnprocessableEntity,
			wantFormTag:  formTag,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			form := url.Values{}
			form.Add("name", tt.userName)
			form.Add("email", tt.userEmail)
			form.Add("password", tt.userPassword)
			form.Add("csrf_token", tt.csrfToken)

			code, _, body := ts.postForm(t, "/user/signup", form)

			assert.Equal(t, code, tt.wantCode)

			if tt.wantFormTag != "" {
				assert.StringContains(t, body, tt.wantFormTag)
			}
		})
	}
}

Todo deberia dar OK.

13.7 Testing de integracion

Correr los test E2E con mocks de dependecias es algo bueno , pero podemos mejorar la confianza de nuestra app incluso mas , si verificamos que los modelos reales de las db funcionan como esperamos.

Para hacer esto podemos testear contra una db de MySQL de prueba, la cual imita nuestra db en produccion, pero solo la usamos para hacer los test.

En este captitulo vamos a configurar una integracion completa pra ver si el metodo models.UserModel.Exist funciona correctamente.

13.7 Configuracion de la Dd

El primer paso es crear una version de test de nuestra DB MySQL.

Vamos a la terminal y ejecutemos como root MySQL y usemos la siguiente declaracion para crear una db test_snippetbox y un usuario test_web.

CREATE DATABASE test_snippetbox CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'test_web'@'localhost';
GRANT CREATE, DROP, ALTER, INDEX, SELECT, INSERT, UPDATE, DELETE ON test_snippetbox.* TO 'test_web'@'localhost';
ALTER USER 'test_web'@'localhost' IDENTIFIED BY 'pass';

Una vez tengamos hecho esto vamos a hacer dos scripts:

  1. Un script para crear las tablas en la db (para que imite a las de produccion) e inserte un conjunto conocido de datos para que podamos trabajar con nuestros test.
  2. Un script que haga un drop de las tablas y los datos.

Vamos a crear esos scripts en internal/models/testdata.

$ mkdir internal/models/testdata
$ touch internal/models/testdata/setup.sql
$ touch internal/models/testdata/teardown.sql
CREATE TABLE snippets (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
created DATETIME NOT NULL,
expires DATETIME NOT NULL
);

CREATE INDEX idx_snippets_created ON snippets(created);

CREATE TABLE users (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
hashed_password CHAR(60) NOT NULL,
created DATETIME NOT NULL
);

ALTER TABLE users ADD CONSTRAINT users_uc_email UNIQUE (email);
INSERT INTO users (name, email, hashed_password, created) VALUES (
'Alice Jones',
'alice@example.com',
'$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6HzGJSWG',
'2022-01-01 09:18:24'
);
DROP TABLE users;
DROP TABLE snippets;

Go ignora cualquier directorio llamado testdata, asi que esos scripts seran ignorados en la compilacion de nuetra app. Por otro lado , tambine ignora cualquier directorio o archivo que comience con _ o .

Ahora que tenemos eso en su lugar, vamos a crear un nuevo archivo que contenga los helpers para los test de integracion.

touch internal/models/testutils_test.go

En este archivo crearemos una funcion newTestDB() la cual:

  • Crea un nuevo pool de conexion *sql.DB para esa db.
  • Ejecuta setup.sql para crear la tabla y agregar los datos falsos.
  • Correr terardown.sql y cerrar el pool de conexiones.
package models

import (
	"database/sql"
	"os"
	"testing"
)

func newTestDB(t *testing.T) *sql.DB {
	// Establish a sql.DB connection pool for our test database. Because our
	// setup and teardown scripts contains multiple SQL statements, we need
	// to use the "multiStatements=true" parameter in our DSN. This instructs
	// our MySQL database driver to support executing multiple SQL statements
	// in one db.Exec() call.
	db, err := sql.Open("mysql", "test_web:pass@/test_snippetbox?parseTime=true&multiStatements=true")
	if err != nil {
		t.Fatal(err)
	}
	// Read the setup SQL script from the file and execute the statements.
	script, err := os.ReadFile("./testdata/setup.sql")
	if err != nil {
		t.Fatal(err)
	}
	_, err = db.Exec(string(script))
	if err != nil {
		t.Fatal(err)
	}
	// Use t.Cleanup() to register a function *which will automatically be
	// called by Go when the current test (or sub-test) which calls newTestDB()
	// has finished*. In this function we read and execute the teardown script,
	// and close the database connection pool.
	t.Cleanup(func() {
		script, err := os.ReadFile("./testdata/teardown.sql")
		if err != nil {
			t.Fatal(err)
		}
		_, err = db.Exec(string(script))
		if err != nil {
			t.Fatal(err)
		}
		db.Close()
	})
	// Return the database connection pool.
	return db
}

Cada vez que llamamos a esta función newTestDB() dentro de una prueba (o subprueba), ejecutará el script de configuración en la base de datos de prueba. Y cuando la prueba o subprueba termine, la función de limpieza se ejecutará automáticamente y se ejecutará el script de desmontaje.

13.7 Testeando el metodo UserModel.Exist

Sabemos que setup.sql crea la tabla users y que dentro tiene un usuario con el id 1 y el email alice@example.com, asi que vamos a testear que:

  • La llamada a models.UserModel.Exist(1) retorna true y un nil como valor del error.
  • La llamada models.UserModel.Exist() con cualquier otro id retornara false y nil como error.

Creemos primero en el paquete internal/assert una nueva funcion NilError(). ;a cual usaremos para revisar que unerror tiene el valor nil.

func NilError(t *testing.T, actual error) {
	t.Helper()
	if actual != nil {
		t.Errorf("got: %v; expected: nil", actual)
	}
}

Luego siguiendo las convenciones de Go y creando un nuevo archivo users_test.go para nuestro test,lo haremos directamente al lado del codigo que va a ser testeado.

$ touch internal/models/users_test.go

Y agregamso TestUserModelExists que contenga el siguiente test:

package models

import (
	"testing"

	"github.com/nahueldev23/snippetbox/internal/assert"
)

func TestUserModelExists(t *testing.T) {
	// Set up a suite of table-driven tests and expected results.
	tests := []struct {
		name   string
		userID int
		want   bool
	}{
		{
			name:   "Valid ID",
			userID: 1,
			want:   true,
		},
		{
			name:   "Zero ID",
			userID: 0,
			want:   false,
		},
		{
			name:   "Non-existent ID",
			userID: 2,
			want:   false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Call the newTestDB() helper function to get a connection pool to
			// our test database. Calling this here -- inside t.Run() -- means
			// that fresh database tables and data will be set up and torn down
			// for each sub-test.
			db := newTestDB(t)
			// Create a new instance of the UserModel.
			m := UserModel{db}
			// Call the UserModel.Exists() method and check that the return
			// value and error match the expected values for the sub-test.
			exists, err := m.Exists(tt.userID)
			assert.Equal(t, exists, tt.want)
			assert.NilError(t, err)
		})
	}
}

Si corremos los test deberian dar todos OK

13.7 Saltando test que tardan mucho

Cuando un test tarda mucho tiempo, puede que decidas saltarlos en ciertas circuntancias,Por ejemplo podes querer que solo se corran los test de integracion antes de hacer un commit y no todo el tiempo, durnante el desarrollo.

Una manera comun de hacer estas omiciones es usando la funcion testing.Short() para revisar si eta el flag -short in el comando go test, y entonces llama al metodo t.Skip() si es que ese flag esta presente.

Vamos a actualizar TestUserModelExists para que haga esto.

func TestUserModelExists(t *testing.T) {
	// Skip the test if the "-short" flag is provided when running the test.
	if testing.Short() {
		t.Skip("models: skipping integration test")
	}
...
}

Si correst el test ahora con -v y -short veras el mensaje SKIP

13.8 Covertura de test

Go nos permite saber cuanta covertura tenemos hecha en nuestros test con el flag -cover. Si corres ahora go test -cover ./... vas a ver que tenemos un 46% en cmd/web.

Podemos tener mas detalles del la covertura de los test por cada metodo o funcion usando -coverprofile asi :

$ go test -coverprofile=/tmp/profile.out ./...

Esto ejecutara todos tus test de manera normal y si todos los test pasan, se escribira un perfil de covertura en un lugar especifico.En el ejemplo anterior pusimos para que los guarde en /tmp/profile.out.

Podes ver el perfil usando go tool cover asi:

$ go tool cover -func=/tmp/profile.out
github.com/nahueldev23/snippetbox/cmd/web/handlers.go:14:               home                    0.0%
github.com/nahueldev23/snippetbox/cmd/web/handlers.go:27:               snippetView             92.9%
github.com/nahueldev23/snippetbox/cmd/web/handlers.go:59:               snippetCreate           0.0%
github.com/nahueldev23/snippetbox/cmd/web/handlers.go:69:               snippetCreatePost       0.0%
github.com/nahueldev23/snippetbox/cmd/web/handlers.go:108:              userSignup              100.0%
github.com/nahueldev23/snippetbox/cmd/web/handlers.go:114:              userSignupPost          88.5%
github.com/nahueldev23/snippetbox/cmd/web/handlers.go:162:              userLogin               0.0%
github.com/nahueldev23/snippetbox/cmd/web/handlers.go:168:              userLoginPost           0.0%
github.com/nahueldev23/snippetbox/cmd/web/handlers.go:213:              userLogoutPost          0.0%
github.com/nahueldev23/snippetbox/cmd/web/handlers.go:227:              ping                    100.0%
github.com/nahueldev23/snippetbox/cmd/web/helpers.go:14:                serverError             0.0%
github.com/nahueldev23/snippetbox/cmd/web/helpers.go:26:                clientError             100.0%
github.com/nahueldev23/snippetbox/cmd/web/helpers.go:33:                notFound                100.0%
github.com/nahueldev23/snippetbox/cmd/web/helpers.go:37:                render                  63.6%
github.com/nahueldev23/snippetbox/cmd/web/helpers.go:60:                newTemplateData         100.0%
github.com/nahueldev23/snippetbox/cmd/web/helpers.go:71:                decodePostForm          50.0%
github.com/nahueldev23/snippetbox/cmd/web/helpers.go:98:                isAuthenticated         75.0%
github.com/nahueldev23/snippetbox/cmd/web/main.go:30:                   main                    0.0%
github.com/nahueldev23/snippetbox/cmd/web/main.go:98:                   openDB                  0.0%
github.com/nahueldev23/snippetbox/cmd/web/middleware.go:11:             sercureHeaders          100.0%
github.com/nahueldev23/snippetbox/cmd/web/middleware.go:25:             logRequest              100.0%
github.com/nahueldev23/snippetbox/cmd/web/middleware.go:38:             recoverPanic            66.7%
github.com/nahueldev23/snippetbox/cmd/web/middleware.go:57:             requireAuthentication   16.7%
github.com/nahueldev23/snippetbox/cmd/web/middleware.go:77:             noSurf                  100.0%
github.com/nahueldev23/snippetbox/cmd/web/middleware.go:87:             authenticate            38.5%
github.com/nahueldev23/snippetbox/cmd/web/routes.go:11:                 routes                  100.0%
github.com/nahueldev23/snippetbox/cmd/web/templates.go:25:              humanDate               100.0%
github.com/nahueldev23/snippetbox/cmd/web/templates.go:41:              newTemplateCache        83.3%
github.com/nahueldev23/snippetbox/internal/models/snippets.go:27:       Insert                  0.0%
github.com/nahueldev23/snippetbox/internal/models/snippets.go:44:       Get                     0.0%
github.com/nahueldev23/snippetbox/internal/models/snippets.go:64:       Latest                  0.0%
github.com/nahueldev23/snippetbox/internal/models/users.go:31:          Insert                  0.0%
github.com/nahueldev23/snippetbox/internal/models/users.go:54:          Authenticate            0.0%
github.com/nahueldev23/snippetbox/internal/models/users.go:81:          Exists                  100.0%
total:                                                                  (statements)            38.2%