Tdd con go: punteros, errores y el típico ejemplo de wallet

Ya hemos aprendido a usar structs para modelar figuras. Ahora vamos a ir un poco más allá usaremos struct para administrar el estado.
Vamos a realizar el típico ejemplo de una cuenta de banco. ¡El mundo fintec es nuestro!

Vamos a hacer una cartera en al que podremos depositar fondos. Como siempre en toda esta serie implementaremos nuestra cartera haciendo TDD.

8680774480_b55d8e1471_z
strangelittlewoman-onstream

El primer test

Así que nuestro primer test será hacer un depósito de en la cuenta y comprobar nuestro Balance.

package wallet

import "testing"

func TestWallet(t *testing.T) {

    wallet := Wallet{}

    wallet.Deposit(10)

    result := wallet.Balance()
    expected := 10

    if(expected != result){
        t.Errorf("result %d and expected %d",result,expected)
    }
}

Con el primer test ya tenemos definido el package, sabemos que tenemos que crear un struct llamado Wallet y además 2 métodos para ese struct: Deposit y Balance.
Si ejecutamos el test obtenemos que:

go test                                                                                                                     2 ↵
# github.com/jeslopcru/golang-examples/05-wallet
./wallet_test.go:7:12: undefined: Wallet

Así que vamos a ir construyendo lo que nos digan los tests. En este caso tenemos que crear un Wallet.
En el fichero wallet.go escribimos lo siguiente:

package wallet

type Wallet struct {

}

Si volvemos a ejecutar los tests obtenemos que necesitamos crear los métodos Deposit y Balance

╰─$ go test                                                                                                                     2 ↵
# github.com/jeslopcru/golang-examples/05-wallet
./wallet_test.go:9:8: wallet.Deposit undefined (type Wallet has no field or method Deposit)
./wallet_test.go:11:18: wallet.Balance undefined (type Wallet has no field or method Balance)
FAIL    github.com/jeslopcru/golang-examples/05-wallet [build failed]

Así que creamos los métodos, en un principio vacíos y volvemos a ejecutar los tests

package wallet

type Wallet struct {
}

func (wallet Wallet) Deposit(amount int) {

}
func (wallet Wallet) Balance() int {
    return 0
}

Y al ejecutar los tests tenemos el error que esperábamos

╰─$ go test                                                                                                                     2 ↵
--- FAIL: TestWallet (0.00s)
        wallet_test.go:15: result 0 and expected 10
FAIL
exit status 1
FAIL    github.com/jeslopcru/golang-examples/05-wallet  0.005s

Escribiendo el código para que el test vuelva a verde

Lo que haremos será tener una variable en la que ir acumulando el balance cada vez que hacemos un depósito.

type Wallet struct {
    balance int
}

Basados en este struct solo nos quedan completar los métodos para que funcionen

package wallet

type Wallet struct {
     balance int
}

func (wallet Wallet) Deposit(amount int) {
    wallet.balance += amount
}
func (wallet Wallet) Balance() int {
    return wallet.balance
}

pero al ejecutar los test…

╰─$ go test                                                                                                                     1 ↵
--- FAIL: TestWallet (0.00s)
        wallet_test.go:15: result 0 and expected 10
FAIL
exit status 1
FAIL    github.com/jeslopcru/golang-examples/05-wallet  0.004s

¿Qué está pasando? El test es simple, el código parece correcto, pero no devuelve el resultado esperado ¿por qué?

Los punteros en Go

Cuando llamamos a una función en Go los argumentos de entrada son “copiados”, por lo que en nuestro caso: cuando llamamos a wallet.Deposit, wallet es una copia.

Para entenderlos más fácilmente, cuando creamos un wallet este wallet se guarda en algún lugar de la memoria. Para saber en que dirección de la memoria podemos usar & antes de la variable.
Vamos a modificar un poco los tests para tener más información:

func TestWallet(t *testing.T) {

    wallet := Wallet{}

    wallet.Deposit(10)

    result := wallet.Balance()
    fmt.Printf("In tests, address of balance is: %v \n", &wallet.balance)

    expected := 10

    if(expected != result){
        t.Errorf("result %d and expected %d",result,expected)
    }
}

Al ejecutar el test, este seguirá fallando, pero tendremos la dirección donde se guarda el balalance en los test:

╰─$ go test                                                                                                                     1 ↵
In tests, address of balance is: 0xc4200160f0
--- FAIL: TestWallet (0.00s)
        wallet_test.go:20: result 0 and expected 10
FAIL
exit status 1

Vamos a hacer lo mismo en el método Deposit, para poder comparar las direcciones

func (wallet Wallet) Deposit(amount int) {
    fmt.Printf("In tests, address of balance is: %v \n", &wallet.balance)
    wallet.balance += amount
}

Así al ejecutar el test obtendremos:

╰─$ go test                                                                                                                     1 ↵
In Deposit, address of balance is: 0xc4200160f8
In tests, address of balance is: 0xc4200160f0
--- FAIL: TestWallet (0.00s)
        wallet_test.go:20: result 0 and expected 10
FAIL
exit status 1

Como vemos, ambas direcciones son diferentes. Es decir, tenemos un pequeño problema.
Cuando cambiamos el valor del balance dentro de wallet.go, estamos trabajando con un valor “copiado” del test wallet_test.go

Podemos solucionar esto usando punteros.. Así en vez de pasar una copia, lo que pasamos es un puntero a la cartera. Para hacer esto usamos el operador *

El código quedaría algo así:

func (wallet *Wallet) Deposit(amount int) {
    fmt.Printf("In Deposit, address of balance is: %v \n", &wallet.balance)
    wallet.balance += amount
}
func (wallet *Wallet) Balance() int {
    return wallet.balance
}

y al ejecutar los test…

╰─$ go test                                                                                                                     1 ↵
In Deposit, address of balance is: 0xc4200160f0
In tests, address of balance is: 0xc4200160f0
PASS

Todo funciona, la variable que usamos en los tests y en el código de producción es la misma, por lo que los tests funcionan. Ahora ya podemos eliminar el log que hemos creamos antes.
La diferencia es que recibir *Wallet podemos leerlo como puntero a un wallet.

Aprender a crear tipos propios

De momento hemos utilizado int para contar, pero si estamos hablando de bancos, fintec,… vamos a epezar a hablar con propiedad, por lo que vamos a empezar a contar en Euros.
Puede parecer demasiado, pero llamar a los objetos por su nombre, el mismo que tienen en el mundo real ayuda mucho a la hora de trabajar.

Una de las cosas que podemos hacer con Go es crear alias. Un alias no es más dar otro nombre a un tipo que ya existe, en nuestro caso vamos a crear un alias de int al que llamaremos Euro. Algo así:

package wallet

type Euro int

type Wallet struct {
    balance Euro
}

func (wallet *Wallet) Deposit(amount Euro) {
    wallet.balance += amount
}
func (wallet *Wallet) Balance() Euro {
    return wallet.balance
}

Y los tests quedarían así:

package wallet

import (
    "testing"
)

func TestWallet(t *testing.T) {

    wallet := Wallet{}

    wallet.Deposit(Euro(10))

    result := wallet.Balance()

    expected := Euro(10)

    if expected != result {
        t.Errorf("result %d and expected %d", result, expected)
    }
}

Algo superinteresante que podemos hacer al usar alias es crear métodos específicos para esos alias.
Es decir, podemos añadir una funcionalidad específica a nuestro tipo Euro. En nuestro caso vamos a hacer que al imprimir la cantidad siempre aparezca el EUR después. Esto podemos hacerlo implementando la interfaz Stringer.

func (eur Euro) String() string {
    return fmt.Sprintf("%d EUR", eur)
}

Ahora si el test falla la cadena de salida será:

╰─$ go test
--- FAIL: TestWallet (0.00s)
        wallet_test.go:18: result 100 EUR and expected 10 EUR
FAIL
exit status 1

Retirada de dinero de nuestra cartera

Vamos a implementar la función opuesta a Deposit, es decir, Withdraw así que lo primero será implementar el test.

func TestWallet(t *testing.T) {

    t.Run("Balance", func(t *testing.T) {
        wallet := Wallet{}
        wallet.Deposit(Euro(10))
        result := wallet.Balance()
        expected := Euro(10)
        if expected != result {
            t.Errorf("result %s and expected %s", result, expected)
        }
    })

    t.Run("Withdraw", func(t *testing.T) {
        wallet := Wallet{balance: Euro(20)}
        wallet.Withdraw(Euro(10))
        result := wallet.Balance()
        expected := Euro(10)
        if expected != result {
            t.Errorf("result %s and expected %s", result, expected)
        }
    })
}

Al ejecutar el test nos indica que es necesario definir la función WIthdraw:

╰─$ go test                                                                                                                     1 ↵
# github.com/jeslopcru/golang-examples/05-wallet
./wallet_test.go:21:9: wallet.Withdraw undefined (type Wallet has no field or method Withdraw)

La definimos con el mínimo código para que funcione

func (wallet *Wallet) Withdraw(amount Euro) {
    wallet.balance -= amount
}

