¿Cómo Testear aplicaciones en Go usando Test Tables? 🧪

13 de Mayo del 2023 ¿Ves algún error? Corregir artículo golang-wallpaper

En este articulo trabajaremos usando un repositorio que cree previamente para poder explicar de mejor manera como funcionan los test en Go, usaremos también una técnica llamada tablas de pruebas (Test Tables) que nos ayudará a agregar nuevos casos si son necesarios de forma sencilla. Pongámonos manos a la obra.

Herramientas que usaremos

Gomock (Para generar nuestros mocks):

Primero debemos entender que un mock es nada más y nada menos que una versión controlable de alguna entidad de nuestro programa, que nos permitirá inducir fallos o aciertos según nuestras necesidades muy importante para las pruebas unitarias.

Ahora gomock es una librería que nos permitirá generar mocks de nuestras interfaces esto es muy útil si hemos usado interfaces para construir todo nuestro proyecto tal como lo hago yo en mi plantilla de código de este 2023

Testify/Assert (Para realizar las comprobaciones):

Una parte importante de los test es luego de obtener los resultados de la ejecución de nuestra función debemos comprobar que los resultados fueron los esperados eso haremos usando la herramienta Assert de testify.

En resumen, es un conjunto de condiciones preprogramadas que nos ayudarán a comparar nuestro resultado esperado con el obtenido.

¿Cómo funcionan las Tablas de prueba?

Ahora es cuando nos toca aventurarnos un poco más al código lo que suelo usar yo para las tablas de pruebas es una estructura map[string]struct donde la estructura varía según la necesidad de la prueba.

Para este ejemplo vamos a probar un paquete de Usuario que tiene una sola función guardar un usuario en una base de datos.

Nuestro paquete usuario está compuesto por los siguientes archivos:

application

aplication.go

aplication_test.go

domain

models

users.go

ports

ports.go

application

repositories

repository.go

Junto a este tenemos también un paquete llamado Validator que comparte una estructura de archivos similar que nos ayudará a probar como podemos manipular el comportamiento de partes del sistema a nuestro antojo gracias al uso de los mocks.

Dentro del proyecto también encontraremos una carpeta de Mocks que es donde nuestros mocks se generaran al usar la herramienta gomock y una carpeta de Scripts donde almacenaremos acciones útiles que es mejor no memorizar y automatizarlas usando bash.

De esta manera, se vería nuestra estructura del proyecto final:

mocks

mock_user_application.go

mock_user_repository.go

mock_validator.go

scripts

generate-cover-profile.sh

generate-mocks.sh

run-test.sh

user

application

aplication.go

application_test.go

domain

models

users.go

ports

ports.go

application

repositories

repository.go

validator

domain

ports

ports.go

go.mod

go.sum

README.md

Como podrán ver en el repositorio del proyecto el paquete validator ni siquiera se encuentra implementado solo está la interfaz definida aun así es posible generar el mock gracias a la interfaz y podemos probar el paquete usuario sin tener que implementar el paquete validator. Total, independencia.

La clave para ser un buen programador es mantenerse en constante aprendizaje. 👉🏽

El contenido de nuestros archivos.

Ahora veremos que se encuentra dentro del paquete de Usuario archivo por archivo para que puedan entender el contexto antes de realizar las pruebas.

Empezamos con el archivo de user.go que se encuentra dentro de la carpeta de domain/models:

