Maison  >  Article  >  développement back-end  >  Test des API REST dans Go : guide des tests unitaires et d'intégration avec la bibliothèque de tests standard de Go

Test des API REST dans Go : guide des tests unitaires et d'intégration avec la bibliothèque de tests standard de Go

Barbara Streisand
Barbara Streisandoriginal
2024-11-17 01:33:03229parcourir

Testing REST APIs in Go: A Guide to Unit and Integration Testing with Go

Introduction

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
  • 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.

  • Les
  • 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.

Configuration

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.

Test d'intégration

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 :

  • Créer un utilisateur
  • GetUserByEmail
  • GetUserById

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 fonction

TestCreateUser 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.

Tests unitaires

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

  • HandleLogin
  • HandleRegister
  • HandleGetUser

HandleGetUser

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)
}

Conclusion

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!

Déclaration:
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn