Cet article va vous expliquer comment utiliser les tests unitaires et les tests d'intégration pour améliorer votre expérience de développement lorsque vous créez des API de repos dans Golang.
Les tests unitaires sont conçus pour vérifier la fonctionnalité des plus petites parties individuelles d'une application, se concentrant souvent sur une seule fonction ou méthode. Ces tests sont effectués indépendamment des autres parties du code pour garantir que chaque composant fonctionne comme prévu seul.
Tests d'intégration, quant à eux, évaluent comment les différents modules ou composants de l'application fonctionnent ensemble. Dans cet article, nous nous concentrerons sur les tests d'intégration de notre application Go, en vérifiant spécifiquement qu'elle interagit correctement avec une base de données PostgreSQL en créant et en exécutant avec succès des requêtes SQL.
Cet article suppose que vous êtes familier avec Golang et comment créer une API de repos dans Golang, l'accent principal sera mis sur la création de tests pour vos routes (tests unitaires) et sur le test de vos fonctions de requête SQL (tests d'intégration) pour référence, visitez le github pour jeter un oeil au projet.
En supposant que vous ayez configuré votre projet similaire à celui lié ci-dessus, vous aurez une structure de dossiers similaire à celle-ci
test_project |__cmd |__api |__api.go |__main.go |__db |___seed.go |__internal |___db |___db.go |___services |___records |___routes_test.go |___routes.go |___store_test.go |___store.go |___user |___routes_test.go |___routes.go |___store_test.go |___store.go |__test_data |__docker-compose.yml |__Dockerfile |__Makefile
Les tests en Golang sont faciles par rapport à d'autres langages que vous avez peut-être rencontrés grâce au package de tests intégré qui fournit les outils nécessaires pour écrire des tests.
Les fichiers de test sont nommés avec _test.go, ce suffixe permet à go de cibler ces fichiers pour l'exécution lors de l'exécution de la commande go test.
Le point d'entrée de notre projet est le fichier main.go situé dans le dossier cmd
// main.go package main import ( "log" "finance-crud-app/cmd/api" "finance-crud-app/internal/db" "github.com/gorilla/mux" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) type Server struct { db *sqlx.DB mux *mux.Router } func NewServer(db *sqlx.DB, mux *mux.Router) *Server { return &Server{ db: db, mux: mux, } } func main() { connStr := "postgres://postgres:Password123@localhost:5432/crud_db?sslmode=disable" dbconn, err := db.NewPGStorage(connStr) if err != nil { log.Fatal(err) } defer dbconn.Close() server := api.NewAPIServer(":8085", dbconn) if err := server.Run(); err != nil { log.Fatal(err) } }
D'après le code que vous pouvez voir, nous créons un nouveau serveur API en transmettant une connexion à la base de données et un numéro de port. Après avoir créé le serveur, nous l'exécutons sur le port indiqué.
La commande NewAPIServer provient du fichier api.go qui
// api.go package api import ( "finance-crud-app/internal/services/records" "finance-crud-app/internal/services/user" "log" "net/http" "github.com/gorilla/mux" "github.com/jmoiron/sqlx" ) type APIServer struct { addr string db *sqlx.DB } func NewAPIServer(addr string, db *sqlx.DB) *APIServer { return &APIServer{ addr: addr, db: db, } } func (s *APIServer) Run() error { router := mux.NewRouter() subrouter := router.PathPrefix("/api/v1").Subrouter() userStore := user.NewStore(s.db) userHandler := user.NewHandler(userStore) userHandler.RegisterRoutes(subrouter) recordsStore := records.NewStore(s.db) recordsHandler := records.NewHandler(recordsStore, userStore) recordsHandler.RegisterRoutes(subrouter) log.Println("Listening on", s.addr) return http.ListenAndServe(s.addr, router) }
Pour cette API, nous utilisons mux comme routeur http.
Nous avons une structure User Store qui gère les requêtes SQL liées à l'entité utilisateur.
// store.go package user import ( "errors" "finance-crud-app/internal/types" "fmt" "log" "github.com/jmoiron/sqlx" ) var ( CreateUserError = errors.New("cannot create user") RetrieveUserError = errors.New("cannot retrieve user") DeleteUserError = errors.New("cannot delete user") ) type Store struct { db *sqlx.DB } func NewStore(db *sqlx.DB) *Store { return &Store{db: db} } func (s *Store) CreateUser(user types.User) (user_id int, err error) { query := ` INSERT INTO users (firstName, lastName, email, password) VALUES (, , , ) RETURNING id` var userId int err = s.db.QueryRow(query, user.FirstName, user.LastName, user.Email, user.Password).Scan(&userId) if err != nil { return -1, CreateUserError } return userId, nil } func (s *Store) GetUserByEmail(email string) (types.User, error) { var user types.User err := s.db.Get(&user, "SELECT * FROM users WHERE email = ", email) if err != nil { return types.User{}, RetrieveUserError } if user.ID == 0 { log.Fatalf("user not found") return types.User{}, RetrieveUserError } return user, nil } func (s *Store) GetUserByID(id int) (*types.User, error) { var user types.User err := s.db.Get(&user, "SELECT * FROM users WHERE id = ", id) if err != nil { return nil, RetrieveUserError } if user.ID == 0 { return nil, fmt.Errorf("user not found") } return &user, nil } func (s *Store) DeleteUser(email string) error { user, err := s.GetUserByEmail(email) if err != nil { return DeleteUserError } // delete user records first _, err = s.db.Exec("DELETE FROM records WHERE userid = ", user.ID) if err != nil { return DeleteUserError } _, err = s.db.Exec("DELETE FROM users WHERE email = ", email) if err != nil { return DeleteUserError } return nil }
Dans le fichier ci-dessus, nous avons 3 méthodes de réception de pointeur :
Pour que ces méthodes remplissent leur fonction, elles doivent interagir avec un système externe qui, dans ce cas, est Postgres DB .
Pour tester cette méthode, nous allons d'abord créer un fichier store_test.go. En go, nous nommons généralement nos fichiers de test d'après le fichier que nous souhaitons tester et ajoutons le suffixe _test.go .
// store_test.go package user_test import ( "finance-crud-app/internal/db" "finance-crud-app/internal/services/user" "finance-crud-app/internal/types" "log" "os" "testing" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) var ( userTestStore *user.Store testDB *sqlx.DB ) func TestMain(m *testing.M) { // database ConnStr := "postgres://postgres:Password123@localhost:5432/crud_db?sslmode=disable" testDB, err := db.NewPGStorage(ConnStr) if err != nil { log.Fatalf("could not connect %v", err) } defer testDB.Close() userTestStore = user.NewStore(testDB) code := m.Run() os.Exit(code) } func TestCreateUser(t *testing.T) { test_data := map[string]struct { user types.User result any }{ "should PASS valid user email used": { user: types.User{ FirstName: "testfirsjjlkjt-1", LastName: "testlastkjh-1", Email: "validuser@email.com", Password: "00000000", }, result: nil, }, "should FAIL invalid user email used": { user: types.User{ FirstName: "testFirstName1", LastName: "testLastName1", Email: "test1@email.com", Password: "800890", }, result: user.CreateUserError, }, } for name, tc := range test_data { t.Run(name, func(t *testing.T) { value, got := userTestStore.CreateUser(tc.user) if got != tc.result { t.Errorf("test fail expected %v got %v instead and value %v", tc.result, got, value) } }) } t.Cleanup(func() { err := userTestStore.DeleteUser("validuser@email.com") if err != nil { t.Errorf("could not delete user %v got error %v", "validuser@email.com", err) } }) } func TestGetUserByEmail(t *testing.T) { test_data := map[string]struct { email string result any }{ "should pass valid user email address used": { email: "test1@email.com", result: nil, }, "should fail invalid user email address used": { email: "validuser@email.com", result: user.RetrieveUserError, }, } for name, tc := range test_data { got, err := userTestStore.GetUserByEmail(tc.email) if err != tc.result { t.Errorf("test fail expected %v instead got %v", name, got) } } } func TestGetUserById(t *testing.T) { testUserId, err := userTestStore.CreateUser(types.User{ FirstName: "userbyid", LastName: "userbylast", Email: "unique_email", Password: "unique_password", }) if err != nil { log.Panicf("got %v when creating testuser", testUserId) } test_data := map[string]struct { user_id int result any }{ "should pass valid user id used": { user_id: testUserId, result: nil, }, "should fail invalid user id used": { user_id: 0, result: user.RetrieveUserError, }, } for name, tc := range test_data { t.Run(name, func(t *testing.T) { _, got := userTestStore.GetUserByID(tc.user_id) if got != tc.result { t.Errorf("error retrieving user by id got %v want %v", got, tc.result) } }) } t.Cleanup(func() { err := userTestStore.DeleteUser("unique_email") if err != nil { t.Errorf("could not delete user %v got error %v", "unique_email", err) } }) } func TestDeleteUser(t *testing.T) { testUserId, err := userTestStore.CreateUser(types.User{ FirstName: "userbyid", LastName: "userbylast", Email: "delete_user@email.com", Password: "unique_password", }) if err != nil { log.Panicf("got %v when creating testuser", testUserId) } test_data := map[string]struct { user_email string result error }{ "should pass user email address used": { user_email: "delete_user@email.com", result: nil, }, } for name, tc := range test_data { t.Run(name, func(t *testing.T) { err = userTestStore.DeleteUser(tc.user_email) if err != tc.result { t.Errorf("error deletig user got %v instead of %v", err, tc.result) } }) } t.Cleanup(func() { err := userTestStore.DeleteUser("delete_user@email.com") if err != nil { log.Printf("could not delete user %v got error %v", "delete_user@email.com", err) } }) }
Parcourons le fichier en regardant ce que fait chaque section.
La première action consiste à déclarer les variables userTestStore et testDB. Ces variables seront utilisées pour stocker des pointeurs vers le magasin utilisateur et la base de données respectivement. La raison pour laquelle nous les avons déclarés dans la portée globale du fichier est que nous voulons que toutes les fonctions du fichier de test aient accès aux pointeurs.
La fonction TestMain nous permet d'effectuer quelques actions de configuration avant l'exécution du test principal. Nous nous connectons initialement au magasin postgres et enregistrons le pointeur dans notre variable globale.
Nous avons utilisé ce pointeur pour créer un userTestStore que nous utiliserons pour exécuter les requêtes SQL que nous essayons de connecter.
defer testDB.Close() ferme la connexion à la base de données une fois le test terminé
code := m.Run() exécute le reste de la fonction de test avant de revenir et de quitter.
La fonctionTestCreateUser gérera les tests de la fonction create_user. Notre objectif est de tester si la fonction créera l'utilisateur si un e-mail unique est transmis et la fonction ne devrait pas pouvoir créer un utilisateur si un e-mail non unique a déjà été utilisé pour créer un autre utilisateur.
Nous créons d'abord les données de test que nous utiliserons pour tester les deux scénarios de cas.
test_project |__cmd |__api |__api.go |__main.go |__db |___seed.go |__internal |___db |___db.go |___services |___records |___routes_test.go |___routes.go |___store_test.go |___store.go |___user |___routes_test.go |___routes.go |___store_test.go |___store.go |__test_data |__docker-compose.yml |__Dockerfile |__Makefile
Je vais parcourir la carte en exécutant la fonction create_user avec la date du test comme paramètres et comparer si la valeur renvoyée est la même que le résultat attendu
// main.go package main import ( "log" "finance-crud-app/cmd/api" "finance-crud-app/internal/db" "github.com/gorilla/mux" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) type Server struct { db *sqlx.DB mux *mux.Router } func NewServer(db *sqlx.DB, mux *mux.Router) *Server { return &Server{ db: db, mux: mux, } } func main() { connStr := "postgres://postgres:Password123@localhost:5432/crud_db?sslmode=disable" dbconn, err := db.NewPGStorage(connStr) if err != nil { log.Fatal(err) } defer dbconn.Close() server := api.NewAPIServer(":8085", dbconn) if err := server.Run(); err != nil { log.Fatal(err) } }
Dans les cas où le résultat renvoyé n'est pas le même que le résultat attendu, notre test échouera
La dernière partie de cette fonction utilise la fonction Cleanup du package de tests intégré. Cette fonction a enregistré une fonction qui sera appelée lorsque toutes les fonctions du test auront déjà été exécutées. Dans notre exemple de cas, nous utilisons ici la fonction pour effacer les données utilisateur qui ont été utilisées lors de l'exécution de cette fonction de test.
Pour nos tests unitaires, nous allons tester les gestionnaires de routes pour notre API. Dans ce cas, les itinéraires liés à l'entité utilisateur. Observez ci-dessous.
// api.go package api import ( "finance-crud-app/internal/services/records" "finance-crud-app/internal/services/user" "log" "net/http" "github.com/gorilla/mux" "github.com/jmoiron/sqlx" ) type APIServer struct { addr string db *sqlx.DB } func NewAPIServer(addr string, db *sqlx.DB) *APIServer { return &APIServer{ addr: addr, db: db, } } func (s *APIServer) Run() error { router := mux.NewRouter() subrouter := router.PathPrefix("/api/v1").Subrouter() userStore := user.NewStore(s.db) userHandler := user.NewHandler(userStore) userHandler.RegisterRoutes(subrouter) recordsStore := records.NewStore(s.db) recordsHandler := records.NewHandler(recordsStore, userStore) recordsHandler.RegisterRoutes(subrouter) log.Println("Listening on", s.addr) return http.ListenAndServe(s.addr, router) }
Nous avons ici 3 fonctions que nous aimerions tester
La fonction handleGetUser de ce gestionnaire récupère les détails de l'utilisateur en fonction d'un ID utilisateur fourni dans l'URL de la requête HTTP. Cela commence par extraire l'ID utilisateur des variables de chemin de requête à l'aide du routeur multiplexeur. Si l'ID utilisateur est manquant ou invalide (non entier), il répond par une erreur 400 Bad Request. Une fois validée, la fonction appelle la méthode GetUserByID sur le magasin de données pour récupérer les informations utilisateur. Si une erreur se produit lors de la récupération, elle renvoie une erreur interne du serveur 500. En cas de succès, il répond avec un statut 200 OK, envoyant les détails de l'utilisateur au format JSON dans le corps de la réponse.
Comme indiqué précédemment, pour tester les fonctions du gestionnaire, nous devons créer un routes_test.go. Voir le mien ci-dessous
test_project |__cmd |__api |__api.go |__main.go |__db |___seed.go |__internal |___db |___db.go |___services |___records |___routes_test.go |___routes.go |___store_test.go |___store.go |___user |___routes_test.go |___routes.go |___store_test.go |___store.go |__test_data |__docker-compose.yml |__Dockerfile |__Makefile
Notre fonction New Handler nécessite un magasin d'utilisateurs comme paramètre pour créer une structure de gestionnaire.
Puisque nous n'avons pas besoin d'un magasin réel, nous créons une structure fictive et créons des fonctions de récepteur qui se moquent de la fonction de la structure réelle. Nous faisons cela parce que nous traitons les tests de fonction du magasin séparément, nous n'avons donc pas besoin de tester cette partie du code dans les tests du gestionnaire.
La fonction de test TestGetUserHandler teste deux scénarios, le premier tente de récupérer un utilisateur sans fournir l'identifiant de l'utilisateur
// main.go package main import ( "log" "finance-crud-app/cmd/api" "finance-crud-app/internal/db" "github.com/gorilla/mux" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) type Server struct { db *sqlx.DB mux *mux.Router } func NewServer(db *sqlx.DB, mux *mux.Router) *Server { return &Server{ db: db, mux: mux, } } func main() { connStr := "postgres://postgres:Password123@localhost:5432/crud_db?sslmode=disable" dbconn, err := db.NewPGStorage(connStr) if err != nil { log.Fatal(err) } defer dbconn.Close() server := api.NewAPIServer(":8085", dbconn) if err := server.Run(); err != nil { log.Fatal(err) } }
Le test devrait réussir si la requête http répond avec un code d'état 400.
Le deuxième scénario de test concerne les cas où nous récupérons des informations utilisateur en utilisant l'URL correcte contenant un identifiant utilisateur valide. Dans ce cas de test, nous attendions une réponse avec un code d'état 200. Sinon, ce test aura échoué.
// api.go package api import ( "finance-crud-app/internal/services/records" "finance-crud-app/internal/services/user" "log" "net/http" "github.com/gorilla/mux" "github.com/jmoiron/sqlx" ) type APIServer struct { addr string db *sqlx.DB } func NewAPIServer(addr string, db *sqlx.DB) *APIServer { return &APIServer{ addr: addr, db: db, } } func (s *APIServer) Run() error { router := mux.NewRouter() subrouter := router.PathPrefix("/api/v1").Subrouter() userStore := user.NewStore(s.db) userHandler := user.NewHandler(userStore) userHandler.RegisterRoutes(subrouter) recordsStore := records.NewStore(s.db) recordsHandler := records.NewHandler(recordsStore, userStore) recordsHandler.RegisterRoutes(subrouter) log.Println("Listening on", s.addr) return http.ListenAndServe(s.addr, router) }
Nous avons réussi à implémenter des tests unitaires dans notre projet en créant des tests pour nos gestionnaires de routes. Nous avons vu comment utiliser des simulations pour tester uniquement une petite unité de code. Nous avons pu développer des tests d'intégration pour notre fonction qui interagissent avec la base de données Postgresql.
Si vous souhaitez avoir du temps avec le code du projet, clonez le dépôt depuis github ici
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!