En este post vamos a crear una pequeña API en GO aplicando TDD como en toda la serie de post.
Ya tenemos el gusanillo de TDD: hacer un test, el código y refactorizar. Así que vamos a seguir con esa filosofía.
La idea de la API es ir guardando el numero de partidas ganadas por una serie de jugadores y poder consultar el numero de partidas ganadas por jugador.
Básicamente tendremos:
- Obtener la puntuación de un jugador
GET /player/{name}
- Añadir una pueva partida ganada a un jugador
POST /player/{name}
Como siempre mantendremos nuestro ciclo de TDD: Red, Green, Refactor. Para ello trabajaremos haciendo una solución iterativa y añadiendole mejoras en cada ciclo de TDD.
Aquí tenemos el repositorio de GitHub con la solución https://github.com/jeslopcru/golang-examples
Divide y vencerás
La idea de TDD es solucionar problemas pequeños (un test en rojo) cometiendo pequeños pecados en el proceso, porque los solucionaremos con seguridad en la fase de refactor.
La clave de todo este proceso es dar pasos pequeños (baby steps), ya que cuantos más cambios hagamos con los test en rojo, tendremos más probabilidades de tener problemas.
¿Por donde empezamos con nuestra API? Básicamente tenemos 2 problemas, por un lado obtener la puntuación de un jugador y por el otro añadir partidas una partida ganada a un jugador.
Si empezamos añadiendo partidas a un jugador, ¿cómo podemos comprobar si se han incrementado las partidas, si todavía no hemos creado el ver los puntos de un jugador?.
Tampoco podemos empezar por obtener la puntuación porque no hemos guardado ninguna partida…
Aprenderemos a «mockear», es decir, empezaremos por el endpoint GET
y «falsearemos» toda la parte de guardar haciendo un «stub» que nos devuelva siempre la misma puntuación.
De este modo dividimos el problema lo suficiente como para poder tener la estructura general del proyecto funcionando correctamente sin tener que preocuparnos demasiado de toda la lógica de la aplicación.
¿Por dónde empezamos?
Ya sabemos que vamos a implementar la petición GET
, así que como siempre vamos a empezar por el test.
El primer test será «totalmente falso» o en palabras de Kent Beck: «Faking it». Una vez que tengamos el test en verde podremos ir añadiendo otros para ir completando el código.
Un poco de teoría
Para poder crear el primer test necesitamos llamar a ListenAndServe con la firma func ListenAndServe(addr string, handler Handler) error
. Esto creará un servidor web escuchando en el puerto que le digamos y por cada request creará una goroutine
y la ejecutará contra un Handler
. Es como cuando en PHP queremos crear un servidor para probar algo pequeño y usamos el comando php -S localhost:5000
Primer test http
Sabiendo esto, lo que haremos será crear un Handler que responda a la request. Por ello nuestra clase de test será un fichero llamado http_score_test.go
con el siguiente contenido:
package http_score
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHttpScore(t *testing.T) {
t.Run("return Paco's score", func(t *testing.T) {
request, _ := http.NewRequest(http.MethodGet, "/players/Paco", nil)
response := httptest.NewRecorder()
PlayerServer(response, request)
expected := "20"
result := response.Body.String()
if result != expected {
t.Errorf("result '%s', expected '%s'", result, expected)
}
})
}
Lo que mola de estos test es que estamos creando una petición HTTP con request, _ := http.NewRequest(http.MethodGet, "/players/Paco", nil)
y con el paquete httptest estamos creando una «grabadora de la respuesta» así: response := httptest.NewRecorder()
.
Esto es muy útil para nosotros porque al tener la respuesta grabada después podemos ver que es lo que se ha devuelto en el Body, como Http code,…
Si ejecutamos este tests obtenemos que no está definida la función PlayerServer:
╰─$ go test
# github.com/jeslopcru/golang-examples/06-http-score
./http_score_test.go:14:3: undefined: PlayerServer
FAIL github.com/jeslopcru/golang-examples/06-http-score [build failed]
Además Goland ya nos dice que esa función no existe marcándola en rojo. Si creamos la función vacía:
package http_score
func PlayerServer() {}
Y ejecutamos los test:
╰─$ go test 2 ↵
# github.com/jeslopcru/golang-examples/06-http-score
./http_score_test.go:14:15: too many arguments in call to PlayerServer
have (*httptest.ResponseRecorder, *http.Request)
want ()
FAIL github.com/jeslopcru/golang-examples/06-http-score [build failed]
El test nos está indicando cuales son los parámetros de entrada del método. Así que cambiamos el código:
package http_score
import "net/http"
func PlayerServer(response http.ResponseWriter, request *http.Request) {}
Y volvemos a ejecutar los tests:
╰─$ go test 1 ↵
--- FAIL: TestHttpScore (0.00s)
--- FAIL: TestHttpScore/return_Paco's_score (0.00s)
http_score_test.go:20: result '', expected '20'
FAIL
exit status 1
FAIL github.com/jeslopcru/golang-examples/06-http-score 0.008s
¡Genial! Ya está casi todo funcionando. Solo nos queda devolver un 20. De momento como hemos dicho arriba, vamos a «hardcodearlo».
Así quedaría el fichero con el test en verde:
package http_score
import (
"fmt"
"net/http"
)
func PlayerServer(response http.ResponseWriter, request *http.Request) {
fmt.Fprint(response, "20")
}
Con esto, al ejecutar nuestro test obtenemos:
╰─$ go test 1 ↵
PASS
ok github.com/jeslopcru/golang-examples/06-http-score 0.007s
¿Cómo hacer funcionar un servidor web en Go?
Tal y como vimos antes llamando al método ListenAndServe creamos un servidor. Por lo que vamos a completar el scatfolding para usar nuestra aplicación web desde un navegador.
Crear el scatfolding, es importante porque:
- Tendremos software funcionando. Los test dan confianza, pero ver el código en acción es lo que realmente mola.
- Cuando refactorizamos, a veces cambiamos muchas cosas, por lo que tenemos que estar seguros que esos cambios se ven en nuestra aplicación.
Lo primero que haremos es crear un fichero main.go dentro de una nueva carpeta como este:
package main
import (
"fmt"
"github.com/jeslopcru/golang-examples/06-http-score"
"log"
"net/http"
)
func main() {
fmt.Println("Server Running in http://localhost:5000")
handler := http.HandlerFunc(http_score.PlayerServer)
if err := http.ListenAndServe(":5000", handler); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
Ahora para ejecutamos go run
y tenemos:
╰─$ go run main.go 1 ↵
Server Running in http://localhost:5000
Y si vamos a la ruta http://localhost:5000 obtendremos nuestro «20». Si queremos terminar el servidor Ctrl + C y listo.
Como hemos comentado lo que necetamos implementar para servir peticiones HTTP es la interfaz Handler. Más adelante veremos como sacarle partido a todo esto.
Para conseguir eso hacemos uso de HandlerFunc
que es un adaptador que permite que nuestro código (PlayServer) sea usado dentro del servidor web.
Para crear un servidor web llamamos a ListenAndServe
y le pasamos nuestro adaptador.
Complicando un poco todo
Viendo todo el código que tenemos parece que no hemos hecho nada, tan solo devolvemos un 20 a fuego y ya está. QUizá snecesitemos algo para ir guardando las puntuaciones de los distintos jugadores.
Pero recordemos que estamos dando pequeños pasos. Así que seguiremos con el siguiente test para eliminar esa sensación «no hacer nada».
Vamos a crear un nuevo caso para otro jugador:
t.Run("return Manolo's score", func(t *testing.T) {
request, _ := http.NewRequest(http.MethodGet, "/players/Manolo", nil)
response := httptest.NewRecorder()
PlayerServer(response, request)
expected := "35"
result := response.Body.String()
if result != expected {
t.Errorf("result '%s', expected '%s'", result, expected)
}
})
Al ejecutar ese caso vemos que falla:
╰─$ go test
--- FAIL: TestHttpScore (0.00s)
--- FAIL: TestHttpScore/return_Manolo's_score (0.00s)
http_score_test.go:34: result '20', expected '35'
FAIL
exit status 1
FAIL github.com/jeslopcru/golang-examples/06-http-score
Viendo ahora este test parece que vamos a necesitar algo para guardar las puntuaciones. ¡Viva!.
Pero antes vamos a escribir el código suficiente para que pase antes de inventar nada.
func PlayerServer(response http.ResponseWriter, request *http.Request) {
player := request.URL.Path[len("/players/"):]
if player == "Paco" {
fmt.Fprint(response, "20")
return
}
if player == "Manolo" {
fmt.Fprint(response, "35")
return
}
}
El test nos ha forzado a chequear la URL para dar la respuesta de Paco o la de Manolo. De momento elegiremos una solución simple para saber si esPaco o Manolo:
la solución es coger la url y quedarnos con lo que haya después de «/players/» no es una solución ultra potente, pero de momento nos servirá.
Ahora que ya tenemos los test en verde, vamos a refactorizar sin miedo a romper nada. Lo que haremos será simplificar como se devuelve la puntuación de cada jugador.
func PlayerServer(response http.ResponseWriter, request *http.Request) {
player := request.URL.Path[len("/players/"):]
fmt.Fprint(response, ObtainPlayerScore(player))
}
func ObtainPlayerScore(name string) string {
if name == "Paco" {
return "20"
}
if name == "Manolo" {
return "35"
}
return ""
}
Volvemos a ejecutarlos test y tenemso que todo sigue verde:
╰─$ go test 1 ↵
PASS
ok github.com/jeslopcru/golang-examples/06-http-score 0.007s
Y de la misma forma podemos simplificar los tests
func TestHttpScore(t *testing.T) {
t.Run("return Paco's score", func(t *testing.T) {
response := httptest.NewRecorder()
PlayerServer(response, createNewRequest("Paco"))
assertResponseBody(t, "20", response.Body.String())
})
t.Run("return Manolo's score", func(t *testing.T) {
response := httptest.NewRecorder()
PlayerServer(response, createNewRequest("Manolo"))
assertResponseBody(t, "35", response.Body.String())
})
}
func assertResponseBody(t *testing.T, expected string, result string) {
if result != expected {
t.Errorf("result '%s', expected '%s'", result, expected)
}
}
func createNewRequest(name string) *http.Request {
request, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/players/%s", name), nil)
return request
}
El refactor lo ha dejado todo mucho más bonito y simple de entender.
Conclusiones
Hemos aprendido a crear un servidor en go y a exponer una API que devuelve resultados. De momento es algo muy frágil pero nos sirve para ver resultados reales de lo que vamos desarrollando.
Muy educativo tu post, gracias.
Me gustaMe gusta