En algunos lugares del mundo, los ciudadanos siguen confinados debido a la pandemia del coronavirus y desde Friends of Go seguimos predicando el lema #QuédateEnCasa. De hecho, qué mejor ocasión que ésta para seguir aprendiendo Go. Y hoy lo vamos a hacer extendiendo la API de gophers que podéis encontrar en nuestro repositorio y que empezamos a desarrollar en nuestro artículo estrella sobre cómo crear una API REST en Go, que posteriormente mostramos cómo testearla y que recientemente extendimos para darle compatibilidad con CockroachDB.
Lo que vamos a hacer, más exactamente, es extender dicha API para añadirle soporte para MySQL. Es decir, básicamente vamos a hacer una implementación del repositorio que podemos ver definido aquí, y con un 100% de cobertura. Y para ello vamos a usar los siguientes paquetes:
Implementando el repositorio
Aunque sabemos que el TDD es una excelente práctica y que en determinadas situaciones nos puede reportar muchos beneficios, para el propósito de este artículo vamos a hacer primero la implementación y después los tests.
Para ello, vamos a hacer uso de la librería go-sqlbuilder
, que nos va a proporcionar una capa de abstracción de la
construcción de las sentencias SQL que será compatible con el paquete database/sql
, que forma parte de la librería
estándar de Go y nos abstrae de la conexión y la ejecución de las sentencias sobre la base de datos.
Si miramos la documentación de `go-sqlbuilder` podemos ver varios ejemplos pero lo mejor es que veamos un ejemplo real del caso de uso que nos contempla:
package mysql
import (
"context"
"database/sql"
"errors"
gopherapi "github.com/friendsofgo/gopherapi/pkg"
"github.com/huandu/go-sqlbuilder"
_ "github.com/lib/pq"
"time"
)
type sqlGopher struct {
ID string `db:"id"`
Name string `db:"name"`
Image string `db:"image"`
Age int `db:"age"`
CreatedAt *time.Time `db:"created_at"`
UpdatedAt *time.Time `db:"updated_at"`
}
type gopherRepository struct {
table string
db *sql.DB
}
// NewRepository instances a MySQL implementation of the gopherapi.Repository
func NewRepository(table string, db *sql.DB) gopherapi.Repository {
return gopherRepository{table: table, db: db}
}
// CreateGopher satisfies the gopherapi.Repository interface
func (r gopherRepository) CreateGopher(ctx context.Context, g *gopherapi.Gopher) error {
insertBuilder := sqlbuilder.NewStruct(new(sqlGopher)).InsertInto(
r.table,
sqlGopher{
ID: g.ID,
Name: g.Name,
Image: g.Image,
Age: g.Age,
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,
},
)
query, args := insertBuilder.Build()
_, err := r.db.ExecContext(ctx, query, args...)
return err
}
Como podéis ver, lo que vamos a hacer es inyectar una instancia de la estructura sql.DB
a nuestro repositorio, de igual
modo que hicimos en el caso de CockroachDB. Posteriormente vamos a hacer uso del método
sqlbuilder.NewStruct
pasándole como parámetro la definición de nuestro struct que representa el mapeo de nuestro gopher
en base de datos. Y será, el retorno de este último método, el que nos permitirá ejecutar las operaciones de lectura
y escritura habituales en SQL (INSERT INTO
, SELECT FROM
, DELETE FROM
, etc).
Estos métodos nos devolverán un builder, sobre el que podremos ejecutar el método Build
para obtener la query que
queremos hacer y los argumentos asociados a ella. Finalmente, como ya avanzamos anteriormente, vamos a usar la instancia
del paquete database/sql
para ejecutar la sentencia que hemos creado con el paquete go-sqlbuilder
.
Como podéis ver aquí,
de igual modo podríamos hacer para las operaciones de UPDATE
y DELETE
. Sin embargo, el caso del SELECT
es un poco
particular, así que veamos un ejemplo de cómo podríamos recuperar todos nuestros gophers:
func (r gopherRepository) FetchGophers(ctx context.Context) ([]gopherapi.Gopher, error) {
sqlGopherStruct := sqlbuilder.NewStruct(new(sqlGopher))
selectBuilder := sqlGopherStruct.SelectFrom(r.table)
query, args := selectBuilder.Build()
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var gophers []gopherapi.Gopher
for rows.Next() {
sqlGopher := sqlGopher{}
err := rows.Scan(sqlGopherStruct.Addr(&sqlGopher)...)
if err != nil {
return nil, err
}
gophers = append(gophers, gopherapi.Gopher{
ID: sqlGopher.ID,
Name: sqlGopher.Name,
Image: sqlGopher.Image,
Age: sqlGopher.Age,
CreatedAt: sqlGopher.CreatedAt,
UpdatedAt: sqlGopher.UpdatedAt,
})
}
return gophers, nil
}
Como podéis ver, la estructura base es similar. Sin embargo, ahora tendremos que parsear los resultados de ejecutar
la sentencia y mapearlos a nuestro struct que representa las entidades de Gopher
de dominio. Para ello vamos a hacer
uso del iterador (rows) que nos devuelve la función QueryContext
.
En primer lugar, y especialmente importante si queremos evitar leaks de file descriptors, tenemos que hacer defer
del rows.Close()
. Y, a continuación, lo que vamos a hacer será iterar sobre cada una de esas filas (db.Row
) para
construir nuestros gophers (rows.Scan(sqlGopherStruct.Addr(&sqlGopher)...)
) de base de datos y posteriormente mapearlos
a las entidades de dominio. Una vez el iterador llega al final de las filas resultantes de la consulta (rows.Next()
),
entonces ya podremos devolver el resultado.
Adicionalmente, a nuestros builders también les podríamos añadir un operador de equals
o like
de forma similar a:
query, args := selectBuilder.Where(
selectBuilder.Equal("id", ID),
).Build()
Y por si todo esto fuera poco, la librería go-sqlbuilder
también nos proporciona otros operadores, como, por ejemplo,
los de incremento y decremento, que en este caso no nos atañen así que os dejamos investigar a vosotros:
updateBuilder.Set(updateBuilder.Incr(column))
Testeando nuestro repositorio
Cómo dijimos anteriormente, lo que hemos hecho básicamente es usar go-sqlbuilder
para construir nuestras sentencias
SQL de forma programática. Así que, siendo esa la lógica que reside en nuestra implementación del repositorio, lo que
vamos a hacer es comprobar que estos están construyendo las sentencias SQL de forma apropiada y que las están ejecutando
también de forma correcta.
Para ello vamos a hacer uso de la librería `go-sqlmock` que
anteriormente mencionamos, que básicamente actuará de mock de la estructura sql.DB
que inyectamos al repositorio
de forma que nos permitirá:
- Comprobar que se ha ejecutado una sentencia en la base de datos.
- Verificar que la sentencia ejecutada es la esperada y con los argumentos esperados.
- Devolver un resultado fake como retorno de la sentencia ejecutada.
Veamos el ejemplo que se corresponde con los tests de la operación de inserción cuya implementación vimos anteriormente:
func Test_GopherRepository_CreateGopher_RepositoryError(t *testing.T) {
gopher := buildGopher()
db, sqlMock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
assert.NoError(t, err)
}
sqlMock.ExpectExec(
"INSERT INTO gophers (id, name, image, age, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").
WithArgs(gopher.ID, gopher.Name, gopher.Image, gopher.Age, gopher.CreatedAt, gopher.UpdatedAt).
WillReturnError(errors.New("database failed"))
repo := NewRepository("gophers", db)
err = repo.CreateGopher(context.Background(), &gopher)
assert.Error(t, err)
assert.NoError(t, sqlMock.ExpectationsWereMet())
}
func Test_GopherRepository_CreateGopher_Success(t *testing.T) {
gopher := buildGopher()
db, sqlMock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
assert.NoError(t, err)
}
sqlMock.ExpectExec(
"INSERT INTO gophers (id, name, image, age, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").
WithArgs(gopher.ID, gopher.Name, gopher.Image, gopher.Age, gopher.CreatedAt, gopher.UpdatedAt).
WillReturnResult(sqlmock.NewResult(1, 1))
repo := NewRepository("gophers", db)
err = repo.CreateGopher(context.Background(), &gopher)
assert.NoError(t, err)
assert.NoError(t, sqlMock.ExpectationsWereMet())
}
Antes de analizar el código de los tests en sí, queremos clarificar que como el objetivo de este artículo no es profundizar en las diferentes técnicas de tests sino más bien como hacer uso de la librería mencionada, hemos separado los diferentes tests de la operación de inserción en dos tests independientes, aunque evidentemente podríamos hacer uso de subtests o de cualquier otra técnica que hemos explicado en los otros artículos de testing.
Dicho lo cuál, si analizamos la estructura de cualquiera de los dos tests vamos a ver que ambos son muy similares:
-
En primer lugar vamos a inicializar el mock que vamos a inyectar al repositorio:
db, sqlMock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
-
En segundo lugar vamos a configurar el mock para que haga las verificaciones correspondientes
sqlMock.ExpectExec( "INSERT INTO gophers (id, name, image, age, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"). WithArgs(gopher.ID, gopher.Name, gopher.Image, gopher.Age, gopher.CreatedAt, gopher.UpdatedAt). WillReturnResult(sqlmock.NewResult(1, 1))
-
En tercer lugar vamos a inicializar el repositorio inyectándole el mock y vamos a llamar al método que queremos testear:
repo := NewRepository("gophers", db) err = repo.CreateGopher(context.Background(), &gopher)
-
Finalmente, vamos a comprobar si ha habido algún error durante la operación y si los mocks han recibido las llamadas esperadas:
assert.NoError(t, err) assert.NoError(t, sqlMock.ExpectationsWereMet())
¡Y con esto ya tendríamos nuestro método de inserción testeado! De hecho, si os fijáis las diferencias entre un test (el caso en que la base de datos falla) y el otro (el caso en que todo va bien), son mínimos. Así pues, mientras en el primer caso vamos a hacer que el resultado de ejecutar la sentencia sea un error, en el segundo vamos a hacer que todo vaya bien y devuelva el resultado con las filas afectadas:
// case error
WillReturnError(errors.New("database failed"))
// case success
WillReturnResult(sqlmock.NewResult(1, 1))
Además, al final vamos a comprobar que, en el primer caso, el error se nos devuelva, tal y como esperamos, mientras que que en el segundo caso vamos a comprobar que ningún error haya sido devuelto:
// case error
assert.Error(t, err)
// case success
assert.NoError(t, err)
Adicionalmente, podemos ver el ejemplo de la implementación del test del método FetchGophers
, dónde, además de lo visto
anteriormente, también vamos a comprobar que los resultados retornados son los gophers que esperábamos obtener:
func Test_GopherRepository_FetchGophers_Succeeded(t *testing.T) {
expectedGophers := []gopherapi.Gopher{
buildGopher(),
buildGopher(),
}
db, sqlMock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
assert.NoError(t, err)
}
sqlMock.ExpectQuery(
"SELECT gophers.id, gophers.name, gophers.image, gophers.age, gophers.created_at, gophers.updated_at FROM gophers").
WillReturnRows(sqlmock.NewRows(
[]string{"id", "name", "image", "age", "created_at", "updated_at"}).
AddRow(expectedGophers[0].ID, expectedGophers[0].Name, expectedGophers[0].Image, expectedGophers[0].Age, expectedGophers[0].CreatedAt, expectedGophers[0].UpdatedAt).
AddRow(expectedGophers[1].ID, expectedGophers[1].Name, expectedGophers[1].Image, expectedGophers[1].Age, expectedGophers[1].CreatedAt, expectedGophers[1].UpdatedAt),
)
repo := NewRepository("gophers", db)
gophers, err := repo.FetchGophers(context.Background())
assert.NoError(t, err)
assert.NoError(t, sqlMock.ExpectationsWereMet())
assert.Equal(t, expectedGophers, gophers)
}
¡Y voilà! Con esto ya tendríamos nuestra implementación MySQL de nuestro repositorio de gophers con un 100% de coverage.