Hacer test de integración a una API en Go

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.

9154090787_2ebb6f38ef_z

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 ejemplo assertResponseBody(t *testing.T, expected string, result string) y assertStatus(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.

 

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.