ホームページ  >  記事  >  バックエンド開発  >  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 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 では、http ルーターとして mux を使用しています。

結合テスト

ユーザー エンティティに関連する 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 つのポインター レシーバー メソッドがあります。

  • ユーザーの作成
  • GetUserByEmail
  • GetUserById

これらのメソッドが機能を実行するには、外部システム (この場合は 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 ストアに接続し、ポインターをグローバル変数に保存します。
そのポインターを使用して、接続しようとしている SQL クエリを実行するために使用する userTestStore を作成しました。

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 に基づいてユーザーの詳細を取得します。まず、多重化ルーターを使用してリクエスト パス変数からユーザー ID を抽出します。ユーザー ID が欠落しているか無効 (非整数) の場合、400 Bad Request エラーが返されます。検証が完了すると、関数はデータ ストアの GetUserByID メソッドを呼び出してユーザー情報を取得します。取得中にエラーが発生した場合は、500 Internal Server Error が返されます。成功すると、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 は 2 つのケース シナリオをテストします。1 つ目は、ユーザー 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 ステータス コードで応答した場合、テストは合格すると予想されます。

2 番目のテスト ケース シナリオは、有効なユーザー 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 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。