Vamos a seguir aprendiendo un poco más acerca de GO.
En la serie de posts hemos creado una aplicación para ver la puntuación de un jugador y para añadir un punto dado un jugador.
Es la hora de empezar con aprender JSON.
Para ello ampliaremos la API con un nuevo endpoint que nos devuelva la lista de jugadores y sus puntos en formato JSON.
Añadir el endpoint:
GET /league
que devuelve la lista de jugadores con sus puntos
Partimos de una serie de fichero y funcionalidades ya creadas:
main.go
func main() {
fmt.Println("Server Running in http://localhost:5000")
server := &http_score_total.PlayerServer{Store: &http_score_total.InMemoryPlayerStore{}}
if err := http.ListenAndServe(":5000", server); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
package http_score_league
import (
"fmt"
"net/http"
)
// PlayerStore stores score information about players
type PlayerStore interface {
ObtainPlayerScore(name string) int
RecordWin(name string)
}
// PlayerServer is a HTTP interface for player information
type PlayerServer struct {
Store PlayerStore
}
func (p *PlayerServer) ServeHTTP(response http.ResponseWriter, request *http.Request) {
name := request.URL.Path[len("/players/"):]
switch request.Method {
case http.MethodPost:
p.addScore(response, name)
case http.MethodGet:
p.showScore(response, name)
}
}
func (p *PlayerServer) addScore(response http.ResponseWriter, name string) {
p.Store.RecordWin(name)
response.WriteHeader(http.StatusAccepted)
}
func (p *PlayerServer) showScore(response http.ResponseWriter, name string) {
score := p.Store.ObtainPlayerScore(name)
if 0 == score {
response.WriteHeader(http.StatusNotFound)
}
fmt.Fprint(response, score)
}
InMemoryPlayStore.go
package http_score_league
func NewInMemoryPlayerStore() *InMemoryPlayerStore {
return &InMemoryPlayerStore{map[string]int{}}
}
type InMemoryPlayerStore struct {
store map[string]int
}
func (i *InMemoryPlayerStore) RecordWin(name string) {
i.store[name]++
}
func (i *InMemoryPlayerStore) ObtainPlayerScore(name string) int {
return i.store[name]
}
Junto con los ficheros de tests:
http_score_league_test.go
package http_score_league
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
type StubPlayerStore struct {
scores map[string]int
winCalls []string
}
func (s *StubPlayerStore) ObtainPlayerScore(name string) int {
score := s.scores[name]
return score
}
func (s *StubPlayerStore) RecordWin(name string) {
s.winCalls = append(s.winCalls, name)
}
func TestHttpScore(t *testing.T) {
store := StubPlayerStore{
map[string]int{
"Paco": 20,
"Manolo": 35,
}, nil,
}
server := &PlayerServer{&store}
t.Run("return Paco's score", func(t *testing.T) {
response := httptest.NewRecorder()
request := newGetScoreRequest("Paco")
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusOK)
assertResponseBody(t, "20", response.Body.String())
})
t.Run("return Manolo's score", func(t *testing.T) {
response := httptest.NewRecorder()
request := newGetScoreRequest("Manolo")
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusOK)
assertResponseBody(t, "35", response.Body.String())
})
t.Run("return 404 when not found a player", func(t *testing.T) {
response := httptest.NewRecorder()
request := newGetScoreRequest("Juan")
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusNotFound)
})
}
func TestStoreWins(t *testing.T) {
store := StubPlayerStore{
map[string]int{}, nil,
}
server := &PlayerServer{&store}
t.Run("it record a win when POST", func(t *testing.T) {
player := "Luis"
request := newPostWinRequest(player)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusAccepted)
if len(store.winCalls) != 1 {
t.Errorf("got %d calls to RecordWin want %d", len(store.winCalls), 1)
}
if store.winCalls[0] != player {
t.Errorf("did not store correct winner got '%s' want '%s'", store.winCalls[0], player)
}
})
}
func assertStatus(t *testing.T, result int, expected int) {
if result != expected {
t.Errorf("wrong status status result '%d', status expected '%d'", result, expected)
}
}
func assertResponseBody(t *testing.T, expected string, result string) {
if result != expected {
t.Errorf("result '%s', expected '%s'", result, expected)
}
}
func newGetScoreRequest(name string) *http.Request {
request, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/players/%s", name), nil)
return request
}
func newPostWinRequest(name string) *http.Request {
request, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("/players/%s", name), nil)
return request
}
y http_score_league_integration_test.go
package http_score_league
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestRecordingWinsAndRetrievingThem(t *testing.T) {
store := NewInMemoryPlayerStore()
server := PlayerServer{store}
player := "Jesus"
server.ServeHTTP(httptest.NewRecorder(), newPostWinRequest(player))
server.ServeHTTP(httptest.NewRecorder(), newPostWinRequest(player))
server.ServeHTTP(httptest.NewRecorder(), newPostWinRequest(player))
response := httptest.NewRecorder()
server.ServeHTTP(response, newGetScoreRequest(player))
assertStatus(t, response.Code, http.StatusOK)
assertResponseBody(t, "3", response.Body.String())
}
Empezamos por el tests
Lo que haremos será extender la suite de test qeu ya tenemos con un test nuevo.
Como la idea es ir dando pequeños pasos, nuestro primer test solo validará que el endpoint funciona y devuelve 200.
func TestLeague(t *testing.T) {
store := StubPlayerStore{}
server := &PlayerServer{&store}
t.Run("it returns 200 on /league", func(t *testing.T) {
request, _ := http.NewRequest(http.MethodGet, "/league", nil)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusOK)
})
}
Con esto conseguiremos crear el endpoint y separar la funcionalidad para en un siguiente test implementar lo que necesitamos.
Si ejecutamos los tests con go test
obtenemos un panic
$ go test
...
=== RUN TestLeague/it_returns_200_on_/league
panic: runtime error: slice bounds out of range [recovered]
panic: runtime error: slice bounds out of range
...
Si leemos bien la traza del error, nos llevará al fichero linea 21:
name := request.URL.Path[len("/players/"):]
Lo que esta pasando es que en post anteriores utilizamos un enrutamiento un poco «débil». Estamos tratando de dividir la cadena en 2 trozos (players+nombre) y Go nos indica que estamos fuera de rango.
Justamente lo qeue stá pasando es que no distinguimos si la cadena que viene en la request es ‘player’ o ‘league’. Así que es hora de que aprendamos un poco sobre enrutamiento
Enrutamineto
Go tiene un enrutador que se llama ServeMux
que vamos a usar para solucionar este problema.
Como ni siquiera tenemos los tests funcionando, vamos a ir directos a hacer que la aplicación funcione, despues nos preocuparemos de que los test pasen aunque con ello cometamos algunos «pecados».
La idea de TDD es intentar que todo esté en verde el mayor tiempo posible, si llegamos a rojo volver a verde. Pero si por un casual llegamos a un panic, saltarnos cualquier principio para volver a verde. Ya tendremos tiempo de refactorizar con los tests en verde.
Por tanto la función de serveHTTP
quedaría tal que así:
func (p *PlayerServer) ServeHTTP(response http.ResponseWriter, request *http.Request) {
router := http.NewServeMux()
router.Handle("/league", http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
response.WriteHeader(http.StatusOK)
}))
router.Handle("/players/", http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
name := request.URL.Path[len("/players/"):]
switch request.Method {
case http.MethodPost:
p.addScore(response, name)
case http.MethodGet:
p.showScore(response, name)
}
}))
router.ServeHTTP(response, request)
}
Lo que hacemos es crear un router
al que le añadimos nuevas rutas league
y players
y ya dentro de esas rutas podemos hacer lo que necesitemos.
Por ejemplo en el caso de players
solo hemos copiado el código que estaba antes dentro de la función anónima.
Ahora que ya tenemos los tests en verde es hora de refactorizar. En este punto el refactor es claro, hacer que las funciones dejen de ser anónimas y pasen a ser funciones normales.
func (p *PlayerServer) ServeHTTP(response http.ResponseWriter, request *http.Request) {
router := http.NewServeMux()
router.Handle("/league", http.HandlerFunc(p.leagueHandler))
router.Handle("/players/", http.HandlerFunc(p.playersHandler))
router.ServeHTTP(response, request)
}
func (p *PlayerServer) leagueHandler(response http.ResponseWriter, request *http.Request) {
response.WriteHeader(http.StatusOK)
}
func (p *PlayerServer) playersHandler(response http.ResponseWriter, request *http.Request) {
name := request.URL.Path[len("/players/"):]
switch request.Method {
case http.MethodPost:
p.addScore(response, name)
case http.MethodGet:
p.showScore(response, name)
}
}
Los test siguen funcionando:
╰─$ go test
PASS
ok github.com/jeslopcru/golang-examples/09-http-score-league
Llegados a este punto parece un poco raro crear el enrutador dentro ServeHTTP cuando ya tenemos la request. Lo más eficiente sería sacarlo fuera como dependencia.
Para no ensuciar demasiado lo que tenemos vamos a crear una nueva función llamada PlayerServerMaster
(el nombre es malo pero de momento sirve para ilustrar lo que vamos a hacer).
Esta función tendrá como dependencias nuestro PlayerStore
y así solo crearemos una instancia del router.
type PlayerServer struct {
store PlayerStore
router *http.ServeMux
}
func PlayerServerMaster(store PlayerStore) *PlayerServer {
p := &PlayerServer{
store,
http.NewServeMux(),
}
p.router.Handle("/league", http.HandlerFunc(p.leagueHandler))
p.router.Handle("/players/", http.HandlerFunc(p.playersHandler))
return p
}
func (p *PlayerServer) ServeHTTP(response http.ResponseWriter, request *http.Request) {
p.router.ServeHTTP(response, request)
}
Como podemos ver hemos añadido a PlayerServer
una nueva dependencia, el router. Si ahora ejecutamos los test obtenemos:
╰─$ go test
# github.com/jeslopcru/golang-examples/09-http-score-league
./http_score_league_integration_test.go:11:25: too few values in struct initializer
./http_score_league_test.go:32:26: too few values in struct initializer
./http_score_league_test.go:66:26: too few values in struct initializer
./http_score_league_test.go:111:26: too few values in struct initializer
FAIL github.com/jeslopcru/golang-examples/09-http-score-league [build failed]
Básicamente, la manera de instanciar nuestro servidor ha cambiado. Antes lo hacíamos así: server := PlayerServer{store}
Ahora al haber añadido la nueva dependencia lo haremos así:
server := PlayerServerMaster(store)
. Haciendo este cambio en todas las veces que instanciamos el server (en los test de integración y en los unitarios) volvemos a obtener los test en verde
Embedding
Una de las funcionalidades más interesantes de Golang son las interfaces embebidas. En nuestro caso hemos añadido la propiedad router *http.ServeMux
a PlayerServer
.
Si reemplazamos esta segunda propiedad por http.Handler
, estamos embebiendo los métodos de la interfaz http.Handler a PlayServer.
Go no proporciona subclases basadas en tipos como nosotros las conocemos. Pero tiene la capacidad de «tomar prestadas» partes de una implementación al incrustar tipos dentro de una estructura o interfaz.
En resumen cuando una interfaz tiene otra interfaz como una «interfaz embebida», esta tendrá todos los métodos que la clase embebida tiene.
Eso significa que nuestro PlayServer
tiene ahora todos los métodos de http.Handler
. Por lo que finalmente todo quedaría así:
// PlayerStore stores score information about players
type PlayerStore interface {
ObtainPlayerScore(name string) int
RecordWin(name string)
}
// PlayerServer is a HTTP interface for player information
type PlayerServer struct {
store PlayerStore
http.Handler
}
func PlayerServerMaster(store PlayerStore) *PlayerServer {
p := new(PlayerServer)
p.store = store
router := http.NewServeMux()
router.Handle("/league", http.HandlerFunc(p.leagueHandler))
router.Handle("/players/", http.HandlerFunc(p.playersHandler))
p.Handler = router
return p
}
func (p *PlayerServer) leagueHandler(response http.ResponseWriter, request *http.Request) {
response.WriteHeader(http.StatusOK)
}
func (p *PlayerServer) playersHandler(response http.ResponseWriter, request *http.Request) {
name := request.URL.Path[len("/players/"):]
switch request.Method {
case http.MethodPost:
p.addScore(response, name)
case http.MethodGet:
p.showScore(response, name)
}
}
func (p *PlayerServer) addScore(response http.ResponseWriter, name string) {
p.store.RecordWin(name)
response.WriteHeader(http.StatusAccepted)
}
func (p *PlayerServer) showScore(response http.ResponseWriter, name string) {
score := p.store.ObtainPlayerScore(name)
if 0 == score {
response.WriteHeader(http.StatusNotFound)
}
fmt.Fprint(response, score)
}
Pero aun haciendo este «gran cambio» en el código de producción podemos estar seguros de que no hemos roto nada, por que tenemos un monton de tests que prueban toda nuestra funcionalidad. Por lo que si volvermos a ejecutarlos comprobaremos que todo sigue verde.
«Embedding» es una característica muy a tener en cuenta a la hora de trabajar con Go, ya que nos permite componer nuevas interfaces usando otras.
Aunque hay que tener cuidado con que es lo que exponemos, porque al componer unas interfaces con otras podemos dejar «a la vista» detalles que pueden ser demásiado específicos para una interfaz. Si hubiésemos dejado el tipo concreto http.ServeMux
en lugar de utilizar la interfaz http.Handler
todo seguiría funcionando, pero dejaríamos a la vista una implementación concreta lo que podría suponer algunos dolores de cabeza en el futuro (p.e. si deseamos usar otro router como Gorilla).
Devolviendo Json
Ahora que ya hemos restructurado nuestra aplicación y que añadir rutas es super sencillo, vamos a profundizar en devolver para el endpoint /league
un JSON como este:
[
{
"Name":"Paco",
"Score":10
},
{
"Name":"Carmen",
"Score":15
}
]
Como ya tenemos la respuesta que deseamos obtener la mejor idea es crear un test con la respuesta esperada.
Si recordamos, es el test con el que empezamos el post, era un test tan simple como «esperar que una llamada al endpoint devuelva un 200». Vamos a completar ese test para comprobar que llega un JSON vacío.
func TestLeague(t *testing.T) {
store := StubPlayerStore{}
server := PlayerServerMaster(&store)
t.Run("it returns 200 on /league", func(t *testing.T) {
request, _ := http.NewRequest(http.MethodGet, "/league", nil)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
var expected []Player
err := json.NewDecoder(response.Body).Decode(&expected)
if err != nil {
t.Fatalf ("Unable to parse response from server '%s' into slice of Player, '%v'", response.Body, err)
}
assertStatus(t, response.Code, http.StatusOK)
})
}
¿Qué estamos haciendo en este tests? Lo que hacemos para descodificar el JSON llamamos json.NewDecoder
con el cuerpor de la respuesta y lo comparamos con el struct Player vacío que hemos creado.
Si ejecutamos los tests estos fallan.
╰─$ go test 127 ↵
# github.com/jeslopcru/golang-examples/09-http-score-league
./http_score_league_test.go:119:18: undefined: Player
FAIL github.com/jeslopcru/golang-examples/09-http-score-league [build failed]
¿Qué hacemos ahora? Ya estamso probando el JSON como salida, pero mantener debemos ser cuidadosos porque mantener tests «tan grandes» no es sostenible.
- Son frágiles, en el momento que cambiemos el modelo de datos el test fallará.
- Aportan poco valor, en el sentido de aue si fallan encontrar la causa raiz comparando dos JSON no nos ayudan demasiado.
- Lo verdaderamente importante es probar la salida de los datos desde su fuente.
Así que lo vamos a hacer es analizar que estructuras de datos necesitamos y a construirlas.
Modelando los datos
Viendo el JSON que deseamos obtener, un modelo de datos válido para nuestro caso podría ser una estructura como la siguiente:
type Player struct {
Name string
Score int
}
Por lo que si despues de crear el struct ejecutamos los tests tenemos:
╰─$ go test 2 ↵
--- FAIL: TestLeague (0.00s)
--- FAIL: TestLeague/it_returns_200_on_/league (0.00s)
http_score_league_test.go:122: Unable to parse response from server '' into slice of Player, 'EOF'
FAIL
exit status 1
FAIL github.com/jeslopcru/golang-examples/09-http-score-league
Para que los tests pasen solo tenemos que cambiar la función leagueHandler
así:
func (p *PlayerServer) leagueHandler(response http.ResponseWriter, request *http.Request) {
leagueList := []Player{
{"Paco", 20},
}
json.NewEncoder(response).Encode(leagueList)
response.WriteHeader(http.StatusOK)
}
Ahora los tests pasan. Si nos fijamos podemos ver como funciona la librería standard
- Para el
Decoder
necesitamosio.writer
que es lo que implementahttp.ResponseWriter
- Para el
Encoder
necesitamosio.reader
que tenemos en el campo body.
Si nos fijamos con Go y su librería standard podemos crear fácilmente una API que funcione a la perfección.
Como tenemos los tests en verde, ahora viene la fase de refactoring. En nuestro caso separaremos un poco el manejar la petición con el obtener los datos.
func (p *PlayerServer) leagueHandler(response http.ResponseWriter, request *http.Request) {
json.NewEncoder(response).Encode(p.obtainLeagueList())
response.WriteHeader(http.StatusOK)
}
func (p *PlayerServer) obtainLeagueList() []Player {
return []Player{
{"Paco", 20},
}
}
Es el momento de que mejoremos nuestro test para probar el listado de league
Pero antes es necesario qeu actualicemos nuestro StubPlayerStore
para que nos devuelva los datos que estamos esperando.
type StubPlayerStore struct {
scores map[string]int
winCalls []string
league []Player
}
Lo que devolverá nuestro Stub es una lista de jugadores, por lo tanto ya podemos actualizar nuestro test case para añadirle la lista de jugadores que deseamos recibir.
func TestLeague(t *testing.T) {
t.Run("it returns league list as JSON", func(t *testing.T) {
expectedLeague := []Player{
{"Juan", 7},
{"Manolo", 5},
{"Paco", 2},
}
store := StubPlayerStore{nil,nil,expectedLeague}
server := PlayerServerMaster(&store)
request, _ := http.NewRequest(http.MethodGet, "/league", nil)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
var result []Player
err := json.NewDecoder(response.Body).Decode(&result)
if err != nil {
t.Fatalf("Unable to parse response from server '%s' into slice of Player, '%v'", response.Body, err)
}
assertStatus(t, response.Code, http.StatusOK)
if !reflect.DeepEqual(result, expectedLeague) {
t.Errorf("got %v want %v", result, expectedLeague)
}
})
}
Como hemos actualizado nuestro Stub, si ejecutamos los test obtendremos un error:
╰─$ go test 1 ↵
# github.com/jeslopcru/golang-examples/09-http-score-league
./http_score_league_test.go:32:6: too few values in struct initializer
FAIL github.com/jeslopcru/golang-examples/09-http-score-league [build failed]
Lo único que tenemos que hacer es actualizar el resto de tests para adecuarlo a la nueva firma del stub y obtendremos el error que buscamos:
╰─$ go test 2 ↵
--- FAIL: TestLeague (0.00s)
--- FAIL: TestLeague/it_returns_league_list_as_JSON (0.00s)
http_score_league_test.go:134: got [{Paco 20}] want [{Juan 7} {Manolo 5} {Paco 2}]
FAIL
exit status 1
FAIL github.com/jeslopcru/golang-examples/09-http-score-league 0.009s
Con este error ya podemos continuar trabajando. Ya hemos añadido los datos a nuestro Stub, así que ahora tenemos que actualizar la interfaz PlayStore
para que pueda proporcionarnos datos de la liga.
// PlayerStore stores score information about players
type PlayerStore interface {
ObtainPlayerScore(name string) int
RecordWin(name string)
GetLeague() []Player
}
Al actualizar la interfaz si volvermos a ejecutar los tests, son los mismos test los que nos indican donde debemos hacer los cambios
╰─$ go test 1 ↵
# github.com/jeslopcru/golang-examples/09-http-score-league
./http_score_league_integration_test.go:11:30: cannot use store (type *InMemoryPlayerStore) as type PlayerStore in argument to PlayerServerMaster:
*InMemoryPlayerStore does not implement PlayerStore (missing GetLeague method)
./http_score_league_test.go:35:31: cannot use &store (type *StubPlayerStore) as type PlayerStore in argument to PlayerServerMaster:
*StubPlayerStore does not implement PlayerStore (missing GetLeague method)
./http_score_league_test.go:69:31: cannot use &store (type *StubPlayerStore) as type PlayerStore in argument to PlayerServerMaster:
*StubPlayerStore does not implement PlayerStore (missing GetLeague method)
./http_score_league_test.go:120:32: cannot use &store (type *StubPlayerStore) as type PlayerStore in argument to PlayerServerMaster:
*StubPlayerStore does not implement PlayerStore (missing GetLeague method)
FAIL github.com/jeslopcru/golang-examples/09-http-score-league [build failed]
Lo primero será actualizar nuestro handler para que llame a la interfaz:
func (p *PlayerServer) leagueHandler(response http.ResponseWriter, request *http.Request) {
json.NewEncoder(response).Encode(p.store.GetLeague())
response.WriteHeader(http.StatusOK)
}
Por lo que ya es posible eliminar el métod que teníamos llamado obtainLeagueList
. Aún así si volvemos a ejecutar los tests estos siguen fallando.
╰─$ go test 1 ↵
# github.com/jeslopcru/golang-examples/09-http-score-league
./http_score_league_integration_test.go:11:30: cannot use store (type *InMemoryPlayerStore) as type PlayerStore in argument to PlayerServerMaster:
...
FAIL
Lo que indica el compilador es que InMemoryPlayerStore
y StubPlayerStore
no tienen implementado el nuevo método de la interfaz.
Para implementar el Stub es tan simple como hacer esto:
func (s *StubPlayerStore) GetLeague() []Player {
return s.league
}
Si volvemos a ejecutar los tests, el número de errores se ha reducido, solo tenemos que subsanar la implementación de InMemoryStore
╰─$ go test 2 ↵
# github.com/jeslopcru/golang-examples/09-http-score-league
./http_score_league_integration_test.go:11:30: cannot use store (type *InMemoryPlayerStore) as type PlayerStore in argument to PlayerServerMaster:
*InMemoryPlayerStore does not implement PlayerStore (missing GetLeague method)
FAIL github.com/jeslopcru/golang-examples/09-http-score-league [build failed]
Si bien sería bastante sencillo implementar GetLeague
«correctamente» iterando sobre el map, recordemos que solo estamos tratando de escribir la cantidad mínima de código para hacer que las pruebas pasen.
Así que dentro del fichero InMemoryPlayStore.go
solo tenemos que añadir el siguiente método.
func (i *InMemoryPlayerStore) GetLeague() []Player {
return nil
}
Si ejecutamos los tests ya los tenemos todos en verde al fin. Lo que realmente estamos haciendo es postpoponer un poco la implementación. Como tenemos los test en verde, podríamos commitear los cambios y hacer un poco de refactor, sobre todo el los tests
func TestLeague(t *testing.T) {
t.Run("it returns league list as JSON", func(t *testing.T) {
expectedLeague := []Player{
{"Juan", 7},
{"Manolo", 5},
{"Paco", 2},
}
store := StubPlayerStore{nil, nil, expectedLeague}
server := PlayerServerMaster(&store)
request := newGetLeagueRequest()
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
result := obtainLeagueFromResponse(t, response.Body)
assertStatus(t, response.Code, http.StatusOK)
assertLeague(t, expectedLeague, result)
})
}
func assertLeague( t *testing.T, expectedLeague []Player, result []Player) {
if !reflect.DeepEqual(result, expectedLeague) {
t.Errorf("got %v want %v", result, expectedLeague)
}
}
func obtainLeagueFromResponse(t *testing.T, body io.Reader) (league []Player) {
t.Helper()
err := json.NewDecoder(body).Decode(&league)
if err != nil {
t.Fatalf("Unable to parse response from server '%s' into slice of Player, '%v'", body, err)
}
return
}
func newGetLeagueRequest() (*http.Request) {
request, _ := http.NewRequest(http.MethodGet, "/league", nil)
return request
}
Lo único que nos queda es asegurarnos de que enviamos la cabecera «application/json» en las responses, así que podemos añadir este assert al tests:
if response.Header().Get("content-type") != "application/json" {
t.Errorf("response did not have content-type of application/json, got %v", response.HeaderMap)
}
Si ejecutamos los tests obviamente fallan, porque en ningun sitio hemos indicado que queremos la respuesta como JSON:
╰─$ go test 2 ↵
--- FAIL: TestLeague (0.00s)
--- FAIL: TestLeague/it_returns_league_list_as_JSON (0.00s)
http_score_league_test.go:137: response did not have content-type of application/json, got map[Content-Type:[text/plain; charset=utf-8]]
FAIL
exit status 1
FAIL github.com/jeslopcru/golang-examples/09-http-score-league 0.008s
Para solucionar este caso, solo tenemos que añadir la cabecera a nuestro handler
func (p *PlayerServer) leagueHandler(response http.ResponseWriter, request *http.Request) {
response.Header().Set("content-type", "application/json")
json.NewEncoder(response).Encode(p.store.GetLeague())
response.WriteHeader(http.StatusOK)
}
Y de la misma manera que antes ahora que tenemos los tests en verde podemos refactorizar el propio test para crear un assert
func assertContentType(response *httptest.ResponseRecorder, t *testing.T) {
if response.Header().Get("content-type") != "application/json" {
t.Errorf("response did not have content-type of application/json, got %v", response.HeaderMap)
}
}
Ahora que ya hemos resulto todos los entresijos de PlayServer, es hora de que pongamos atención en InMemoryPlayStore
porque en este momento los test pasan pero la aplicación no funciona.
La manera más sencilla de solucionar este marrón es creando unos test de integración.
Creando test de integración
Lo más sencillo sería mantener el t.run
y crear algunos casos para nuestras pruebas.
func TestRecordingWinsAndRetrievingThem(t *testing.T) {
store := NewInMemoryPlayerStore()
server := PlayerServerMaster(store)
player := "Jesus"
server.ServeHTTP(httptest.NewRecorder(), newPostWinRequest(player))
server.ServeHTTP(httptest.NewRecorder(), newPostWinRequest(player))
server.ServeHTTP(httptest.NewRecorder(), newPostWinRequest(player))
t.Run("get store", func(t *testing.T) {
response := httptest.NewRecorder()
server.ServeHTTP(response, newGetScoreRequest(player))
assertStatus(t, response.Code, http.StatusOK)
assertResponseBody(t, "3", response.Body.String())
} )
t.Run("get league", func(t *testing.T) {
response := httptest.NewRecorder()
server.ServeHTTP(response, newGetLeagueRequest())
assertStatus(t, response.Code, http.StatusOK)
result := obtainLeagueFromResponse(t, response.Body)
expected := []Player{
{"Jesus", 3},
}
assertLeague(t, result, expected) } )
}
Si ejecutamos el test, fallará porque no está implementado todavía.
╰─$ go test 1 ↵
--- FAIL: TestRecordingWinsAndRetrievingThem (0.00s)
--- FAIL: TestRecordingWinsAndRetrievingThem/get_league (0.00s)
http_score_league_test.go:148: got [{Jesus 3}] want []
FAIL
exit status 1
FAIL github.com/jeslopcru/golang-examples/09-http-score-league 0.009s
Como antes solo hicimos que los tests unitarios pasaran el método que devuelve los datos de league lo falseamos a que devolviese nil. Ahora solo tenemos que modificar el método GetLeague
de InMemoryPlayStore para que funcione.
func (i *InMemoryPlayerStore) GetLeague() []Player {
var league []Player
for name, score := range i.store {
league = append(league, Player{name, score})
}
return league
}
Todo lo que tenemos que hacer es iterar sobre el map y devolver la lista de jugadores.
Todos los tests pasan
Conclusiones
Continuamos haciendo TDD de manera más o menos segura. En este ejemplo de hoy hemos ido escribiendo una solución iterativa incremental. Primero creando el endpoint con un 200, luego refactorizando, más adelante falseando la implementación para tener los test unitarios pasando y por ultimo haciendo que todo funcionase gracias a los tests de integración.
- Hemos aprendido como funciona el routing en Go usando
http.Handler
de forma sencilla hemos creado rutas solo con la librería standard de GO. Si bien es cierto que esta librería es muy básica, nos ha servido para ilustrar como hacer una API en GO. - Hemos conocido los tipos embebidos, una caracteristica de Go para exponer una API pública.
- Ya sabemos serializar JSON para las respuestas de nuestra API.