Prueba de código intensivo de datos con Go, Parte 1

Visión general

Muchos sistemas no triviales también son intensivos en datos o impulsados ​​por datos. Probar las partes de los sistemas que hacen un uso intensivo de datos es muy diferente a la prueba de los sistemas de código intensivo. Primero, puede haber mucha sofisticación en la propia capa de datos, como almacenes de datos híbridos, almacenamiento en caché, copias de seguridad y redundancia..

Toda esta maquinaria no tiene nada que ver con la aplicación en sí, sino que tiene que ser probada. En segundo lugar, el código puede ser muy genérico, y para probarlo, necesita generar datos que estén estructurados de cierta manera. En esta serie de cinco tutoriales, abordaré todos estos aspectos, exploraré varias estrategias para diseñar sistemas con capacidad de datos comprobables con Go, y analizaré ejemplos específicos. 

En la primera parte, repasaré el diseño de una capa de datos abstractos que permite realizar pruebas adecuadas, cómo realizar el manejo de errores en la capa de datos, cómo simular un código de acceso a los datos y cómo realizar una prueba con una capa de datos abstracta. 

Pruebas contra una capa de datos

Tratar con los almacenes de datos reales y sus complejidades es complicado y no está relacionado con la lógica empresarial. El concepto de una capa de datos le permite exponer una interfaz nítida a sus datos y ocultar los detalles sangrientos de cómo se almacenan los datos exactamente y cómo acceder a ellos. Usaré una aplicación de ejemplo llamada "Songify" para la gestión de música personal para ilustrar los conceptos con código real.

Diseñar una capa de datos abstracta

Revisemos el dominio de administración de música personal; los usuarios pueden agregar canciones y etiquetarlas, y considerar qué datos necesitamos almacenar y cómo acceder a ellos. Los objetos en nuestro dominio son usuarios, canciones y etiquetas. Hay dos categorías de operaciones que desea realizar en cualquier dato: consultas (solo lectura) y cambios de estado (crear, actualizar, eliminar). Aquí hay una interfaz básica para la capa de datos:

paquete abstract_data_layer importar "tiempo" tipo Song struct Url string Name string Descripción string type Label struct Name string type User struct Name string Correo electrónico RegisteredAt time.Time LastLogin time.Time type DataLayer interface // Queries (leer -solo) GetUsers () ([] Usuario, error) GetUserByEmail (cadena de correo electrónico) (Usuario, error) GetLabels () ([] Etiqueta, error) GetSongs () ([] Song, error) GetSongsByUser (usuario usuario) ([ ] Canción, error) GetSongsByLabel (cadena de etiqueta) ([] Canción, error) // Operaciones de cambio de estado CreateUser (usuario Usuario) error ChangeUserName (usuario Usuario, cadena de nombre) error AddLabel (cadena de etiqueta) error AddSong (usuario Usuario, canción Canción , etiquetas [] Etiqueta) error 

Tenga en cuenta que el propósito de este modelo de dominio es presentar una capa de datos simple pero no completamente trivial para demostrar los aspectos de prueba. Obviamente, en una aplicación real habrá más objetos como álbumes, géneros, artistas y mucha más información sobre cada canción. Si se trata de un empujón, siempre puede almacenar información arbitraria sobre una canción en su descripción, así como adjuntar tantas etiquetas como desee..

En la práctica, es posible que desee dividir su capa de datos en múltiples interfaces. Algunas de las estructuras pueden tener más atributos y los métodos pueden requerir más argumentos (por ejemplo, todos los GetXXX ()los métodos probablemente requerirán algunos argumentos de paginación). Es posible que necesite otras interfaces de acceso a datos y métodos para operaciones de mantenimiento como carga masiva, copias de seguridad y migraciones. A veces tiene sentido exponer una interfaz de acceso a datos asíncrona en lugar o además de la interfaz síncrona.

¿Qué ganamos con esta capa de datos abstractos??

  • Una ventanilla única para las operaciones de acceso de datos.
  • Visión clara de los requisitos de gestión de datos de nuestras aplicaciones en términos de dominio..
  • Capacidad para cambiar la implementación de la capa de datos concreta a voluntad..
  • Capacidad para desarrollar la capa de lógica de dominio / negocio en la interfaz antes de que la capa de datos concreta esté completa o estable.
  • Por último, pero no menos importante, la capacidad de burlarse de la capa de datos para realizar pruebas rápidas y flexibles de la lógica de dominio / negocio.

Errores y manejo de errores en la capa de datos

Los datos pueden almacenarse en múltiples almacenes de datos distribuidos, en múltiples agrupaciones en diferentes ubicaciones geográficas en una combinación de centros de datos locales y la nube. 

Habrá fallas, y esas fallas deben ser manejadas. Idealmente, la lógica de manejo de errores (reintentos, tiempos de espera, notificación de fallas catastróficas) puede ser manejada por la capa de datos concretos. El código lógico del dominio debería recuperar los datos o un error genérico cuando no se puede acceder a los datos.. 

En algunos casos, la lógica del dominio puede querer un acceso más granular a los datos y seleccionar una estrategia de reserva en ciertas situaciones (por ejemplo, solo hay datos parciales disponibles porque parte del clúster es inaccesible o los datos son obsoletos porque la memoria caché no se actualizó) ). Esos aspectos tienen implicaciones para el diseño de su capa de datos y para su prueba. 

En lo que respecta a las pruebas, debe devolver sus propios errores definidos en la capa de datos abstractos y asignar todos los mensajes de error concretos a sus propios tipos de error o confiar en mensajes de error muy genéricos..   

Código de acceso a datos de burla

Vamos a burlarnos de nuestra capa de datos. El propósito del simulacro es reemplazar la capa de datos reales durante las pruebas. Eso requiere que la capa de datos simulados exponga la misma interfaz y sea capaz de responder a cada secuencia de métodos con una respuesta enlatada (o calculada). 

Además, es útil hacer un seguimiento de cuántas veces se llamó a cada método. No lo demostraré aquí, pero incluso es posible hacer un seguimiento del orden de las llamadas a diferentes métodos y qué argumentos se pasaron a cada método para garantizar una determinada cadena de llamadas.. 

Aquí está la estructura de la capa de datos simulados.

importación paquete concrete_data_layer ( "abstract_data_layer") const (GET_USERS = iota ERRORES GET_USER_BY_EMAIL GET_LABELS GET_SONGS GET_SONGS_BY_USER GET_SONG_BY_LABEL) tipo MockDataLayer struct Errores [] GetUsersResponses de error [] [] GetUserByEmailResponses usuario [] GetLabelsResponses usuario [] [] GetSongsResponses etiqueta [] [] Song GetSongsByUserResponses [] [] Song GetSongsByLabelResponses [] [] Song Indices [] int func NewMockDataLayer () MockDataLayer return MockDataLayer Indices: [] int 0, 0, 0, 0, 0, 0, 0, 0, 0, 0  

los const La declaración enumera todas las operaciones compatibles y los errores. Cada operación tiene su propio índice en el Índices rebanada. El índice para cada operación representa cuántas veces se llamó al método correspondiente y cuál debería ser la próxima respuesta y error.. 

Para cada método que tiene un valor de retorno además de un error, hay una porción de respuestas. Cuando se llama al método simulado, se devuelven la respuesta y el error correspondientes (según el índice para este método). Para los métodos que no tienen un valor de retorno excepto un error, no es necesario definir un XXXrespuestas rebanada. 

Tenga en cuenta que los errores son compartidos por todos los métodos. Eso significa que si desea probar una secuencia de llamadas, deberá inyectar el número correcto de errores en el orden correcto. Un diseño alternativo usaría para cada respuesta un par que consiste en el valor de retorno y el error. los NewMockDataLayer () La función devuelve una nueva estructura de capa de datos simulados con todos los índices inicializados a cero.

Aquí está la implementación de la GetUsers () Método, que ilustra estos conceptos.. 

func (m * MockDataLayer) GetUsers () (usuarios [] Usuario, error de error) i: = m.Indices [GET_USERS] users = m.GetUsersResponses [i] if len (m.Errors)> 0 err = m. Errores [m.Indices [ERRORES]] m.Indices [ERRORES] ++ m.Indices [GET_USERS] ++ return 

La primera línea obtiene el índice actual de GET_USERS operación (será 0 inicialmente). 

La segunda línea obtiene la respuesta para el índice actual.. 

Las líneas tercera a quinta asignan el error del índice actual si el Los errores campo fue poblado e incrementar el índice de errores. Al probar el camino feliz, el error será nulo. Para que sea más fácil de usar, simplemente puede evitar inicializar Los errores campo y luego cada método devolverá cero para el error.

La siguiente línea incrementa el índice, por lo que la próxima llamada obtendrá la respuesta correcta.

La última línea simplemente regresa. Los valores de retorno nombrados para los usuarios y err ya están rellenados (o nil por defecto para err).

Aquí hay otro método., GetLabels (), que sigue el mismo patrón. La única diferencia es qué índice se usa y qué colección de respuestas enlatadas se usa.

func (m * MockDataLayer) GetLabels () (labels [] Label, err error) i: = m.Indices [GET_LABELS] labels = m.GetLabelsResponses [i] if len (m.Errors)> 0 err = m. Errores [m.Indices [ERRORES]] m.Indices [ERRORES] ++ m.Indices [GET_LABELS] ++ return 

Este es un excelente ejemplo de un caso de uso en el que los genéricos podrían ahorrar mucho de código repetitivo. Es posible aprovechar la reflexión para el mismo efecto, pero está fuera del alcance de este tutorial. La principal conclusión aquí es que la capa de datos simulados puede seguir un patrón de propósito general y admitir cualquier escenario de prueba, como verá pronto.

¿Qué hay de algunos métodos que simplemente devuelven un error? Revisar la Crear usuario() método. Es incluso más sencillo porque solo se ocupa de los errores y no necesita administrar las respuestas enlatadas.

func (m * MockDataLayer) CreateUser (usuario usuario) (error err) if len (m.Errors)> 0 i: = m.Indices [CREATE_USER] err = m.Errors [m.Indices [ERRORS]] m. Índices [ERRORES] ++ volver 

Esta capa de datos simulados es solo un ejemplo de lo que se necesita para simular una interfaz y proporcionar algunos servicios útiles para probar. Puedes crear tu propia implementación simulada o usar las bibliotecas simuladas disponibles. Incluso hay un marco estándar de GoMock. 

Personalmente, creo que los marcos simulados son fáciles de implementar y prefiero rodar los míos (a menudo generándolos automáticamente) porque paso la mayor parte de mi tiempo de desarrollo escribiendo pruebas y burlándome de dependencias. YMMV.

Pruebas contra una capa de datos abstracta

Ahora que tenemos una capa de datos simulada, vamos a escribir algunas pruebas contra ella. Es importante darse cuenta de que aquí no probamos la capa de datos en sí. Probaremos la capa de datos con otros métodos más adelante en esta serie. El propósito aquí es probar la lógica del código que depende de la capa de datos abstracta.

Por ejemplo, supongamos que un usuario desea agregar una canción, pero tenemos una cuota de 100 canciones por usuario. El comportamiento esperado es que si el usuario tiene menos de 100 canciones y la canción agregada es nueva, se agregará. Si la canción ya existe, devuelve un error de "Duplicar canción". Si el usuario ya tiene 100 canciones, devuelve el error "Cuota de canción excedida".   

Vamos a escribir una prueba para estos casos de prueba utilizando nuestra capa de datos simulados. Esta es una prueba de caja blanca, lo que significa que necesita saber a qué métodos de la capa de datos llamará el código bajo prueba y en qué orden para poder rellenar las respuestas simuladas y los errores correctamente. Así que el enfoque de prueba primero no es ideal aquí. Vamos a escribir el código primero. 

Aquí está el SongManager estructura Depende solo de la capa de datos abstracta. Eso le permitirá pasarle una implementación de una capa de datos reales en producción, pero una capa de datos simulada durante la prueba.

los SongManager En sí mismo es completamente agnóstico a la implementación concreta de la DataLayer interfaz. los SongManager La estructura también acepta un usuario, que almacena. Presumiblemente, cada usuario activo tiene su propio SongManager instancia, y los usuarios solo pueden agregar canciones para ellos mismos. los NuevoSongManager ()función asegura la entrada DataLayer la interfaz no es nula.

paquete song_manager import ("errores". "abstract_data_layer") const (MAX_SONGS_PER_USER = 100) tipo SongManager struct user User dal DataLayer func NewSongManager (user User, dal DataLayer) (* SongManager, error) if dal == nil return nil, errors.New ("DataLayer no puede ser nil") return & SongManager usuario, dal, nil 

Vamos a implementar un AddSong () método. El método llama a la capa de datos GetSongsByUser () Primero, y luego pasa por varios controles. Si todo está bien, llama a la capa de datos AddSong () Método y devuelve el resultado..

func (lm * SongManager) AddSong (newSong Song, labels [] Label) error songs, err: = lm.dal.GetSongsByUser (lm.user) si err! = nil return nil // Comprueba si la canción es un duplicado for _, song: = range songs if song.Url == newSong.Url return errors.New ("Duplicate song") // Compruebe si el usuario tiene el número máximo de canciones si len (songs) == MAX_SONGS_PER_USER  devuelve errores. Nuevo ("Cuota de canción excedida") devuelve lm.dal.AddSong (usuario, newSong, etiquetas) 

Al observar este código, puede ver que hay otros dos casos de prueba que descuidamos: las llamadas a los métodos de la capa de datos GetSongByUser () y AddSong () Podría fallar por otras razones. Ahora, con la implementación de SongManager.AddSong () delante de nosotros, podemos escribir una prueba completa que cubra todos los casos de uso. Empecemos por el camino feliz. los TestAddSong_Success () método crea un usuario llamado Gigi y una capa de datos simulada.

Puebla el GetSongsByUserResponses campo con un sector que contiene un sector vacío, que se traducirá en un sector vacío cuando el SongManager llama GetSongsByUser () en la capa de datos simulados sin error. No hay necesidad de hacer nada por la llamada a la capa de datos simulados AddSong () Método, que devolverá un error nulo por defecto. La prueba solo verifica que, de hecho, no se devolvió ningún error de la llamada principal al SongManager AddSong () método.   

paquete song_manager import ("testing". "abstract_data_layer". "concrete_data_layer") func TestAddSong_Success (t * testing.T) u: = Usuario Nombre: "Gigi", Correo electrónico: "[email protected]" simulacro: = NewMockDataLayer () // Preparar respuestas simuladas mock.GetSongsByUserResponses = [] [] Song  lm, err: = NewSongManager (u, & mock) si err! = Nil t.Error ("NewSongManager () devolvió 'nil' ") url: = https://www.youtube.com/watch?v=MlW7T0SUH0E" err = lm.AddSong (Song Url: url ", Nombre:" Chacarron ", nil) if err! = nil  t.Error ("AddSong () failed") $ go test PASS ok song_manager 0.006s 

Probando las condiciones de error es súper fácil también. Usted tiene control total sobre lo que la capa de datos devuelve de las llamadas a GetSongsByUser () y AddSong (). Aquí hay una prueba para verificar que al agregar una canción duplicada, reciba el mensaje de error correcto..

func TestAddSong_Duplicate (t * testing.T) u: = User Nombre: "Gigi", Correo electrónico: "[email protected]" simulacro: = NewMockDataLayer () // Preparar respuestas simuladas mock.GetSongsByUserResponses = [] [] Song testSong lm, err: = NewSongManager (u, & simulacro) si err! = Nil t.Error ("NewSongManager () devolvió 'nil'") err = lm.AddSong (testSong, nil) si err == nil t.Error ("AddSong () debería haber fallado") si err.Error ()! = "Canción duplicada" t.Error ("AddSong () error erróneo:" + err.Error ())  

Los dos casos de prueba siguientes prueban que se devuelve el mensaje de error correcto cuando falla la capa de datos. En el primer caso la capa de datos. GetSongsByUser () devuelve un error.

func TestAddSong_DataLayerFailure_1 (t * testing.T) u: = User Nombre: "Gigi", Correo electrónico: "[email protected]" mock: = NewMockDataLayer () // Prepare mock.GetSongsByUserResponses = [] [] Canción  e: = errores.Nuevo ("Error GetSongsByUser ()") mock.Errors = [] error e lm, err: = NewSongManager (u, & mock) si err! = Nil t.Error ( "NewSongManager () devolvió 'nil'") err = lm.AddSong (testSong, nil) si err == nil t.Error ("AddSong () debería haber fallado") si err.Error ()! = " Error de GetSongsByUser () "t.Error (" Error erróneo de AddSong (): "+ err.Error ()) 

En el segundo caso, la capa de datos. AddSong () método devuelve un error. Desde la primera llamada a GetSongsByUser () debería tener éxito, el simulacro. slice contiene dos elementos: nil para la primera llamada y el error para la segunda llamada. 

func TestAddSong_DataLayerFailure_2 (t * testing.T) u: = User Nombre: "Gigi", Correo electrónico: "[email protected]" mock: = NewMockDataLayer () // Preparar respuestas simuladas mock.GetSongsByUserResponses = [] [] Canción  e: = errores.Nuevo ("Error de AddSong ()") simulacro.Errores = [] error nil, e lm, err: = NewSongManager (u, & mock) si err! = Nil t. Error ("NewSongManager () devolvió 'nil'") err = lm.AddSong (testSong, nil) si err == nil t.Error ("AddSong () debería haber fallado") si err.Error ()! = "Error de AddSong ()" t.Error ("Error de AddSong () erróneo:" + err.Error ())

Conclusión

En este tutorial, presentamos el concepto de una capa de datos abstracta. Luego, utilizando el dominio de administración de música personal, demostramos cómo diseñar una capa de datos, crear una capa de datos simulada y usar la capa de datos simulada para probar la aplicación.. 

En la segunda parte, nos centraremos en las pruebas utilizando una capa de datos en memoria real. Manténganse al tanto.