De nombreux systèmes non triviaux utilisent également beaucoup de données. Tester les composants des systèmes à forte intensité de données est très différent de celui des systèmes à forte intensité de code. Premièrement, il peut y avoir beaucoup de sophistication dans la couche de données elle-même, telle que des magasins de données hybrides, la mise en cache, la sauvegarde et la redondance..
Toutes ces machines n'ont rien à voir avec l'application elle-même, mais doivent être testées. Deuxièmement, le code peut être très générique et pour le tester, vous devez générer des données structurées d'une certaine manière. Dans cette série de cinq tutoriels, je vais aborder tous ces aspects, explorer plusieurs stratégies de conception de systèmes à forte intensité de données testables avec Go, et plonger dans des exemples spécifiques..
Dans la première partie, je vais passer en revue la conception d'une couche de données abstraites qui permet de tester correctement, comment traiter les erreurs dans la couche de données, comment simuler le code d'accès aux données et comment effectuer des tests par rapport à une couche de données abstraite.
Le traitement de véritables magasins de données et de leurs subtilités est compliqué et indépendant de la logique métier. Le concept de couche de données vous permet d’exposer une interface soignée à vos données et de masquer les détails sanglants de la manière exacte dont les données sont stockées et de la manière d’y accéder. Je vais utiliser un exemple d'application appelé "Songify" pour la gestion de musique personnelle afin d'illustrer les concepts avec du code réel..
Passons en revue le domaine de gestion de musique personnelle - les utilisateurs peuvent ajouter des chansons et les étiqueter - et déterminer quelles données nous devons stocker et comment y accéder. Les objets de notre domaine sont des utilisateurs, des chansons et des labels. Il existe deux catégories d'opérations que vous souhaitez effectuer sur toutes les données: les requêtes (en lecture seule) et les modifications d'état (création, mise à jour, suppression). Voici une interface de base pour la couche de données:
package abstract_data_layer import "time" type Structure du morceau Chaîne d'URL Chaîne de description Description chaîne type Libellé de structure Nom chaîne type User struct Nom chaîne Email Chaîne RegisteredAt time.Time LastLogin time.Time type Interface DataLayer // Requêtes (lues) -only) GetUsers () ([] utilisateur, erreur) GetUserByEmail (chaîne de messagerie électronique) (utilisateur, erreur) GetLabels () ([] étiquette, erreur) GetSongs () ([] chanson, erreur) GetSongsByUser (utilisateur) (utilisateur) ] Morceau, erreur) GetSongsByLabel (chaîne de caractères) ([] Morceau, erreur) // Opérations de changement d’état CreateUser (utilisateur Utilisateur) erreur ChangeUserName (utilisateur, nom chaîne) erreur AddLabel (chaîne de caractères) erreur AddSong (utilisateur, chanson , labels [] Label) erreur
Notez que le but de ce modèle de domaine est de présenter une couche de données simple mais pas complètement triviale pour illustrer les aspects de test. Évidemment, dans une application réelle, il y aura plus d'objets comme des albums, des genres, des artistes et beaucoup plus d'informations sur chaque chanson. Si le besoin s'en fait sentir, vous pouvez toujours stocker des informations arbitraires sur une chanson dans sa description, ainsi que joindre autant d'étiquettes que vous le souhaitez..
En pratique, vous souhaiterez peut-être diviser votre couche de données en plusieurs interfaces. Certaines structures peuvent avoir plus d’attributs et les méthodes peuvent nécessiter plus d’arguments (par exemple tous les GetXXX ()
méthodes nécessiteront probablement des arguments de pagination). Vous aurez peut-être besoin d'autres interfaces et méthodes d'accès aux données pour les opérations de maintenance, telles que le chargement en bloc, les sauvegardes et les migrations. Il est parfois utile d'exposer une interface d'accès aux données asynchrone à la place ou en plus de l'interface synchrone..
Qu'avons-nous gagné de cette couche de données abstraite?
Les données peuvent être stockées dans plusieurs magasins de données distribués, sur plusieurs clusters situés dans différents emplacements géographiques, dans une combinaison de centres de données sur site et dans le cloud..
Il y aura des échecs, et ces échecs doivent être traités. Idéalement, la couche de données concrète peut gérer la logique de traitement des erreurs (tentatives, délais d'expiration, notification des défaillances catastrophiques). Le code logique du domaine devrait juste récupérer les données ou une erreur générique lorsque les données sont inaccessibles.
Dans certains cas, la logique de domaine peut souhaiter un accès plus granulaire aux données et sélectionner une stratégie de secours dans certaines situations (par exemple, seules des données partielles sont disponibles car une partie du cluster est inaccessible ou les données sont obsolètes car le cache n'a pas été actualisé. ). Ces aspects ont des implications pour la conception de votre couche de données et pour ses tests.
En ce qui concerne les tests, vous devez renvoyer vos propres erreurs définies dans la couche de données abstraites et mapper tous les messages d'erreur concrets avec vos propres types d'erreur ou vous fier à des messages d'erreur très génériques..
Moquons notre couche de données. Le but de la maquette est de remplacer la couche de données réelle pendant les tests. Cela nécessite que la couche de données fictive expose la même interface et puisse répondre à chaque séquence de méthodes avec une réponse prédéfinie (ou calculée).
De plus, il est utile de savoir combien de fois chaque méthode a été appelée. Je ne le démontrerai pas ici, mais il est même possible de suivre l'ordre des appels à différentes méthodes et quels arguments ont été passés à chaque méthode pour assurer une certaine chaîne d'appels.
Voici la structure de la couche de données fictive.
berceau de charles Morceau GetSongsByUserResponses [] [] Morceau GetSongsByLabelResponses [] [] Index de morceaux [] int fun NewMockDataLayer () MockDataLayer return MockDataLayer Indices: [] int 0, 0, 0, 0, 0, 0, 0, 0, 0
le const
instruction liste toutes les opérations prises en charge et les erreurs. Chaque opération a son propre index dans le Indices
tranche. L'index de chaque opération représente le nombre de fois que la méthode correspondante a été appelée, ainsi que la prochaine réponse et l'erreur suivantes..
Pour chaque méthode qui a une valeur de retour en plus d'une erreur, il existe une tranche de réponses. Lorsque la méthode fictive est appelée, la réponse et l'erreur correspondantes (basées sur l'index de cette méthode) sont renvoyées. Pour les méthodes qui n’ont pas de valeur de retour sauf une erreur, il n’est pas nécessaire de définir un paramètre. XXXResponses
tranche.
Notez que les erreurs sont partagées par toutes les méthodes. Cela signifie que si vous souhaitez tester une séquence d'appels, vous devez injecter le nombre correct d'erreurs dans le bon ordre. Une conception alternative utiliserait pour chaque réponse une paire composée de la valeur renvoyée et de l'erreur. le NewMockDataLayer ()
la fonction retourne une nouvelle structure de couche de données fictive avec tous les index initialisés à zéro.
Voici la mise en œuvre de la GetUsers ()
méthode, qui illustre ces concepts.
func (m * MockDataLayer) GetUsers () (utilisateurs (], utilisateur, erreur) i: = m.Indices [GET_USERS] utilisateurs = m.GetUsersResponses [i] si len (m.Errors)> 0 err = m. Erreurs [m.Indices [ERRORS]] m.Indices [ERRORS] ++ m.Indices [GET_USERS] ++ return
La première ligne obtient l’index actuel du GET_USERS
opération (sera 0 initialement).
La deuxième ligne obtient la réponse pour l'index actuel.
Les troisième à cinquième lignes attribuent l’erreur de l’index actuel si le les erreurs
champ a été rempli et incrémenter l'index des erreurs. Lors du test du chemin heureux, l'erreur sera nulle. Pour faciliter son utilisation, vous pouvez simplement éviter d’initialiser le les erreurs
champ et chaque méthode retournera nil pour l'erreur.
La ligne suivante incrémente l'index, de sorte que le prochain appel obtiendra la réponse appropriée..
La dernière ligne revient juste. Les valeurs de retour nommées pour les utilisateurs et err sont déjà renseignées (ou nil par défaut pour err).
Voici une autre méthode, GetLabels ()
, qui suit le même schéma. La seule différence est quel index est utilisé et quelle collection de réponses prédéfinies est utilisée.
func (m * MockDataLayer) GetLabels () (labels [] Label, erreur d'erreur) i: = m.Indices [GET_LABELS] labels = m.GetLabelsResponses [i] si len (m.Errors)> 0 err = m. Erreurs [m.Indices [ERRORS]] m.Indices [ERRORS] ++ m.Indices [GET_LABELS] ++ return
C’est un excellent exemple de cas d’utilisation des génériques pour sauver un lot du code standard. Il est possible de tirer parti de la réflexion dans le même sens, mais cela sort du cadre de ce tutoriel. Le principal inconvénient est que la couche de données fictive peut suivre un modèle général et prendre en charge tous les scénarios de test, comme vous le verrez bientôt..
Que diriez-vous de certaines méthodes qui renvoient une erreur? Vérifiez Créer un utilisateur()
méthode. C'est encore plus simple, car il ne traite que les erreurs et n'a pas besoin de gérer les réponses prédéfinies.
func (m * MockDataLayer) CreateUser (utilisateur User) (erreur err) if len (m.Errors)> 0 i: = m.Indices [CREATE_USER] err = m.Errors [m.Indices [ERRORS]] m. Indices [ERREURS] ++ retour
Cette couche de données fictive n'est qu'un exemple de ce qu'il faut pour simuler une interface et fournir des services utiles à tester. Vous pouvez créer votre propre implémentation factice ou utiliser les bibliothèques factices disponibles. Il existe même un framework GoMock standard.
Personnellement, je trouve les simulacres faciles à mettre en œuvre et préfère rouler les miens (en les générant souvent automatiquement) car je passe le plus clair de mon temps de développement à écrire des tests et à me moquer des dépendances. YMMV.
Maintenant que nous avons une couche de données fictive, écrivons quelques tests à ce sujet. Il est important de réaliser qu'ici, nous ne testons pas la couche de données elle-même. Nous testerons la couche de données elle-même avec d'autres méthodes plus tard dans cette série. Le but ici est de tester la logique du code qui dépend de la couche de données abstraite.
Par exemple, supposons qu'un utilisateur souhaite ajouter une chanson, mais nous avons un quota de 100 chansons par utilisateur. Le comportement attendu est que si l'utilisateur a moins de 100 chansons et que la chanson ajoutée est nouvelle, elle sera ajoutée. Si la chanson existe déjà, une erreur "Dupliquer la chanson" est renvoyée. Si l'utilisateur a déjà 100 morceaux, il renvoie l'erreur "Dépassement du quota de morceau"..
Écrivons un test pour ces cas de test en utilisant notre couche de données fictive. Il s'agit d'un test en blanc, ce qui signifie que vous devez savoir quelles méthodes de la couche de données le code à tester va appeler et dans quel ordre pour pouvoir renseigner correctement les réponses fictives et les erreurs. L’approche test-first n’est donc pas idéale ici. Ecrivons le code en premier.
Voici la SongManager
struct. Cela ne dépend que de la couche de données abstraites. Cela vous permettra de passer une implémentation d’une vraie couche de données en production, mais une couche de données fictive lors des tests..
le SongManager
elle-même est complètement agnostique à la mise en œuvre concrète de la Couche de données
interface. le SongManager
struct accepte également un utilisateur, qu'il stocke. Vraisemblablement, chaque utilisateur actif a son propre SongManager
exemple, et les utilisateurs ne peuvent ajouter que des chansons pour eux-mêmes. le NewSongManager ()
fonction assure l'entrée Couche de données
l'interface n'est pas nulle.
package song_manager import ("errors". "abstract_data_layer") const (MAX_SONGS_PER_USER = 100) type SongManager struct user User dal DataLayer func NewSongManager (utilisateur, dal DataLayer) (* SongManager, erreur) si dal == nil retour nil, errors.New ("DataLayer ne peut pas être nil") return & SongManager utilisateur, dal, nil
Implémentons un AddSong ()
méthode. La méthode appelle la couche de données GetSongsByUser ()
d'abord, puis il passe par plusieurs contrôles. Si tout va bien, il appelle la couche de données AddSong ()
méthode et retourne le résultat.
func (lm * SongManager) AddSong (newSong Song, étiquettes [] Label) erreur chansons, err: = lm.dal.GetSongsByUser (lm.user) si err! = nil return nil // Vérifie si la chanson est une copie. pour _, chanson: = plage de chansons si chanson.Url == newSong.Url renvoyer erreurs.Nouveau ("chanson dupliquée") // // vérifie si l'utilisateur a un nombre maximal de chansons si len (chansons) == MAX_SONGS_PER_USER return errors.New ("quota de morceau dépassé") return lm.dal.AddSong (utilisateur, newSong, libellés)
En regardant ce code, vous pouvez voir qu'il y a deux autres cas de test que nous avons négligés: les appels aux méthodes de la couche de données. GetSongByUser ()
et AddSong ()
pourrait échouer pour d'autres raisons. Maintenant, avec la mise en œuvre de SongManager.AddSong ()
devant nous, nous pouvons rédiger un test complet couvrant tous les cas d'utilisation. Commençons par le chemin heureux. le TestAddSong_Success ()
méthode crée un utilisateur nommé Gigi et une couche de données fictive.
Il remplit le GetSongsByUserResponses
champ avec une tranche contenant une tranche vide, ce qui entraînera une tranche vide lorsque le SongManager appelle GetSongsByUser ()
sur la couche de données fictive sans erreur. Il n’est pas nécessaire de faire quoi que ce soit pour l’appel de la couche de données fictive. AddSong ()
méthode, qui retournera zéro erreur par défaut. Le test vérifie simplement qu’en effet aucune erreur n’a été renvoyée de l’appel parent à l’appel de SongManager. AddSong ()
méthode.
package song_manager import ("testing". "abstract_data_layer". "concrete_data_layer") func TestAddSong_Success (t * testing.T) u: = Utilisateur Nom: "Gigi", E-mail: "[email protected]" mock: = NewMockDataLayer () // Préparer des réponses fictives maquette.GetSongsByUserResponses = [] [] Morceau lm, err: = NewSongManager (u, & mock) si err! = Nil t.Error ("NewSongManager () a été renvoyé 'nil' ") url: = https://www.youtube.com/watch?v=MlW7T0SUH0E" err = lm.AddSong (Chanson Url: url ", Nom:" Chacarron ", nil) si err! = nil t.Error ("AddSong () failed") $ go test PASS ok song_manager 0.006s
Tester les conditions d'erreur est également très facile. Vous avez un contrôle total sur ce que la couche de données renvoie des appels à GetSongsByUser ()
et AddSong ()
. Voici un test pour vérifier que lors de l’ajout d’un morceau dupliqué, vous obtenez le message d’erreur approprié.
func TestAddSong_Duplicate (t * testing.T) u: = Utilisateur Nom: "Gigi", E-mail: "[email protected]" mock: = NewMockDataLayer () // Préparer des réponses fictives mock.GetSongsByUserResponses = [] [] Morceau testSong lm, err: = NewSongManager (u, & mock) si err! = Nil t.Error ("NewSongManager () a renvoyé 'nil'") err = lm.AddSong (testSong, nil) si err == nil t.Error ("AddSong () aurait dû échouer") si err.Error ()! = "Chanson en double" t.Error ("Erreur erronée AddSong ():" + err.Error ())
Les deux cas de test suivants vérifient que le message d'erreur correct est renvoyé en cas d'échec de la couche de données. Dans le premier cas, la couche de données GetSongsByUser ()
renvoie une erreur.
func TestAddSong_DataLayerFailure_1 (t * testing.T) u: = Utilisateur Nom: "Gigi", E-mail: "[email protected]" mock: = NewMockDataLayer () // Préparer des réponses fictives mock.GetSongsByUserResponses = [] [] Le morceau e: = errors.New ("échec de GetSongsByUser ()") mock.Errors = [] error e lm, err: = NewSongManager (u, & mock) si err! = Nil t.Error ( "NewSongManager () a renvoyé 'nil'") err = lm.AddSong (testSong, nil) si err == nil t.Error ("AddSong () aurait dû échouer") si err.Error ()! = " GetSongsByUser () échec "t.Error (" Erreur erronée AddSong (): "+ err.Error ())
Dans le second cas, la couche de données AddSong ()
La méthode retourne une erreur. Depuis le premier appel à GetSongsByUser ()
devrait réussir, le mock.Errors
la tranche contient deux éléments: nil pour le premier appel et erreur pour le deuxième appel.
func TestAddSong_DataLayerFailure_2 (t * testing.T) u: = Utilisateur Nom: "Gigi", E-mail: "[email protected]" mock: = NewMockDataLayer () // Préparer des réponses fictives mock.GetSongsByUserResponses = [] [] Morceau e: = errors.New ("échec AddSong ()") mock.Errors = [] erreur nil, e lm, err: = NewSongManager (u, & mock) si err! = Nil t. Error ("NewSongManager () a renvoyé 'nil'") err = lm.AddSong (testSong, nil) si err == nil t.Error ("AddSong () aurait dû échouer") si err.Error ()! = "Echec AddSong ()" t.Error ("Erreur erronée AddSong ():" + err.Error ())
Dans ce tutoriel, nous avons introduit le concept de couche de données abstraite. Ensuite, à l'aide du domaine de gestion de musique personnelle, nous avons montré comment concevoir une couche de données, créer une couche de données fictive et utiliser la couche de données fictive pour tester l'application..
Dans la deuxième partie, nous allons nous concentrer sur les tests utilisant une véritable couche de données en mémoire. Restez à l'écoute.