En el anterior post ya creamos una pequeña API en GO aplicando TDD. En ese caso se trataba solo de leer datos con peticiones GET.
Basándonos en esa parte y ya que tenemos el gusanillo de TDD(hacer un test, el código y refactorizar) vamos a implementar una petición POST, que nos servirá para guardar datos.
Si recordamos, 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}
ya implementado en el post anterior. - Añadir una nueva 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ñadiendo mejoras en cada ciclo de TDD.

¿Por dónde empezamos?
Vamos a partir del código que ya tenemos: una API para consultar las puntuaciones de «Paco» y «Manolo»
Crearemos una nueva carpeta llamada «http-score-store» con el fichero de los test y el fichero del código.
Aquí tenemos el repositorio de GitHub con la solución https://github.com/jeslopcru/golang-examples
Este es el fichero de tests:
package http_score_store
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
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 fichero del código es este otro:
package http_score_store
import (
"fmt"
"net/http"
)
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 ""
}
Una vez creados lo primero que hacemos es ejecutar los tests para comprobar que todo está en verde.
╰─$ go test
PASS
ok github.com/jeslopcru/golang-examples/07-http-score-store
Con todo funcionando ya podemos empezar.
Separar responsabilidades
Ahora mismo tenemos un código que funciona, está cubierto por tests y lo mejor: es claro y fácil de entender.
Analizando un poco al responsabilidad de cada método vemos que PlayerServer
es responsable de servir la respuesta http y además conoce como funcionan las puntuaciones, quizás sea demasiada responsabilidad para un solo método.
Como tenemos todo bastante bien hecho, podemos refactorizar esta parte tranquilamente. La mejor manera de separar responsabilidades es creando interfaces, así que vamos a mover la función ObtainPlayerScore
a una interfaz
type PlayerStore interface {
ObtainPlayerScore(name string) int
}
Creamos la interfaz, pero todavía no la estamos utilizando. Para ello tenemos que cambiar nuestra arquitectura, haciendo que PlayerServer
use PlayerStore
.
Para ello debemos convertir PlayerServer
en un struct:
type PlayerServer struct {
Store PlayerStore
}
Justo después de crear este struct vemos que todo está rojo y si ejecutamos los tests…
╰─$ go test
# github.com/jeslopcru/golang-examples/07-http-score-store
./http_score_store.go:16:58: PlayerServer redeclared in this block
previous declaration at ./http_score_store.go:12:6
FAIL github.com/jeslopcru/golang-examples/07-http-score-store [build failed]
Tranquilidad, tan solo tenemos que implementar la interfaz de la manera correcta.
func (p *PlayerServer) ServeHTTP(response http.ResponseWriter, request *http.Request) {
player := request.URL.Path[len("/players/"):]
fmt.Fprint(response, p.Store.ObtainPlayerScore(player))
}
El único cambio es que ahora llamamos a p.store.ObtainPlayerScore
para obtener la puntuación. Con eso hemos ganado muchísimo porque las responsabilidades están separadas.
Todo está ahora fallando pero ya tenemos lo que queríamos, con tan solo un par de cambios tendremos de nuevo los test en verde.
Al ejecutar los test tenemos:
╰─$ go test 2 ↵
# github.com/jeslopcru/golang-examples/07-http-score-store
./http_score_store_test.go:13:15: too many arguments to conversion to PlayerServer: PlayerServer(response, createNewRequest("Paco"))
./http_score_store_test.go:21:15: too many arguments to conversion to PlayerServer: PlayerServer(response, createNewRequest("Manolo"))
FAIL github.com/jeslopcru/golang-examples/07-http-score-store [build failed
Que no cunda el pánico
Vamos a ayudarnos del compilador para hacer que todo vuelva a la normalidad, usando un fichero main.go como este (es el mismo que teníamos en el post anterior)
package main
import (
"fmt"
"github.com/jeslopcru/golang-examples/07-http-score-store"
"log"
"net/http"
)
func main() {
fmt.Println("Server Running in http://localhost:5000")
handler := http.HandlerFunc(http_score_store.PlayerServer)
if err := http.ListenAndServe(":5000", handler); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
Vemos que al ejecutar el comando run ni siquiera compila nada.
╰─$ go run main.go 1 ↵
# command-line-arguments
./main.go:13:29: type http_score_store.PlayerServer is not an expression
Así que de momento vamos a dejar de lado los test. Lo más importante es hacer que el código compile y después ya podremos preocuparnos de los test. Ya que cambiar los test sin que compile puede llevarnos a caminos sin salida.
Lo que tenemos que hacer para que todo vuelva a compilar (que no a funcionara) es crear una instancia de PlayServer.
func main() {
fmt.Println("Server Running in http://localhost:5000")
server := &http_score_store.PlayerServer{}
if err := http.ListenAndServe(":5000", server); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
Ejecutando el run tenemos:
╰─$ go run main.go 1 ↵
Server Running in http://localhost:5000
Todo está compilando, que no funcionando (si accedemos a localhost:5000 tendremos un bonito panic)
Así que ya sabemos como arreglar los tests, así que vamos a ello.
func TestHttpScore(t *testing.T) {
server := &PlayerServer{}
t.Run("return Paco's score", func(t *testing.T) {
response := httptest.NewRecorder()
server.ServeHTTP(response, createNewRequest("Paco"))
assertResponseBody(t, "20", response.Body.String())
})
t.Run("return Manolo's score", func(t *testing.T) {
response := httptest.NewRecorder()
server.ServeHTTP(response, createNewRequest("Manolo"))
assertResponseBody(t, "35", response.Body.String())
})
}
si ejecutamos los test tenemos un error:
╰─$ go test
panic: runtime error: invalid memory address or nil pointer dereference [recovered]
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x18 pc=0x122f2f0]
Esto es debido a que no estamos pasando ninguna PlayStore
en nuestros tests, crearemos un Stub donde guardar las puntuaciones.
type StubPlayerStore struct {
scores map[string]int
}
func (s *StubPlayerStore) ObtainPlayerScore(name string) int {
score := s.scores[name]
return score
}
Un map es la manera más sencilla de guardar nuestras puntuaciones para los tests.
Así que nuestro fichero de test queda así:
package http_score_store
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
type StubPlayerStore struct {
scores map[string]int
}
func (s *StubPlayerStore) ObtainPlayerScore(name string) int {
score := s.scores[name]
return score
}
func TestHttpScore(t *testing.T) {
store := StubPlayerStore{
map[string]int{
"Paco": 20,
"Manolo": 35,
},
}
server := &PlayerServer{&store}
t.Run("return Paco's score", func(t *testing.T) {
response := httptest.NewRecorder()
request := createNewRequest("Paco")
server.ServeHTTP(response, request)
assertResponseBody(t, "20", response.Body.String())
})
t.Run("return Manolo's score", func(t *testing.T) {
response := httptest.NewRecorder()
request := createNewRequest("Manolo")
server.ServeHTTP(response, request)
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
}
Ahora ya utilizamos la interfaz PlayerStore
que definimos al principio, junto con el struct.
Por eso al ejecutar los tests obtenemos:
PASS
ok github.com/jeslopcru/golang-examples/07-http-score-store 0.007s
Ya tenemos los test en verde y tienen mejor pinta. Al introducir el concepto de «Store» la «intención» de nuestro código es más clara.
Estamos diciendo al siguiente desarrollador que venga (o a nosotros dentro de un tiempo) que los datos estarán en un PlayStore
cada vez que use PlayServer
No hemos acabado, ejecutando la aplicación
Tenemos nuestros test en verde, pero eso no significa que la aplicación funcione, sí es un poco «fastidio» que eso pase.
Hecho muchos cambios para tener un código desacoplado, hemos ido paso a paso intentando siempre tener los test en verde. Ahora es cuando nos centramos en la aplicación.
Si hacemos run la aplicación funciona, pero seguimos con el mismo problema que antes. En el momento que lanzamos una petición http obtenemos un panic.
La razón es porque no estamos pasando ningún PlayStore
. Lo que haremos será «hardcodear» uno rápido
type InMemoryPlayerStore struct{}
func (i *InMemoryPlayerStore) ObtainPlayerScore(name string) int {
return 123
}
func main() {
fmt.Println("Server Running in http://localhost:5000")
server := &http_score_store.PlayerServer{Store:&InMemoryPlayerStore{}}
if err := http.ListenAndServe(":5000", server); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
Ahora si vamos a http://localhost:5000/players/paco obtendremos siempre la misma respuesta 123.
Tenemos los test en verde y la aplicación funcionando… por lo que es el momento de elegir nuestro siguiente paso.
- Manejar el escenario de cuando el jugador no existe
- Crear el método
POST/players/{name}
- Las pruebas manuales no molan, quizás podríamos cubrir la aplicación con test funcionales.
Aunque sería lo normal escoger la opción de crear el método post, creo que será más sencillo ir por el camino de que el jugador no existe.
Si el jugador no existe devolvemos 404
Ya hemos comentado al principio que íbamos a hacer crecer nuestra aplicación para aceptar POST y que dicha petición añadiese puntuaciones a los jugadores.
Pero la idea de estos post es ir recorriendo el camino con TDD, aprendiendo Go y con ello tener aplicaciones robustas.
Crear la indirección InMemoryPlayerStore
usando inversión de dependencias ha hecho que nuestro código sea más robusto. Eso sí, hemos sufrido para que la aplicación volviese a compilar.
Por eso ahora vamos a un caso sencillo, si un jugador no existe devolveremos 404.
t.Run("return 404 when not found a player", func(t *testing.T) {
response := httptest.NewRecorder()
request := createNewRequest("Juan")
server.ServeHTTP(response, request)
expected := http.StatusNotFound
result := response.Code
if result != expected {
t.Errorf("status result '%d', status expected '%d'", result, expected)
}
})
Al ejecutar los test obtenemos el error
╰─$ go test
--- FAIL: TestHttpScore (0.00s)
--- FAIL: TestHttpScore/return_404_when_not_found_a_player (0.00s)
http_score_store_test.go:54: status result '200', status expected '404'
FAIL
Ahora el mínimo código para que pase es:
func (p *PlayerServer) ServeHTTP(response http.ResponseWriter, request *http.Request) {
player := request.URL.Path[len("/players/"):]
response.WriteHeader(http.StatusNotFound)
fmt.Fprint(response, p.Store.ObtainPlayerScore(player))
}
Todos los test vuelven a verde y este es uno de los ejemplos que ilustra que haciendo TDD encontramos huecos en nuestro código.
Pero debemos resistir la tentación y hacer el mínimo código para que pasen los tests, en la siguiente iteración crearemos un test para solucionar ese hueco.
En nuestro caso, ahora todas las respuestas devuelven el código 404. Lo que haremos será actualizar los assert para chequear también el status.
El fichero de Test queda ahora así:
package http_score_store
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
type StubPlayerStore struct {
scores map[string]int
}
func (s *StubPlayerStore) ObtainPlayerScore(name string) int {
score := s.scores[name]
return score
}
func TestHttpScore(t *testing.T) {
store := StubPlayerStore{
map[string]int{
"Paco": 20,
"Manolo": 35,
},
}
server := &PlayerServer{&store}
t.Run("return Paco's score", func(t *testing.T) {
response := httptest.NewRecorder()
request := createNewRequest("Paco")
server.ServeHTTP(response, request)
assertStatus(response.Code, http.StatusOK, t)
assertResponseBody(t, "20", response.Body.String())
})
t.Run("return Manolo's score", func(t *testing.T) {
response := httptest.NewRecorder()
request := createNewRequest("Manolo")
server.ServeHTTP(response, request)
assertStatus(response.Code, http.StatusOK, t)
assertResponseBody(t, "35", response.Body.String())
})
t.Run("return 404 when not found a player", func(t *testing.T) {
response := httptest.NewRecorder()
request := createNewRequest("Juan")
server.ServeHTTP(response, request)
assertStatus(response.Code, http.StatusNotFound, t)
})
}
func assertStatus(result int, expected int, t *testing.T) {
if result != expected {
t.Errorf("wrong stsatus 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 createNewRequest(name string) *http.Request {
request, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/players/%s", name), nil)
return request
}
Hemos creado una función nueva llamada assertStatus
para chequear el código http en todos los casos.
Ahora los 2 primeros test fallan así que vamos a solucionarlo haciendo que solo devolvamos 404 cuando la puntuación es 0.
func (p *PlayerServer) ServeHTTP(response http.ResponseWriter, request *http.Request) {
player := request.URL.Path[len("/players/"):]
score := p.Store.ObtainPlayerScore(player)
if 0 == score {
response.WriteHeader(http.StatusNotFound)
}
fmt.Fprint(response, score)
}
Guardando valores
Ahora que ya tenemos el método que obtiene el «score» de un jugador ya podemos empezar a trabajar en el método que guarda nuevas puntuaciones.
Cuando realicemos esta cambio podremos distinguir entre peticiones GET para obtener datos y peticiones pOST para guardar datos.
Poco a poco, estamos creando una API REST con Go usando TDD.
Así que, este sería el nuevo test:
func TestStoreWins(t *testing.T) {
store := StubPlayerStore{
map[string]int{},
}
server := &PlayerServer{&store}
t.Run("it returns accepted on POST", func(t *testing.T) {
request, _ := http.NewRequest(http.MethodPost, "/players/Luis", nil)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
assertStatus(response.Code, http.StatusAccepted, t)
})
}
Si lo ejecutamos obtenemos lo que ya intuíamos…
╰─$ go test
--- FAIL: TestStoreWins (0.00s)
--- FAIL: TestStoreWins/it_returns_accepted_on_POST (0.00s)
http_score_store_test.go:76: wrong stsatus status result '404', status expected '202'
Que va a fallar porque el status es 404, así que lo que tendremos que hacer es comprobar cuando la petición es GET
y cuando es POST. En el caso en el que sea POST escribir en la response es status correcto:
func (p *PlayerServer) ServeHTTP(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodPost {
response.WriteHeader(http.StatusAccepted)
return
}
player := request.URL.Path[len("/players/"):]
score := p.Store.ObtainPlayerScore(player)
if 0 == score {
response.WriteHeader(http.StatusNotFound)
}
fmt.Fprint(response, score)
}
Todo está funcionando a la perfección, hacemos un commit de los cambios y ahora podemos ir al paso de refactor.
El «code smell» es que el la función ServeHTTP no tiene las responsabilidades separadas, por lo que crearemos funciones pequeñas para separas las preocupaciones.
func (p *PlayerServer) ServeHTTP(response http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodPost:
p.processWin(response)
case http.MethodGet:
p.addScore(response, request)
}
}
func (p *PlayerServer) processWin(response http.ResponseWriter) {
response.WriteHeader(http.StatusAccepted)
}
func (p *PlayerServer) addScore(response http.ResponseWriter, request *http.Request) {
player := request.URL.Path[len("/players/"):]
score := p.Store.ObtainPlayerScore(player)
if 0 == score {
response.WriteHeader(http.StatusNotFound)
}
fmt.Fprint(response, score)
}
Este cambio hace que hayamos separado toda la responsabilidad, como tenemos tests podemos comprobar que todo sigue funcionando.
Registrando ganadores
A continuación, queremos comprobar que cuando hacemos una petición POST / players / {name}
se suma una nueva punto a ese jugador.
Así que lo haremos será cambiar un poco el test que tenemos «it returns accepted on POST» para poder comprobar que se guarda un punto cuando se hace el post.
Pero ¿cómo lo hacemos? Para conseguir este objetivo, lo primero que tenemos que hacer es cambiar nuestro StubPlayerStore
para añadirle la funcionalidad de RecordWin
.
Con esto conseguimos separar la responsabilidad, ya que el repositorio será encargado de guardar y además cambiando StubPlayerStore
podemos «espiar» cuando se invoca a este método.
Vamos a empezar por el assert e iremos viendo los cambios:
if len(store.winCalls) != 1 {
t.Errorf("got %d calls to RecordWin want %d", len(store.winCalls), 1)
}
Con store.winCalls
estamos espiando el resultado. Para conseguirlo vamos a modificar StubPlayerStore
así:
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)
}
En cuanto hacemos ese cambio Goland nos indica que algo va mal. Básicamente hemos cambiado la firma de StubPlayerStore por lo que hay que actualizar la manera en la que se inicia, en un principio ponemos el valor a nil
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) {
request, _ := http.NewRequest(http.MethodPost, "/players/Luis", nil)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
assertStatus(response.Code, http.StatusAccepted, t)
if len(store.winCalls) != 1 {
t.Errorf("got %d calls to RecordWin want %d", len(store.winCalls), 1)
}
})
}
Si ejecutamos los test tenemos:
╰─$ go test 1 ↵
# github.com/jeslopcru/golang-examples/07-http-score-store
./http_score_store_test.go:27:17: too few values in struct initializer
FAIL github.com/jeslopcru/golang-examples/07-http-score-store [build failed]
Todo falla porque hemos cambiado la firma, así que como nos pasó antes, lo primero es hacer que compile y luego ya veremos que dicen los tests.
Tenemos que hacer lo mismo en el test TestHttpScore
inicia a nil la declaración para tener los test funcionando.
╰─$ go test 2 ↵
--- FAIL: TestStoreWins (0.00s)
--- FAIL: TestStoreWins/it_record_a_win_when_POST (0.00s)
http_score_store_test.go:77: got 0 calls to RecordWin want 1
FAIL
exit status 1
FAIL github.com/jeslopcru/golang-examples/07-http-score-store 0.007s
¡Bien! ya compila nuestra aplicación y encima ya sabemos cual es el siguiente paso, hacer que el test pase.
Para ello lo primero será añadir a la el método con el que vamos a guardar. Después modificamos la firma del método processWin
para obtener la request. Y por último modificar el dicho método para que todo quede así:
package http_score_store
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) {
switch request.Method {
case http.MethodPost:
p.processWin(response, request)
case http.MethodGet:
p.addScore(response, request)
}
}
func (p *PlayerServer) processWin(response http.ResponseWriter, request *http.Request) {
player := request.URL.Path[len("/players/"):]
p.Store.RecordWin(player)
response.WriteHeader(http.StatusAccepted)
}
func (p *PlayerServer) addScore(response http.ResponseWriter, request *http.Request) {
player := request.URL.Path[len("/players/"):]
score := p.Store.ObtainPlayerScore(player)
if 0 == score {
response.WriteHeader(http.StatusNotFound)
}
fmt.Fprint(response, score)
}
Como vemos hemos tenido que modificar la firma, pero ahora tenemos los test pasando, así que podemos refactorizar la extracción del nombre del jugador y que a los métodos processWin
y addScore
solo le pasemos el nombre y no toda la request. Quedando finalmente el método así:
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)
}
}
Ahora ejecutamos los tests y el resultado es que todos pasan. Tenemso nuestra primera API REST construida en Go usando TDD.
Conclusiones
Hemos aprendido a separar responsabilidades creando interfaces. Para ello nos hemos apoyado en TDD para construirlas y del mismo modo hemos visto que no es necesario tener un «almacenamiento» real de datos cuando trabajamos con TDD.
Paralelamente hemos aprendido a usar Mocking e ir dando pequeños pasos para llegar a la solución. Creo que la lección más importante es ir primero a que la aplicación compile y luego a que pasen los tests.
Lo más importante, a mi usar TDD me ha servido para ir escribiendo el post poco a poco, porque cuando retomaba la escritura días después solo tenía que ejecutar los test para ver por donde estaba y que me quedaba por escribir.