首頁  >  文章  >  後端開發  >  在 Go 中測試 REST API:使用 Go 標準測試庫進行單元和整合測試的指南

在 Go 中測試 REST API:使用 Go 標準測試庫進行單元和整合測試的指南

Barbara Streisand
Barbara Streisand原創
2024-11-17 01:33:03229瀏覽

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

介紹

本文將帶您了解如何使用單元測試和整合測試來改善您在 golang 中建立 REST API 時的開發體驗。

  • 單元測試旨在驗證應用程式最小的各個部分的功能,通常側重於單一函數或方法。這些測試與程式碼的其他部分隔離進行,以確保每個組件都能按預期工作。

  • 另一方面,
  • 整合測試評估應用程式的不同模組或元件如何協同工作。在本文中,我們將重點放在 Go 應用程式的整合測試,特別是透過成功建立和執行 SQL 查詢來檢查它是否與 PostgreSQL 資料庫正確互動。

本文假設您熟悉golang 以及如何在golang 中建立Rest api,主要重點是為您的路由建立測試(單元測試)並測試您的sql 查詢函數(整合測試)以供參考,請造訪github來看看這個項目。

設定

假設您已設定與上面連結的項目類似的項目,您將擁有與此類似的資料夾結構

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

與您可能遇到的其他語言相比,golang 中的測試很容易,因為內建的測試包提供了編寫測試所需的工具。
測試檔以 _test.go 命名,此後綴允許 go 在執行 go test 指令時以該檔案為目標執行。

我們專案的入口點是位於 cmd 資料夾中的 main.go 檔案

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

從程式碼中您可以看到我們正在透過傳遞資料庫連線和連接埠號碼來建立一個新的 api 伺服器。創建伺服器後,我們在指定的連接埠上運行它。

NewAPIServer 指令來自 api.go 文件,其中

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

對於這個 api,我們使用 mux 作為我們的 http 路由器。

整合測試

我們有一個使用者儲存結構,用於處理與使用者實體相關的 SQL 查詢。

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

在上面的文件中,我們有 3 個指標接收器方法:

  • 建立使用者
  • 透過電子郵件取得使用者
  • 取得UserById

為了讓這些方法執行其功能,它們必須與外部系統交互,在本例中為 Postgres DB .

為了測試這個方法,我們先建立一個 store_test.go 檔案。在 go 中,我們通常以要測試的文件命名測試文件,並添加後綴 _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)
        }
    })
}

讓我們瀏覽一下文件,看看每個部分的作用。

第一個操作是宣告變數 userTestStore 和 testDB。這些變數將用於分別儲存指向用戶儲存和資料庫的指標。我們在全域檔案作用域中聲明它們的原因是因為我們希望測試檔案中的所有函數都可以存取指標。

TestMain 函數允許我們在執行主測試之前執行一些設定操作。我們最初連接到 postgres 儲存並將指標保存到我們的全域變數中。
我們已經使用該指標建立了一個 userTestStore,我們將使用它來執行我們嘗試連接的 sql 查詢。

defer testDB.Close() 測試完成後關閉資料庫連線

code := m.Run() 在返回和退出之前運行測試函數的其餘部分。

TestCreateUser 函數將處理 create_user 函數的檢定。我們的目標是測試如果傳遞了唯一的電子郵件,該函數是否會建立用戶,並且如果非唯一的電子郵件已用於建立另一個用戶,則該函數應該無法建立用戶。

首先我們建立測試數據,用於測試這兩種情況場景。

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

我將循環遍歷地圖執行 create_user 函數,並以測試日期作為參數,並比較傳回的值是否與我們期望的結果相同

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

如果回傳的結果與預期結果不一樣,那麼我們的測試將會失敗

函數的最後一部分是使用內建的測試包函數 Cleanup。該函數註冊了一個函數,當測試中的所有函數都已執行完畢時將呼叫該函數。在我們的範例中,我們使用該函數來清除在此測試函數執行期間使用的使用者資料。

單元測試

對於我們的單元測試,我們將測試 api 的路由處理程序。在這種情況下,路由與用戶實體相關。請觀察下面。

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

我們想要測試 3 個函數

  • 處理登入
  • 句柄註冊
  • HandleGetUser

處理獲取用戶

此處理程序中的handleGetUser 函數根據HTTP 請求URL 中提供的使用者ID 檢索使用者詳細資料。它首先使用 mux 路由器從請求路徑變數中提取使用者 ID。如果 userID 遺失或無效(非整數),則會傳回 400 Bad Request 錯誤。驗證後,該函數將呼叫資料儲存體上的 GetUserByID 方法來檢索使用者資訊。如果檢索期間發生錯誤,它將傳回 500 內部伺服器錯誤。成功後,它會回應 200 OK 狀態,並在回應正文中以 JSON 形式發送使用者詳細資訊。

如前所述,為了測試處理程序函數,我們需要建立一個 routes_test.go。請參閱下面我的

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

我們的新處理程序函數需要一個使用者儲存作為參數來建立處理程序結構。
由於我們不需要實際存儲,因此我們建立一個模擬結構並建立模擬實際結構函數的接收器函數。我們這樣做是因為我們單獨處理儲存函數測試,因此我們不需要在處理程序測試中測試這部分程式碼。

測試函數 TestGetUserHandler 測試兩種情況,第一種是嘗試在不提供使用者 ID 的情況下檢索使用者

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

如果 http 請求回應 400 狀態代碼,則測試預計會通過。

第二個測試案例場景是我們使用包含有效使用者 ID 的正確 url 檢索使用者資訊的情況。在此測試案例中,我們期望得到 200 狀態代碼的回應。如果沒有,測試就會失敗。

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

結論

我們已經透過為路由處理程序建立測試來在我們的專案中實現單元測試。我們已經了解如何使用模擬來僅測試一小部分程式碼。我們已經能夠為與 Postgresql DB 互動的函數開發整合測試。
如果您想親身體驗專案程式碼,請在此處從 github 複製儲存庫

以上是在 Go 中測試 REST API:使用 Go 標準測試庫進行單元和整合測試的指南的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn