
Dans cet article, je parlerai de la façon dont j'ai écrit un programme de console en langage Go pour télécharger des données de la base de données vers des fichiers, en essayant de couvrir tout le code avec des tests à 100%. Je vais commencer par une description des raisons pour lesquelles j'avais besoin de ce programme. Je continuerai à décrire les premières difficultés, dont certaines sont causées par les fonctionnalités du langage Go. Ensuite, je mentionnerai un petit build sur Travis CI, puis je parlerai de la façon dont j'ai écrit les tests, en essayant de couvrir le code à 100%. Je vais aborder un peu le test du travail avec la base de données et le système de fichiers. En conclusion, je dirai à quoi mène le désir de couvrir le code avec des tests et ce que dit cet indicateur. Je fournirai du matériel avec des liens vers la documentation et des exemples de commits de mon projet.
Objectif du programme
Le programme doit être lancé à partir de la ligne de commande avec une indication de la liste des tables et de certaines de leurs colonnes, une plage de données sur la première colonne spécifiée, une énumération des relations des tables sélectionnées entre elles, avec la possibilité de spécifier un fichier avec les paramètres de connexion à la base de données. Le résultat du travail doit être un fichier qui décrit les demandes de création des tables spécifiées avec les colonnes spécifiées et les expressions d'insertion des données sélectionnées. Il a été supposé que l'utilisation d'un tel programme simplifierait le scénario d'extraction d'une partie des données d'une grande base de données et de déploiement de cette partie localement. De plus, ces fichiers sql de déchargement étaient censés être traités par un autre programme, qui remplace une partie des données selon un modèle spécifique.
Le même résultat peut être obtenu en utilisant l'un des clients populaires de la base de données et une quantité suffisante de travail manuel. L'application était censée simplifier ce processus et automatiser autant que possible.
Ce programme aurait dû être développé par mes stagiaires à des fins de formation et d'utilisation ultérieure dans leur formation continue. Mais la situation s'est avérée telle qu'ils ont refusé cette idée. Mais j'ai décidé d'essayer d'écrire un tel programme dans mon temps libre dans le but de ma pratique de développement dans la langue Go.
La solution est incomplète; elle présente un certain nombre de limitations, décrites dans le fichier README. En tout cas, ce n'est pas un projet de combat.
Exemples d'utilisation et code source .
Premières difficultés
La liste des tables et de leurs colonnes est transmise au programme sous forme d'argument sous la forme d'une chaîne, c'est-à-dire qu'elle n'est pas connue à l'avance. La plupart des exemples de travail avec une base de données sur Go impliquaient que la structure de la base de données est connue à l'avance, nous créons simplement une struct
indiquant les types de chaque colonne. Mais dans ce cas, cela ne fonctionne pas de cette façon.
La solution à cela était d'utiliser la méthode MapScan
de github.com/jmoiron/sqlx
, qui a créé une tranche d'interface de taille égale au nombre de colonnes d'échantillons. La question suivante était de savoir comment obtenir un véritable type de données à partir de ces interfaces. La solution est un boîtier de commutation par type . Une telle solution n'est pas très belle, car tous les types devront être convertis en chaîne: les entiers tels quels, les chaînes à échapper et placées entre guillemets, mais en même temps pour décrire tous les types pouvant provenir de la base de données. Je n'ai pas trouvé de solution plus élégante pour résoudre ce problème.
Avec les types, une fonctionnalité de langage Go a également été manifestée - une variable de type chaîne ne peut pas prendre la valeur nil
, mais une chaîne vide et NULL
peuvent provenir de la base de données. Pour résoudre ce problème, il existe une solution dans le package database/sql
- utilisez une strut
spéciale, qui stocke la valeur et le signe, qu'il soit NULL
ou non.
Assemblage et calcul du pourcentage de couverture du code par des tests
Pour l'assemblage, j'utilise Travis CI, pour obtenir le pourcentage de couverture de code avec des tests - Combinaisons. Le fichier .travis.yml
de l'assemblage est assez simple:
language: go go: - 1.9 script: - go get -t -v ./... - go get golang.org/x/tools/cmd/cover - go get github.com/mattn/goveralls - go test -v -covermode=count -coverprofile=coverage.out ./... - $HOME/gopath/bin/goveralls -coverprofile=coverage.out -service=travis-ci -repotoken $COVERALLS_TOKEN
Dans les paramètres de Travis CI, il vous suffit de spécifier la variable d'environnement COVERALLS_TOKEN
, dont la valeur doit être prise sur le site .
Les combinaisons vous permettent de découvrir facilement quel pourcentage de l'ensemble du projet, pour chaque fichier, mettez en surbrillance une ligne de code source qui s'est avérée être un test découvert. Par exemple, dans la première version, il est clair que je n'ai pas écrit de tests pour certains cas d'erreurs lors de l'analyse d'une demande utilisateur.
La couverture à 100% du code signifie que des tests sont écrits qui, entre autres, exécutent le code pour chaque branche dans if
. C'est le travail le plus volumineux lors de l'écriture de tests et, en général, lors du développement d'une application.
Vous pouvez calculer la couverture avec des tests localement, par exemple, avec le même go test -v -covermode=count -coverprofile=coverage.out ./...
, mais vous pouvez le faire plus bien dans CI, vous pouvez placer une plaque sur Github.
Puisque nous parlons de dés, je trouve les dés de https://goreportcard.com utiles, qui analysent les indicateurs suivants:
- gofmt - formatage de code, y compris la simplification des constructions
- go_vet - vérifie les constructions suspectes
- gocyclo - montre des problèmes de complexité cyclomatique
- golint - pour moi c'est de vérifier la disponibilité de tous les commentaires nécessaires
- licence - le projet doit avoir une licence
- ineffassign - vérifie les affectations inefficaces
- faute d'orthographe - vérifie les fautes de frappe
Difficultés à couvrir le code avec des tests à 100%
Si l'analyse d'une petite demande utilisateur de composants fonctionne principalement avec la conversion de chaînes en certaines structures à partir de chaînes et est assez facilement couverte par des tests, alors pour tester du code qui fonctionne avec une base de données, la solution n'est pas si évidente.
Vous pouvez également vous connecter à un véritable serveur de base de données, pré-remplir les données de chaque test, effectuer des sélections et effacer. Mais c'est une solution difficile, loin des tests unitaires et qui impose ses exigences à l'environnement, y compris au serveur CI.
Une autre option pourrait être d'utiliser une base de données en mémoire, par exemple, sqlite ( sqlx.Open("sqlite3", ":memory:")
), mais cela implique que le code devrait être aussi faiblement lié au moteur de base de données que possible, ce qui complique grandement le projet mais pour le test d'intégration est assez bon.
Pour les tests unitaires, l'utilisation de maquette pour la base de données convient. J'ai trouvé celui-ci . À l'aide de ce package, vous pouvez tester le comportement à la fois dans le cas d'un résultat normal et en cas d'erreurs, en indiquant quelle demande doit retourner quelle erreur.
L'écriture de tests a montré que la fonction qui se connecte à la base de données réelle doit être déplacée vers main.go, donc elle peut être redéfinie dans les tests pour celle qui retournera l'instance fictive.
En plus de travailler avec la base de données, il est nécessaire de faire du travail avec le système de fichiers une dépendance distincte. Cela permettra de remplacer l'enregistrement de fichiers réels par l'écriture dans la mémoire pour faciliter les tests et réduire le couplage. C'est ainsi que l'interface FileWriter
est apparue, et avec elle l'interface du fichier qu'elle renvoie. Pour tester les scénarios d'erreur, des implémentations auxiliaires de ces interfaces ont été créées et placées dans le fichier filewriter_test.go
, afin qu'elles ne tombent pas dans la version générale, mais peuvent être utilisées dans les tests.
Après un certain temps, j'avais une question sur la façon de couvrir main()
tests. À ce moment-là, j'avais assez de code là-bas. Comme les résultats de recherche l'ont montré, cela ne se fait pas dans Go . Au lieu de cela, tout le code qui peut être extrait de main()
doit être extrait. Dans mon code, je n'ai laissé que des options d'analyse et des arguments de ligne de commande (package d' flag
), la connexion à la base de données, l'instanciation d'un objet qui va écrire des fichiers et l'appel d'une méthode qui fera le reste du travail. Mais ces lignes ne vous permettent pas d'obtenir une couverture exacte à 100%.
Dans le test de Go, il existe des « fonctions d'exemple ». Ce sont des fonctions de test qui comparent la sortie avec ce qui est décrit dans le commentaire à l'intérieur d'une telle fonction. Des exemples de ces tests peuvent être trouvés dans le code source des packages go . Si ces fichiers ne contiennent pas de tests et de benchmarks, ils sont nommés avec le préfixe example_
et se terminent par _test.go
. Le nom de chacune de ces fonctions de test doit commencer par Example
. À ce sujet, j'ai écrit un test pour un objet qui écrit SQL dans un fichier, en remplaçant l'enregistrement réel dans le fichier par une maquette, à partir de laquelle vous pouvez obtenir le contenu et les afficher. Cette conclusion est comparée à la norme. De manière pratique, vous n'avez pas besoin d'écrire une comparaison avec vos mains, et il est pratique d'écrire quelques lignes dans les commentaires. Mais en ce qui concerne le test d'un objet qui écrit des données dans un fichier csv, des difficultés sont apparues. Selon RFC4180, les lignes en CSV doivent être séparées par CRLF, et go fmt
remplace toutes les lignes par LF, ce qui conduit au fait que la norme du commentaire ne correspond pas à la sortie actuelle en raison de différents séparateurs de ligne. J'ai dû écrire un test régulier pour cet objet, tout en renommant le fichier en supprimant example_
de celui-ci.
La question demeure, si le fichier, par exemple, query.go
testé à l'aide des tests d'exemple et conventionnels, devrait-il y avoir deux fichiers example_query_test.go
et query_test.go
? Ici, par exemple, il n'y a qu'un seul example_test.go
. Utiliser la recherche de "go test example" est toujours amusant.
J'ai appris à écrire des tests dans Go selon les guides que Google donne pour "go writing tests". La plupart de celles que j'ai rencontrées ( 1 , 2 , 3 , 4 ) suggèrent de comparer le résultat avec la conception attendue du formulaire
if v != 1.5 { t.Error("Expected 1.5, got ", v) }
Mais quand il s'agit de comparer des types, une construction familière évolue évolutivement en un tas d'utilisation de "refléter" ou de l'assertion de type. Ou un autre exemple, lorsque vous devez vérifier que la tranche ou la carte a la valeur nécessaire. Le code devient lourd. Je veux donc écrire mes fonctions auxiliaires pour le test. Bien qu'une bonne solution ici consiste à utiliser une bibliothèque pour les tests. J'ai trouvé https://github.com/stretchr/testify . Il vous permet de faire des comparaisons sur une seule ligne . Cette solution réduit la quantité de code et simplifie la lecture et la prise en charge des tests.
Fragmentation et test du code
L'écriture d'un test pour une fonction de haut niveau qui fonctionne avec plusieurs objets vous permet d'augmenter considérablement la valeur de la couverture de code par des tests à la fois, car pendant ce test, de nombreuses lignes de code d'objets individuels sont exécutées. Si vous vous fixez l'objectif d'une couverture à 100% seulement, la motivation pour écrire des tests unitaires sur de petits composants du système disparaît, car cela n'affecte pas la valeur de la couverture du code.
De plus, si vous ne vérifiez pas le résultat dans la fonction de test, cela n'affectera pas non plus la valeur de la couverture du code. Vous pouvez obtenir une valeur de couverture élevée, mais vous ne pouvez pas détecter d’erreurs graves dans l’application.
En revanche, si vous avez du code avec de nombreuses branches , après quoi une fonction volumineuse est appelée, il sera difficile de la couvrir avec des tests. Et ici, vous avez une incitation à améliorer ce code, par exemple, pour prendre toutes les branches dans une fonction distincte et écrire un test distinct dessus. Cela affectera positivement la lisibilité du code.
Si le code a un couplage fort, alors vous ne pourrez probablement pas écrire de test dessus, ce qui signifie que vous devrez y apporter des modifications, ce qui affectera positivement la qualité du code.
Conclusion
Avant ce projet, je n'avais pas à fixer d'objectif de couverture à 100% du code avec des tests. J'ai pu obtenir une application fonctionnelle en 10 heures de développement, mais il m'a fallu 20 à 30 heures pour atteindre une couverture de 95%. À l'aide d'un petit exemple, j'ai eu une idée de la façon dont la valeur de la couverture du code affecte sa qualité et des efforts nécessaires pour la maintenir.
Ma conclusion est que si vous voyez un tableau de bord avec une valeur de couverture de code élevée pour quelqu'un, il ne dit presque rien sur la façon dont cette application a été testée. Quoi qu'il en soit, vous devez regarder les tests eux-mêmes. Mais si vous avez vous-même fixé un cap pour un honnête 100%, cela vous aidera à mieux écrire une application.
Vous pouvez en savoir plus à ce sujet dans les documents et commentaires suivants:
SpoilerLe mot «revêtement» est utilisé environ 20 fois. Désolé.