users/domain/models/user.go
package models import "errors" type User struct { ID int Email string Password string } var ( // Validation errors ErrInvalidEmail = errors.New("invalid email") ErrInvalidPassword = errors.New("invalid password") // Repository errors ErrSavingUser = errors.New("error saving user") ) func (u *User) Validate() error { if u.Email == "" { return ErrInvalidEmail } if u.Password == "" { return ErrInvalidPassword } return nil }

Aquí se encuentra nuestro modelo usuario junto con su función de validación y los posibles errores es una implementación no completa de cómo debería comportase una estructura de Usuario no la tomen como ejemplo para sus proyectos recuerden que esto es acerca de las Pruebas unitarias.

Ahora sigamos con el archivo de ports.go que se encuentra dentro de domain/ports:

users/domain/ports/ports.go
package ports import "github.com/solrac97gr/go-test-tables/users/domain/models" type Application interface { // CreateUser creates a new user CreateUser(email, password string) (*models.User, error) } type Repository interface { // SaveUser saves a user SaveUser(user *models.User) error }

Aquí encontramos definidos el comportamiento del repositorio y el servicio o aplicación de nuestro paquete de usuarios, encontramos dos interfaces cada una con sus respectivas funciones.

La función que probaremos será la de CreateUser en la capa de aplicación.

La implementación correspondiente de estas interfaces serían las siguientes:

repositories/repositories.go:

users/infrastructure/repositories/repository.go
package repositories import "github.com/solrac97gr/go-test-tables/users/domain/models" type FakeStorage struct { DB map[int]*models.User } func NewFakeStorage() *FakeStorage { return &FakeStorage{ DB: make(map[int]*models.User), } } var ( ErrSavingUser = models.ErrSavingUser ) func (s *FakeStorage) SaveUser(user *models.User) error { s.DB[user.ID] = user return nil }

application/application.go:

users/application/application.go
package application import ( "github.com/solrac97gr/go-test-tables/users/domain/models" "github.com/solrac97gr/go-test-tables/users/domain/ports" val "github.com/solrac97gr/go-test-tables/validator/domain/ports" ) type UserApp struct { UserRepo ports.Repository Validator val.Validator } func NewUserApp(repo ports.Repository, val val.Validator) *UserApp { return &UserApp{ UserRepo: repo, Validator: val, } } func (app *UserApp) CreateUser(email, password string) (*models.User, error) { user := &models.User{Email: email, Password: password} err := app.Validator.Struct(user) if err != nil { return nil, err } err = app.UserRepo.SaveUser(user) if err != nil { return nil, err } return user, nil }

Teniendo en cuenta nuestro estado actual en el proyecto empezaremos a con nuestras pruebas.

Definiendo nuestras pruebas

Dentro de la carpeta aplicación creamos el archivo aplication_test.go por convención es como los archivos de prueba se llaman en Go.

En el archivo definiremos una función que empieza con Test luego el nombre del paquete y por último la función del paquete que estamos probando.

Nuestro nombre de función quedaría de la siguiente forma TestApplication_CreateUser ahora comenzaremos a entender las partes de una función de test usando las tablas de prueba.

Casos:

Aquí estarán nuestros distintos casos de prueba dentro de un mapa de la siguiente manera:

Pueden notar que nuestras pruebas se componen de un nombre que es la clave en el mapa y de diferentes propiedades en la estructura que es valor del mapa.

En esta prueba podemos observar que necesitamos un Input que en este caso es el email y el password. También tenemos 2 funciones testSetup y assertSetup.

users/application/application_test.go
email: "mail@car.com", password: ""

TestSetup: Es donde determinaremos el comportamiento de nuestros mocks según el caso a probar.

users/application/application_test.go
testSetup: func(repo *mocks.MockRepository, val *mocks.MockValidator) { val.EXPECT().Struct(gomock.Any()).Return(models.ErrInvalidPassword) }

AssertSetup: Es donde pondremos nuestras reglas de comparación sobre si la función se comporta o no de manera adecuada.

users/application/application_test.go
assertSetup: func(t *testing.T, user *models.User, email, password string, err error) { assert.Nil(t, user) assert.EqualError(t, err, models.ErrInvalidPassword.Error()) }

Dejándonos así la siguiente estructura en este caso de test:

users/application/application_test.go
"Empty Password [Validation Error]": { email: "mail@car.com", password: "", testSetup: func(repo *mocks.MockRepository, val *mocks.MockValidator) { val.EXPECT().Struct(gomock.Any()).Return(models.ErrInvalidPassword) }, assertSetup: func(t *testing.T, user *models.User, email, password string, err error) { assert.Nil(t, user) assert.EqualError(t, err, models.ErrInvalidPassword.Error()) }, }

Aquí se puede ver claramente que tenemos definido 3 importantes cosas la información que necesitamos para que la función funcione, el comportamiento de los elementos internos de la función y la evaluación del resultado.

Ciclo For:

Aquí vamos a recorrer nuestros casos e inicializar la parte de boilerplate de nuestras pruebas:

users/application/application_test.go
for name, tc := range cases { t.Run(name, func(t *testing.T) { // Create a mock controller ctrl := gomock.NewController(t) defer ctrl.Finish() // Create a mock repository repo := mocks.NewMockRepository(ctrl) val := mocks.NewMockValidator(ctrl) // Setup the mock repository if tc.testSetup != nil { tc.testSetup(repo, val) } app := application.NewUserApp(repo, val) user, err := app.CreateUser(tc.email, tc.password) // Assert the result if tc.assertSetup != nil { tc.assertSetup(t, user, tc.email, tc.password, err) } }) }

Podemos observar a detalle que empezamos creando el controlador para nuestros mocks, luego de estos creamos nuestros mocks necesarios para el funcionamiento de la función y les inyectamos el controlador, acto seguido validamos la existencia de la función que determina el comportamiento de los mocks y una vez validada procedemos a ejecutarla, eso dotara a nuestos mocks con el comportamiento que necesita el test.

Ahora creamos nuestra estructura de application, le inyectamos los mocks que necesitamos (que ya tienen el comportamiento esperado) y ejecutamos ahora si nuestra función que queremos probar usando el input que definimos en el caso de test.

En la parte final validamos la existencia de la función que determina el resultado de la ejecución de nuestra función y la ejecutamos para comprobar que nuestra función arrojo los resultados esperados.

Este sería el resultado final:

users/application/application_test.go
package application_test import ( "testing" "github.com/golang/mock/gomock" "github.com/solrac97gr/go-test-tables/mocks" "github.com/solrac97gr/go-test-tables/users/application" "github.com/solrac97gr/go-test-tables/users/domain/models" "github.com/solrac97gr/go-test-tables/users/infrastructure/repositories" "github.com/stretchr/testify/assert" ) func TestApplication_CreateUser(t *testing.T) { cases := map[string]struct { email string password string testSetup func(*mocks.MockRepository, *mocks.MockValidator) assertSetup func(*testing.T, *models.User, string, string, error) }{ "Empty Password [Validation Error]": { email: "mail@car.com", password: "", testSetup: func(repo *mocks.MockRepository, val *mocks.MockValidator) { val.EXPECT().Struct(gomock.Any()).Return(models.ErrInvalidPassword) }, assertSetup: func(t *testing.T, user *models.User, email, password string, err error) { assert.Nil(t, user) assert.EqualError(t, err, models.ErrInvalidPassword.Error()) }, }, "Empty Email [Validation Error]": { email: "", password: "123456", testSetup: func(repo *mocks.MockRepository, val *mocks.MockValidator) { val.EXPECT().Struct(gomock.Any()).Return(models.ErrInvalidEmail) }, assertSetup: func(t *testing.T, user *models.User, email, password string, err error) { assert.Nil(t, user) assert.EqualError(t, err, models.ErrInvalidEmail.Error()) }, }, "Error saving [Repository Error]": { email: "test@mail.com", testSetup: func(repo *mocks.MockRepository, val *mocks.MockValidator) { val.EXPECT().Struct(gomock.Any()).Return(nil) repo.EXPECT().SaveUser(gomock.Any()).Return(repositories.ErrSavingUser) }, assertSetup: func(t *testing.T, user *models.User, email, password string, err error) { assert.Nil(t, user) assert.EqualError(t, err, repositories.ErrSavingUser.Error()) }, }, "Valid User [Success]": { email: "test@mail.com", password: "123456", testSetup: func(repo *mocks.MockRepository, val *mocks.MockValidator) { val.EXPECT().Struct(gomock.Any()).Return(nil) repo.EXPECT().SaveUser(gomock.Any()).Return(nil) }, assertSetup: func(t *testing.T, user *models.User, email, password string, err error) { assert.NotNil(t, user) assert.Equal(t, email, user.Email) assert.Equal(t, password, user.Password) assert.NoError(t, err) }, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { // Create a mock controller ctrl := gomock.NewController(t) defer ctrl.Finish() // Create a mock repository repo := mocks.NewMockRepository(ctrl) val := mocks.NewMockValidator(ctrl) // Setup the mock repository if tc.testSetup != nil { tc.testSetup(repo, val) } app := application.NewUserApp(repo, val) user, err := app.CreateUser(tc.email, tc.password) // Assert the result if tc.assertSetup != nil { tc.assertSetup(t, user, tc.email, tc.password, err) } }) } }

Conclusiones

Como se habrán dado cuenta esto nos permite una flexibilidad para agregar pruebas muy grandes, también nos permite agregar dentro de la estructura por ejemplo algún resultado esperado para alguna función en especifica. Por el momento este es el método de pruebas que uso en mis proyectos junto con TDD suelo siempre hacer mis mocks y mis pruebas antes de empezar a programar, pronto escribiré un artículo acerca de cómo funciona esta metodología.

Conviértete en un Go Ninja 🥷.Suscríbete a mi newsletter y recibe las últimas novedades en Go.