Crear una API con TDD en GO parte 2

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.

30627718347_e5af269af4_z
SanchezM LittleFinland GoldButte2

¿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.

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.