Entendemos por algoritmo determinista como aquél algoritmo qué, en términos informales, es completamente predictivo si se conocen sus entradas. Cuándo hablamos de pruebas (tests), entendemos que éstas son deterministas sí, dada una implementación concreta, el resultado de la prueba (OK o KO) es siempre el mismo. Lo mismo lo podemos extrapolar a pruebas de carga o estrés.
Seguimos con el jefe del proyecto preguntando por las mejoras de nuestra implementación, pues, a pesar de que vimos como comparar el rendimiento de nuestras implementaciones, aún no hemos sido capaces de encontrar qué es lo que hace qué nuestra implementación sea excesivamente lenta en comparación con la de la competencia.
Si vamos a ciegas, nos será un poco complicado llegar a una implementación mejor. Pero, antes de ver cómo descubrir qué partes de nuestra implementación resultan un problema para la eficiencia de la misma, tenemos otro problema a solucionar: el no determinismo de nuestras pruebas. Lo que se traduce en diferentes resultados (tiempos de ejecución dispares) para una misma implementación, lo que nos complica el proceso de análisis de nuestra implementación.
Teniendo en cuenta que el origen de los datos de nuestra aplicación son servicios de terceros, resulta imprescindible tomar consciencia de las falacias del cómputo distribuido: consumir un servicio al otro lado de la red no es gratis, su comportamiento no será siempre el esperado y el coste no será homogéneo.
Una forma de hacer frente a éste tipo de problemas, reside en la abstracción de los servicios de terceros (o al otro lado de la red).
Para ello, vamos a hacer uso de test doubles: objetos o procesos que suplantan un componente real simulando su comportamiento.
Test doubles
Antes de escoger cuál de todas las opciones disponibles vamos a usar, hagamos un repaso a los diferentes tipos de tests doubles:
- Dummies: objetos qué, generalmente, son pasados cómo parámetros para satisfacer contratos, pero nunca usados.
- Fakes: objetos con una implementación real que difiere de la implementación de producción, pero respetando el contrato.
- Stubs: objetos con un comportamiento predeterminado, sin lógica, pero que también respeta el contrato original.
- Spies: stubs que, además, también guardan información sobre el uso que se les ha dado. Por ejemplo, cuántas veces se ha llamado cada método y con qué parámetros.
- Mocks: spies que, además, también realizan aserciones sobre el comportamiento observado. Por ejemplo, comprobar que un método específico ha sido llamado.
Por lo tanto, en esta ocasión, cómo cuándo queremos abstraernos de las operaciones de lectura sobre una base de datos, nos bastaría con hacer uso de stubs o de fakes, pues realmente no queremos atar nuestra implementación al uso que se hace de los servicios de los que depende (error habitual en el uso de mocks), sino que simplemente queremos que éstos nos devuelvan datos con los que poder trabajar.
Permitiendo la inyección de dependencias
Si hacemos memoria, en el último artículo veíamos código similar a éste:
i := NewIntegrator()
i.Synchronize()
Sin embargo, éste código será difícil de probar, pues no tenemos forma de hacer uso de test doubles para dotar a nuestros tests del determinismo necesario. Ante esta situación, podemos hacer un pequeño refactor:
-
Lo primero será definir una interfaz que defina el comportamiento del cliente HTTP encargado de hacer las peticiones a los servicios externos, por ejemplo:
type HttpClient interface { Do(req *http.Request) (*http.Response, error) }
(aunque idealmente también nos podríamos abstraer de la request/response específica del paquete “net/http”)
-
Lo segundo será hacer que nuestra “constructora” permita recibir un cliente HTTP para pasárselo a nuestro integrador:
func NewIntegrator(client HttpClient) Integrator
-
Finalmente, inyectaremos el cliente HTTP en cuestión:
c := http.Client{} i := NewIntegrator(c)
Fakeando los datos de los servicios externos
Algo similar a los snippets anteriores podría ser el código de producción, sin embargo, para dotar a nuestros benchmarks de determinismo, deberemos inyectar un cliente HTTP fake, esto, en Go, lo podemos hacer de varias formas.
A continuación veremos tres de las principales, para ello vamos a suponer que tenemos definida una función
func FakeData() []byte
que nos devuelve los datos fake que queremos que devuelvan nuestros clientes fake.
-
Crear tu propio cliente fake:
Es quizás opción más práctica y sencilla para un caso como el planteado, sin embargo, en algunas ocasiones puede resultar demasiado tedioso tener que hacer todas las implementaciones a mano.
type FakeHttpClient struct { ... } func (c *FakeHttpClient) Do(req *http.Request) (*http.Response, error) { fakeData := FakeData() dataBuffer := bytes.NewBuffer(fakeData) response := http.Response{ Request: req, StatusCode: http.StatusOK, Body: ioutil.NopCloser(dataBuffer), } return &response, nil } c := &FakeClient{...}
-
Usar el paquete
"net/http/httptest"
:Que nos permite crear un servidor fake y el cliente HTTP asociado a éste.
Veamos como hacerlo y como hipotéticamente podríamos implementar diferente lógica en función del cuerpo y la URL de la petición.
fakeServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { // Here we can use the following parameters to add business logic: // req.Body -> to get the request body // req.URL.String() -> to get the request path // Send response to be tested rw.Write(FakeData()) })) c := fakeServer.Client()
-
Usar el paquete
moq
sin hacer aserciones sobre las llamadas de los métodos:Este no es quizás el mejor ejemplo para debatir sobre el uso de mocks, pero, en una ocasión similar a ésta, resultaría imprescindible ser conscientes de éste aspecto, pues, imaginad que tenemos una API que tiene varias formas de recoger los datos. Si hiciéramos aserciones sobre las llamadas específicas, estaríamos limitando el margen de actuación de nuestras implementaciones.
Veamos como usar una librería como la de
moq
:a. Primero instalamos la librería:
go get github.com/matryer/moq
b. Después la ejecutamos pasándole la interfaz a mockear y el path del fichero resultante dónde se guardaran los mocks generados:
moq -out http_client_mocks.go . HttpClient
c. Y finalmente inicializamos nuestro “mock":
c := &HttpClientMock{ DoFunc: func(req *http.Request) (response *http.Response, e error) { fakeData := FakeData() dataBuffer := bytes.NewBuffer(fakeData) response := http.Response{ Request: req, StatusCode: http.StatusOK, Body: ioutil.NopCloser(dataBuffer), } return &response, nil }, }
Además de éstas tres, también hay otras opciones muy similares a éstas, cómo por ejemplo el GoMock.
Sin embargo, cómo hemos podido ver, muchas veces resulta más práctico implementar nuestros propios fakes/stubs o usar una
librería específica como httptest
que no recurrir .
Inyectando los fakes/stubs en nuestros benchmarks:
Finalmente, podríamos modificar el código que veíamos en el anterior artículo, por algo similar a ésto:
func BenchmarkSynchronize(b *testing.B) {
// Assuming c variable has one of the previously seen possible values
i := NewIntegrator(c)
b.ResetTimer()
for n := 0; n < b.N; n++ {
i.Synchronize()
}
}
Y el profiling pa’cuándo?
Ésta vez sí, ahora que nuestros tests son deterministas, ya estamos listos para empezar a investigar qué partes de nuestra implementación están suponiendo un problema en términos de rendimiento. Sin embargo, ésto es lo que veremos en el próximo capítulo de la serie “Analizando el rendimiento de tus aplicaciones Go”, de momento nos podéis ir dejando vuestro feedback, nos interesa mucho vuestra opinión para saber qué os está pareciendo la serie y sí os gustaría que hubieran más.
Ideas, dudas, preguntas y sugerencias, cómo siempre: en Twitter a través de nuestra cuenta @FriendsofGOTech.