Como vemos casi igual que Deposit, solo que en vez de sumar, resta la cantidad.

Ahora los tests están en verde:

╰─$ go test                                                                                                                     2 ↵
PASS
ok      github.com/jeslopcru/golang-examples/05-wallet  0.005s

Ahora el tercer paso de TDD es refactorizar. COmo vemos el código de los test es bastante parecido, así que vamos a extraer una función para hacer el assert.

func TestWallet(t *testing.T) {

    assertBalance:= func(t *testing.T, wallet Wallet, expected Euro) {
        result := wallet.Balance()
        if expected != result {
            t.Errorf("result %s and expected %s", result, expected)
        }
    }

    t.Run("Balance", func(t *testing.T) {
        wallet := Wallet{}
        wallet.Deposit(Euro(10))
        assertBalance(t, wallet, Euro(10))
    })

    t.Run("Withdraw", func(t *testing.T) {
        wallet := Wallet{balance: Euro(20)}
        wallet.Withdraw(Euro(10))
        assertBalance(t, wallet, Euro(10))
    })
}

Hasta aquí todo funcionando. Pero ¿qué pasa si queremos retirar más dinero del que hay en la cuenta?

Como usar los errores en Go

Así que vamos a aprender a usar los errores en Go, lo primero será escribir un tests y a partir de ese test iremos aprendiendo con funcionan los errores en Go.

 t.Run("Withdraw without money", func(t *testing.T) {
        wallet := Wallet{balance: Euro(20)}
        err := wallet.Withdraw(Euro(100))

        if err == nil {
            t.Error("wanted an error but didn't get one")
        }
    })

En Go los errores son propios de las funciones, es decir. Si una función devuelve un error, hay que indicarlo en la firma de la función.

Queremos que Withdraw devuelva un error si estamos sacando más dinero del que tenemos en la cuenta. Por lo que verificamos si la función devuelve un error igualándolo con nil

nil es el equivalente a null de PHP. En este caso los errores de withdraw pueden ser nil porque no se devuelva ningún error.
Luego verificamos que un error ha regresado fallando la prueba si es nil.

Vamos a por el código:

func (wallet *Wallet) Withdraw(amount Euro)error {
    if amount > wallet.balance {
        return errors.New("not enough Euro")
    }
    wallet.balance -= amount
    return nil
}

Lo que hacemos es comprobar si tenemos suficiente dinero antes de retirar.
Si la cantidad a retirar es mayor que el balance, entonces devolvemos un nuevo error.
En cualquier otro caso, hacemos la retirada y devolvemos nil. Quizás devolver nil siempre sea lo más extraño.

Como hemos comentado, en Go para devolver errores, debemos cambiar la firma e indicar que devolvemos error. por lo que si todo va bien debemos devolver un nil,o lo que es lo mismo, debemos devolver que no hay ningún error.

Así que ahora al ejecutar los tests, todo está en verde.

╰─$ go test                                                                                                                     2 ↵
PASS
ok      github.com/jeslopcru/golang-examples/05-wallet  0.006s

En el momento que tenemos los test en verde, podemos refactorizar los tests:

func TestWallet(t *testing.T) {

    assertBalance := func(t *testing.T, wallet Wallet, expected Euro) {
        result := wallet.Balance()
        if expected != result {
            t.Errorf("result %s and expected %s", result, expected)
        }
    }

    assertError := func(t *testing.T, error error) {
        if error == nil {
            t.Error("wanted an error but didn't get one")
        }
    }

    t.Run("Balance", func(t *testing.T) {
        wallet := Wallet{}
        wallet.Deposit(Euro(10))
        assertBalance(t, wallet, Euro(10))
    })

    t.Run("Withdraw", func(t *testing.T) {
        wallet := Wallet{balance: Euro(20)}
        wallet.Withdraw(Euro(10))
        assertBalance(t, wallet, Euro(10))
    })

    t.Run("Withdraw without enough funds", func(t *testing.T) {
        wallet := Wallet{balance: Euro(20)}
        err := wallet.Withdraw(Euro(100))
        assertBalance(t, wallet, Euro(20))
        assertError(t, err)
    })
}

Vamos a mejorar un poco los tests. Por un lado vamos a chequear el mensaje de error "not enough Euro" y vamos a hacer que los tests paren de ejecutarse si encontramos un error.

Para chequear el error vamos a cambiar el assertError así:

