Ha llegado la hora de usar nuestra API hecha en Go. Tenemos la API llena de tests unitarios gracias a TDD, además con las responsabilidades separadas usando inyección de dependencias.
Partiendo de la aplicación de los post anteriores en este vamos a crear un punto de entrada main.go
y usaremos test de integración para comprobar que todo funcione.
La API que estamos desarrollando sirve para guardar el numero de partidas ganadas por un jugador.
Tenemos 2 endpoints:
GET /player/{name}
obtiene la puntuación de un jugador dado su nombre.POST /player/{name}
añade un punto al jugador.
De momento, lo que nos importa es seguir el flujo de TDD (test – código – refactor) mientras aprendemos GO. Así que no tenemos base de datos, sino que lo guardamos todo en memoria.
Aquí tenemos el repositorio de GitHub con la solución https://github.com/jeslopcru/golang-examples
Aquí tenemos el fichero http_score_total.go
package http_score_total
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)
}
Por otro lado aquí tenemos nuestros tests en el fichero http_score_total_test.go
package http_score_store
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 := 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 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)
}
})
}
func assertStatus(result int, expected int, t *testing.T) {
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 createNewRequest(name string) *http.Request {
request, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/players/%s", name), nil)
return request
}
Lo primero que tenemos que hacer es ejecutar los tests para comprobar que todo está en verde.
╰─$ go test
PASS
ok github.com/jeslopcru/golang-examples/08-http-score-total
Y por ultimo aquí tenemos un pequeño fichero main.go
dentro de una carpeta llamada main
. El problema es que no termina de dar buenos resultados…
package main
import (
"fmt"
"github.com/jeslopcru/golang-examples/08-http-score-total"
"log"
"net/http"
)
type InMemoryPlayerStore struct{}
func (i *InMemoryPlayerStore) RecordWin(name string) {
}
func (i *InMemoryPlayerStore) ObtainPlayerScore(name string) int {
return 123
}
func main() {
fmt.Println("Server Running in http://localhost:5000")
server := &http_score_total.PlayerServer{Store: &InMemoryPlayerStore{}}
if err := http.ListenAndServe(":5000", server); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
Si ejecutamos nuestro main usando go run main/main.go
dejamos el servidor funcionando en http://localhost:5000:
╰─$ go run main/main.go 2 ↵
Server Running in http://localhost:5000
Al acceder haciendo una petición GET http://localhost:5000/player/jesus
obtenemos un 200 como respuesta y que el score almacenado es 123:
GET http://localhost:5000/player/jesus
HTTP/1.1 200 OK
Date: Sat, 22 Dec 2018 07:21:30 GMT
Content-Length: 3
Content-Type: text/plain; charset=utf-8
123
Response code: 200 (OK); Time: 79ms; Content length: 3 bytes
Si hacemos una petición post para añadir una nueva partida ganada al jugador (solo tenemos que cambiar GET por POST) tenemos
POST http://localhost:5000/player/jesus
HTTP/1.1 202 Accepted
Date: Sat, 22 Dec 2018 07:23:06 GMT
Content-Length: 0
Response code: 202 (Accepted); Time: 18ms; Content length: 0 bytes
Hasta aquí parece que todo funciona, pero al comprobar si se ha añadido un punto al jugador vemos que haciendo una petición GET obtenemos otra vez 123.
¿Cómo puede ser? Si están los tests en verde ¿por qué no funciona correctamente nuestra API?
A pesar de que tenemos los tests en verde, realmente no tenemos nuestra API funcionando. En realidad esto esta bien, ya que nos hemos centrado en los controladores, en separar responsabilidades y identificar una interfaz PlayerStore
en vez de diseñarla por adelantado. La interfaz que tenemos es justo la que necesitamos.
Test de integración al rescate
Podríamos hacer test para InMemoryPlayerStore
pero si más adelante la cambiamos por otra solución más robusta esos test dejaran de tener sentido.
Vamos a crear unos tests de integración que nos permitiran alcanzar nuestro objetivo: tener una API totalmente funcional, sin tener que probar InMemoryPlayerStore
directamente.
Con ello lo que conseguiremos es que si cambiamos a una base de datos normal (PostgreSQL, MySQL, Firebase,…) los tests de integración serán muy útiles y no tendremos que cambiar nada.
Aunque parece que los test de integración son la solución a todo y que solo debemos implementar este tipo de test, debemos tener claro que:
- Son más difíciles de escribir
- Si fallan es más difícil conocer el punto de fallo, así que son difíciles de arreglar
- Son lentos, lo que hace que el ciclo de feedback se más lento.
Así que lo mejor es que leamos un poco sobre la pirámide de los tests
Probando todo
Lo primero que haremos será crear un fichero llamado http_score_total_integration_test.go
. En este fichero tendremos nuestro test de integración:
package http_score_total
import (
"fmt"
"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( response.Code, http.StatusOK,t)
assertResponseBody(t, "3", response.Body.String())
}
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
}
El test básicamente lo que hace es que cuando nosotros ejecutamos 3 peticiones POST estamos añadiendo 3 puntos a Juan. Si justo después ejecutamos una petición GET para ver la puntuación de Juan, esta debe ser 3.
Para que ese test funcione tenemos separar responsabilidades: por un lado tendremos el fichero main.go con solo el servidor http y por otro nuestra implementación para guardar datos en memoria en el fichero InMemoryPlayerStore.go.
El fichero main.go
queda así:
package main
import (
"fmt"
"github.com/jeslopcru/golang-examples/08-http-score-total"
"log"
"net/http"
)
func main() {
server := &http_score_total.PlayerServer{Store: &http_score_total.NewInMemoryPlayerStore{}}
fmt.Println("Server Running in http://localhost:5000")
if err := http.ListenAndServe(":5000", server); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
Lo que hemos hecho ha sido llevarnos toda la lógica a NewInMemoryPlayerStore
:
package http_score_total
func NewInMemoryPlayerStore() *InMemoryPlayerStore {
return &InMemoryPlayerStore{}
}
type InMemoryPlayerStore struct {
}
func (i *InMemoryPlayerStore) RecordWin(name string) {
}
func (i *InMemoryPlayerStore) ObtainPlayerScore(name string) int {
return 123
}
Por lo cual el ejecutar los tests tenemos:
╰─$ go test
--- FAIL: TestRecordingWinsAndRetrievingThem (0.00s)
http_score_total_test.go:90: result '123', expected '3'
FAIL
exit status 1
FAIL github.com/jeslopcru/golang-examples/08-http-score-total 0.008s
Ya lo tenemos todo funcionando: las responsabilidades separadas, por un lado el servidor http, por otro la do los controladores y por otro nuestro repositorio (que es lo que estamos haciendo ahora)
Lo que tenemos que hacer es una implementación en memoria usando un map, tal que así:
package http_score_total
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]
}
Como vemos lo que hacemos es crear un struct llamado InMemoryPlayerStore que internamente no es más que un mapstringint donde guardaremos los datos.
Usando ese struct implementamos los 2 métodos de la interfaz ObtainPlayerScore y RecordWin.
Al extraer toda la creación del NewInMemoryPlayerStore, queda una implementación muy entendible. Por lo que si ahora ejecutamos los tests tenemos que:
╰─$ go test 1 ↵
PASS
ok github.com/jeslopcru/golang-examples/08-http-score-total 0.009s
Todo funciona correctamente. Si queremos probarlo de manera manual podemos hacer un go run y ejectutar:
curl -X POST http://localhost:5000/players/juan
y chequearlo con curl -X GET http://localhost:5000/players/juan
Si queremos chequearlo en Goland, podemos crear un fichero request.http
GET http://localhost:5000/player/jesus
###
POST http://localhost:5000/player/jesus
###
Y justo al lado de cada petición aparecerá un botón de play con el que ejecutar dichas peticiones.
Refactorizando
Ahora que tenemos todos los tests en verde vamos a darle un poco de consistencia al código. Serán pequeños refactors que podemos afrontar fácilmente.
- Los métodos
assertxxx
tienen distinta firma: por ejemploassertResponseBody(t *testing.T, expected string, result string)
yassertStatus(result int, expected int, t *testing.T)
vamos a darles a todos la misma firma:(t *testing.T, expected string, result string)
Teniendo test es tan facil como poner la nueva firma ejecutar los test y ver donde falla
╰─$ go test
# github.com/jeslopcru/golang-examples/08-http-score-total
./http_score_total_integration_test.go:21:23: cannot use response.Code (type int) as type *testing.T in argument to assertStatus
./http_score_total_integration_test.go:21:23: cannot use t (type *testing.T) as type int in argument to assertStatus
./http_score_total_test.go:39:24: cannot use response.Code (type int) as type *testing.T in argument to assertStatus
...
Así que solo nos queda cambiar el la linea 21 del fichero http_score_total_integration_test.go, la linea 39 de http_score_total_test.go y todas las que nos indiquen los tests.
Otro refactor que podríamos asumir es crear/usar un mismo método para hacer peticiones GET y otro para peticiones POST. Lo que haremos en este caso será usar lo métodos: newGetScoreRequest
y newPostWinRequest
en todas las peticiones http olvidandonos del resto.
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
}
Para ello lo que haremos será buscar la cadena http.NewRequest(http.MethodGet
, ver donde se usa y cambiarla por la llamada a newGetScoreRequest
. Como tenemos los test en verde, tan solo tenemos que cambiar hasta conseguir todos los tests en verde de nuevo
Conclusiones
Genial, ya tenemos una API implementada en Go con test de integración. Hemos conseguido tener separadas todas las responsabilidades, tanto es así que ahora poodríamos implementar PostgresPlayerStore
o FirebasePlayStore
de manera sencilla.
Por descontado sabemos que toda la funcionalidad funciona porque al trabajar con TDD todo está cubierto de tests.
En esta serie de post hemos aprendido como manejar peticiones HTTP con GO, crear interfaces, usar dependencia de inyección junto con mocks para tener unos tests más robustos y todo esto utilizando TDD poniendo enfasis en hacer el mínimo código para que el test pase.
Pero si recordamos un poco, esto no ha sido un camino de rosas, a veces nos hemos encontrado con código que ni siquiera compilaba. Ahí hemos puesto énfasis en hacer que el código volviese a compilar y a partir de ahí trabajar para que todo vuelva a verde. Cumplir con este enfoque nos obliga a escribir pruebas pequeñas, lo que significa realizar pequeños cambios, lo que ayuda a seguir trabajando en sistemas complejos manejables.