func TestWallet(t *testing.T) {
    t.Run("Balance", func(t *testing.T) {
        wallet := Wallet{}
        wallet.Deposit(Euro(10))
        assertBalance(t, wallet, Euro(10))
    })

    t.Run("Withdraw", func(t *testing.T) {
        wallet := Wallet{balance: Euro(20)}
        wallet.Withdraw(Euro(10))
        assertBalance(t, wallet, Euro(10))
    })

    t.Run("Withdraw without enough funds", func(t *testing.T) {
        wallet := Wallet{balance: Euro(20)}
        err := wallet.Withdraw(Euro(100))

        assertBalance(t, wallet, Euro(20))
        assertError(t, err, "not enough Euro")
    })
}

func assertBalance(t *testing.T, wallet Wallet, expected Euro) {
    result := wallet.Balance()
    if expected != result {
        t.Errorf("result %s and expected %s", result, expected)
    }
}

func assertError(t *testing.T, error error, expected string) {
    if error == nil {
        t.Fatal("wanted an error but didn't get one")
    }

    if error.Error() != expected {
        t.Errorf("result %s and expected %s", error, expected)
    }
}

Hemos añadido t.Fatal para hacer que los test paren si se encuentran con un error. De la misma manera, hemos mejorado los tests para que también se chequee que el mensaje es el mismo.
También hemos extraído los assert a funciones para que los tests queden más pequeños y fáciles de seguir.
Si ejecutamos los tests, todo sigue verde:

╰─$ go test
PASS
ok      github.com/jeslopcru/golang-examples/05-wallet  0.005s

Otra propiedad útil de los tests es que nos ayudan a entender el uso real de nuestro código.

Errores que no conocemos

Aunque el compilador de Go es muy útil y con Goland tenemos mucho ganado, hay errores que se nos escapan, corner cases que no hemos visto. Por eso existe una utilidad llamada errcheck

Para descargarlos ejecutamos en el terminal

go get -u github.com/kisielk/errcheck

Y una vez descargado, tenemos que ejecutar errcheck .

╭─jesuslc@MacBook-Pro-de-Jesus ~/go/src/github.com/jeslopcru/golang-examples/05-wallet  ‹master*›
╰─$ errcheck .
wallet_test.go:16:18:   wallet.Withdraw(Euro(10))

errcheck es un Linter que nos ayuda a detectar huecos que no hemos verificado en los tests.
Por lo que finalmente los tests nos quedarán así:

func TestWallet(t *testing.T) {
    t.Run("Balance", func(t *testing.T) {
        wallet := Wallet{}
        wallet.Deposit(Euro(10))
        assertBalance(t, wallet, Euro(10))
    })

    t.Run("Withdraw", func(t *testing.T) {
        wallet := Wallet{balance: Euro(20)}
        result := wallet.Withdraw(Euro(10))
        assertBalance(t, wallet, Euro(10))
        assertNoError(t,result)
    })

    t.Run("Withdraw without enough funds", func(t *testing.T) {
        wallet := Wallet{balance: Euro(20)}
        err := wallet.Withdraw(Euro(100))

        assertBalance(t, wallet, Euro(20))
        assertError(t, err, "not enough Euro")
    })
}

func assertBalance(t *testing.T, wallet Wallet, expected Euro) {
    result := wallet.Balance()
    if expected != result {
        t.Errorf("result %s and expected %s", result, expected)
    }
}

func assertError(t *testing.T, error error, expected string) {
    if error == nil {
        t.Fatal("wanted an error but didn't get one")
    }

    if error.Error() != expected {
        t.Errorf("result %s and expected %s", error, expected)
    }
}

func assertNoError(t *testing.T, error error) {
    if error != nil {
        t.Fatal("got an error but did not want one")
    }
}

Que hemos hecho: crear la función assertNoError que chequea que lo que devuelve withdraw en condiciones normales en un nil es decir. withdraw devuelve nil si todo va bien.

Los test siguen funcionando:

╰─$ go test                                                                                                                     1 ↵
PASS
ok      github.com/jeslopcru/golang-examples/05-wallet  0.006s

Y ahora errcheck no devuelve nada.

Conclusiones

Hemos aprendido a utilizar punteros que son útiles para poder trabajar con funciones que guardan un estado.
De la misma manera ahora conocemos mucho más de los errores en Go. Como hemos comentado, los errores hay que especificarlo en la firma, si una función puede devolver un error debemos devolverlos o devolver nil en otro caso.

Tambien hemos descubierto errcheck para encontrar huecos en nuestro código y tests.

¡Ah! y no debemos olvidarnos de los alias, que son muy útiles para añadir significado a nuestro dominio.

 

Anuncios

Comenta la entrada

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión /  Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión /  Cambiar )

Conectando a %s