使用Golang创建一个带有处理程序的模拟数据库,并使用接口调用数据库。

huangapple go评论68阅读模式
英文:

Golang create a mock database with handler and call to database using interfaces

问题

我正在尝试在我的SignUp处理程序和对数据库的调用上实现单元测试。然而,在我的SignUp处理程序中,对数据库的调用会引发恐慌错误。这是一个简单的SignUp处理程序,它接收一个带有用户名、密码和电子邮件的JSON。然后,我将使用SELECT语句在SignUp处理程序内部检查此用户名是否重复。

当我向该处理程序发送POST请求时,一切正常运行。然而,当我进行单元测试时,它不起作用,并抛出了两个错误消息。我觉得这是因为在测试环境中未初始化数据库,但我不确定如何在不使用第三方框架进行模拟数据库的情况下实现这一点。

错误消息如下:

panic: runtime error: invalid memory address or nil pointer dereference [recovered]
        panic: runtime error: invalid memory address or nil pointer dereference

signup.go文件如下:

package handler

type SignUpJson struct {
	Username string `json:"username"`
	Password string `json:"password"`
	Email    string `json:"email"`
}

func SignUp(w http.ResponseWriter, r *http.Request) {
    // 设置头部
	w.Header().Set("Content-Type", "application/json")
	var newUser auth_management.SignUpJson

	// 读取请求体并将其解组为LoginJson结构体
	bs, _ := io.ReadAll(req.Body)
	if err := json.Unmarshal(bs, &newUser); err != nil {
		utils.ResponseJson(w, http.StatusInternalServerError, "Internal Server Error")
		log.Println("Internal Server Error in UnMarshal JSON body in SignUp route:", err)
		return
	}

	ctx := context.Background()
    ctx, cancel = context.WithTimeout(ctx, time.Minute * 2)
    defer cancel()

    // 检查数据库中是否已存在用户名(不允许重复)
	isExistingUsername := database.GetUsername(ctx, newUser.Username) // 在测试时会在此处引发恐慌错误
	if isExistingUsername {
		utils.ResponseJson(w, http.StatusBadRequest, "Username has already been taken. Please try again.")
		return
	}

    // 其他代码逻辑...
}

sqlquery.go文件如下:

package database

var SQL_SELECT_FROM_USERS = "SELECT %s FROM users WHERE %s = $1;"

func GetUsername(ctx context.Context, username string) bool {
	row := conn.QueryRow(ctx, fmt.Sprintf(SQL_SELECT_FROM_USERS, "username", "username"), username)
	return row.Scan() != pgx.ErrNoRows
}

SignUp_test.go文件如下:

package handler

func Test_SignUp(t *testing.T) {

	var tests = []struct {
		name               string
		postedData         SignUpJson
		expectedStatusCode int
	}{
		{
			name: "valid login",
			postedData: SignUpJson{
				Username: "testusername",
				Password: "testpassword",
				Email:    "test@email.com",
			},
			expectedStatusCode: 200,
		},
	}

	for _, e := range tests {
		jsonStr, err := json.Marshal(e.postedData)
		if err != nil {
			t.Fatal(err)
		}

		// 设置用于测试的请求
		req, _ := http.NewRequest(http.MethodPost, "/signup", strings.NewReader(string(jsonStr)))
		req.Header.Set("Content-Type", "application/json")

		// 设置并记录响应
		res := httptest.NewRecorder()
		handler := http.HandlerFunc(SignUp)

		handler.ServeHTTP(res, req)

		if res.Code != e.expectedStatusCode {
			t.Errorf("%s: returned wrong status code; expected %d but got %d", e.name, e.expectedStatusCode, res.Code)
		}
	}
}

setup_test.go文件如下:

func TestMain(m *testing.M) {

	os.Exit(m.Run())
}

我在这里看到了一个类似的问题,但不确定是否是正确的方法,因为没有回复,答案也令人困惑:https://stackoverflow.com/questions/73639698/how-to-write-an-unit-test-for-a-handler-that-invokes-a-function-that-interacts-w

英文:

I am trying to implement unit testing on my SignUp Handler and a call to database. However, it throws panic error on the database call in my SignUp Handler. It is a simple SignUp Handler that receives a JSON with username, password, and email. I will then use a SELECT statement to check if this username is duplicated inside the SignUp handler itself.

This all works when I am sending my post request to this handler. However, when I am actually doing unit testing, it doesn't work and threw me the 2 error messages. I feel that this is because the database wasn't initialized in the test environment but I am not sure how do do this without using third party frameworks to conduct a mock database.

error message

panic: runtime error: invalid memory address or nil pointer dereference [recovered]
        panic: runtime error: invalid memory address or nil pointer dereference

signup.go

package handler

type SignUpJson struct {
	Username string `json:"username"`
	Password string `json:"password"`
	Email    string `json:"email"`
}

func SignUp(w http.ResponseWriter, r *http.Request) {
    // Set Headers
	w.Header().Set("Content-Type", "application/json")
	var newUser auth_management.SignUpJson

	// Reading the request body and UnMarshal the body to the LoginJson struct
	bs, _ := io.ReadAll(req.Body)
	if err := json.Unmarshal(bs, &newUser); err != nil {
		utils.ResponseJson(w, http.StatusInternalServerError, "Internal Server Error")
		log.Println("Internal Server Error in UnMarshal JSON body in SignUp route:", err)
		return
	}

	ctx := context.Background()
    ctx, cancel = context.WithTimeout(ctx, time.Minute * 2)
    defer cancel()

    // Check if username already exists in database (duplicates not allowed)
	isExistingUsername := database.GetUsername(ctx, newUser.Username) // throws panic error here when testing
	if isExistingUsername {
		utils.ResponseJson(w, http.StatusBadRequest, "Username has already been taken. Please try again.")
		return
	}

    // other code logic...
}

sqlquery.go

package database

var SQL_SELECT_FROM_USERS = "SELECT %s FROM users WHERE %s = $1;"

func GetUsername(ctx context.Context, username string) bool {
	row := conn.QueryRow(ctx, fmt.Sprintf(SQL_SELECT_FROM_USERS, "username", "username"), username)
	return row.Scan() != pgx.ErrNoRows
}

SignUp_test.go

package handler

func Test_SignUp(t *testing.T) {

	var tests = []struct {
		name               string
		postedData         SignUpJson
		expectedStatusCode int
	}{
		{
			name: "valid login",
			postedData: SignUpJson{
				Username: "testusername",
				Password: "testpassword",
				Email:    "test@email.com",
			},
			expectedStatusCode: 200,
		},
	}

	for _, e := range tests {
		jsonStr, err := json.Marshal(e.postedData)
		if err != nil {
			t.Fatal(err)
		}

		// Setting a request for testing
		req, _ := http.NewRequest(http.MethodPost, "/signup", strings.NewReader(string(jsonStr)))
		req.Header.Set("Content-Type", "application/json")

		// Setting and recording the response
		res := httptest.NewRecorder()
		handler := http.HandlerFunc(SignUp)

		handler.ServeHTTP(res, req)

		if res.Code != e.expectedStatusCode {
			t.Errorf("%s: returned wrong status code; expected %d but got %d", e.name, e.expectedStatusCode, res.Code)
		}
	}
}

setup_test.go

func TestMain(m *testing.M) {

	os.Exit(m.Run())
}

I have seen a similar question here but not sure if that is the right approach as there was no response and the answer was confusing: https://stackoverflow.com/questions/73639698/how-to-write-an-unit-test-for-a-handler-that-invokes-a-function-that-interacts-w

答案1

得分: 1

让我试着帮你弄清楚如何实现这些功能。我稍微重构了你的代码,但整体思路和使用的工具与你的代码相同。首先,我将分享分散在两个文件中的生产代码:handlers/handlers.gorepo/repo.go

handlers/handlers.go 文件

package handlers

import (
	"context"
	"database/sql"
	"encoding/json"
	"io"
	"net/http"
	"time"

	"handlertest/repo"
)

type SignUpJson struct {
	Username string `json:"username"`
	Password string `json:"password"`
	Email    string `json:"email"`
}

func SignUp(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")

	var newUser SignUpJson
	bs, _ := io.ReadAll(r.Body)
	if err := json.Unmarshal(bs, &newUser); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(err.Error()))
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), time.Minute*2)
	defer cancel()

	db, _ := ctx.Value("DB").(*sql.DB)
	if isExistingUserName := repo.GetUserName(ctx, db, newUser.Username); isExistingUserName {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte("username already present"))
		return
	}
	w.WriteHeader(http.StatusOK)
}

这里有两个主要的区别:

  1. 使用的 context。你不需要实例化另一个 ctx,只需使用与 http.Request 一起提供的那个。
  2. 使用的 sql 客户端。正确的方式是通过 context.Context 传递它。对于这种情况,你不需要构建任何结构体或使用任何接口等等。只需编写一个期望 *sql.DB 作为参数的函数。记住这一点,函数是一等公民

当然,还有改进的空间。"DB" 应该是一个常量,并且我们需要检查上下文值中是否存在此条目,但为了简洁起见,我省略了这些检查。

repo/repo.go 文件

package repo

import (
	"context"
	"database/sql"

	"github.com/jackc/pgx/v5"
)

func GetUserName(ctx context.Context, db *sql.DB, username string) bool {
	row := db.QueryRowContext(ctx, "SELECT username FROM users WHERE username = $1", username)
	return row.Scan() != pgx.ErrNoRows
}

这里的代码与你的代码非常相似,只有这两个小的区别:

  1. 当你希望考虑上下文时,有一个专用的方法叫做 QueryRowContext
  2. 当你需要构建一个 SQL 查询时,使用预处理语句功能。不要使用 fmt.Sprintf 连接字符串,原因有两个:安全性和可测试性。

现在,我们来看一下测试代码。

handlers/handlers_test.go 文件

package handlers

import (
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/jackc/pgx/v5"
	"github.com/stretchr/testify/assert"
)

func TestSignUp(t *testing.T) {
	db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
	if err != nil {
		t.Fatalf("err not expected while open a mock db, %v", err)
	}
	defer db.Close()
	t.Run("NewUser", func(t *testing.T) {
		mock.ExpectQuery("SELECT username FROM users WHERE username = $1").WithArgs("john.doe@example.com").WillReturnError(pgx.ErrNoRows)

		w := httptest.NewRecorder()
		r := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(`{"username": "john.doe@example.com", "password": "1234", "email": "john.doe@example.com"}`))

		ctx := context.WithValue(r.Context(), "DB", db)
		r = r.WithContext(ctx)

		SignUp(w, r)

		assert.Equal(t, http.StatusOK, w.Code)
		if err := mock.ExpectationsWereMet(); err != nil {
			t.Errorf("not all expectations were met: %v", err)
		}
	})

	t.Run("AlreadyExistentUser", func(t *testing.T) {
		rows := sqlmock.NewRows([]string{"username"}).AddRow("john.doe@example.com")
		mock.ExpectQuery("SELECT username FROM users WHERE username = $1").WithArgs("john.doe@example.com").WillReturnRows(rows)

		w := httptest.NewRecorder()
		r := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(`{"username": "john.doe@example.com", "password": "1234", "email": "john.doe@example.com"}`))

		ctx := context.WithValue(r.Context(), "DB", db)
		r = r.WithContext(ctx)

		SignUp(w, r)

		assert.Equal(t, http.StatusBadRequest, w.Code)
		if err := mock.ExpectationsWereMet(); err != nil {
			t.Errorf("not all expectations were met: %v", err)
		}
	})
}

这里与你的版本相比有很多变化。让我快速总结一下:

  • 使用子测试功能为测试提供层次结构。
  • 使用 httptest 包提供的内容来构建和断言 HTTP 请求和响应。
  • 使用 sqlmock 包。这是模拟数据库的事实标准。
  • 使用 contextsql 客户端传递给 http.Request
  • 使用 github.com/stretchr/testify/assert 包进行断言。

同样的原则也适用于这里:有改进的空间(例如,你可以使用表驱动测试功能重新设计测试)。

结语

这可以被认为是编写 Go 代码的惯用方式。我知道这可能非常具有挑战性,特别是在开始阶段。如果你需要进一步了解某些部分的细节,请告诉我,我将很乐意帮助你,谢谢!

英文:

Let me try to help you in figuring out how to achieve these things. I refactored your code a little bit but the general idea and the tools used are still the same as yours. First, I'm gonna share the production code that is spread into two files: handlers/handlers.go and repo/repo.go.

handlers/handlers.go file

package handlers

import (
	"context"
	"database/sql"
	"encoding/json"
	"io"
	"net/http"
	"time"

	"handlertest/repo"
)

type SignUpJson struct {
	Username string `json:"username"`
	Password string `json:"password"`
	Email    string `json:"email"`
}

func SignUp(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")

	var newUser SignUpJson
	bs, _ := io.ReadAll(r.Body)
	if err := json.Unmarshal(bs, &newUser); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(err.Error()))
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), time.Minute*2)
	defer cancel()

	db, _ := ctx.Value("DB").(*sql.DB)
	if isExistingUserName := repo.GetUserName(ctx, db, newUser.Username); isExistingUserName {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte("username already present"))
		return
	}
	w.WriteHeader(http.StatusOK)
}

Here, there are two main differences:

  1. The context used. You don't have to instantiate another ctx, just use the one that is provided alongside the http.Request.
  2. The sql client used. The right way is to pass it through the context.Context. For this scenario, you don't have to build any structs or use any interface and so on. Just write a function that expects an *sql.DB as a parameter. Remember this, Functions are first-class citizens.

For sure, there is room for refactoring. The "DB" should be a constant and we've to check for the existence of this entry in the context values but, for the sake of brevity, I omitted these checks.

repo/repo.go file

package repo

import (
	"context"
	"database/sql"

	"github.com/jackc/pgx/v5"
)

func GetUserName(ctx context.Context, db *sql.DB, username string) bool {
	row := db.QueryRowContext(ctx, "SELECT username FROM users WHERE username = $1", username)
	return row.Scan() != pgx.ErrNoRows
}

Here, the code is pretty similar to yours except for these two small things:

  1. There is a dedicated method called QueryRowContext when you wish to take into consideration the context.
  2. Use the prepared statements feature when you've to build an SQL query. Don't concatenate stuff with fmt.Sprintf for two reasons: security and testability.

Now, we're gonna look at the test code.

handlers/handlers_test.go file

package handlers

import (
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/jackc/pgx/v5"
	"github.com/stretchr/testify/assert"
)

func TestSignUp(t *testing.T) {
	db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
	if err != nil {
		t.Fatalf("err not expected while open a mock db, %v", err)
	}
	defer db.Close()
	t.Run("NewUser", func(t *testing.T) {
		mock.ExpectQuery("SELECT username FROM users WHERE username = $1").WithArgs("john.doe@example.com").WillReturnError(pgx.ErrNoRows)

		w := httptest.NewRecorder()
		r := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(`{"username": "john.doe@example.com", "password": "1234", "email": "john.doe@example.com"}`))

		ctx := context.WithValue(r.Context(), "DB", db)
		r = r.WithContext(ctx)

		SignUp(w, r)

		assert.Equal(t, http.StatusOK, w.Code)
		if err := mock.ExpectationsWereMet(); err != nil {
			t.Errorf("not all expectations were met: %v", err)
		}
	})

	t.Run("AlreadyExistentUser", func(t *testing.T) {
		rows := sqlmock.NewRows([]string{"username"}).AddRow("john.doe@example.com")
		mock.ExpectQuery("SELECT username FROM users WHERE username = $1").WithArgs("john.doe@example.com").WillReturnRows(rows)

		w := httptest.NewRecorder()
		r := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(`{"username": "john.doe@example.com", "password": "1234", "email": "john.doe@example.com"}`))

		ctx := context.WithValue(r.Context(), "DB", db)
		r = r.WithContext(ctx)

		SignUp(w, r)

		assert.Equal(t, http.StatusBadRequest, w.Code)
		if err := mock.ExpectationsWereMet(); err != nil {
			t.Errorf("not all expectations were met: %v", err)
		}
	})
}

Here, there are a lot of changes compared to your version. Let me quickly recap them:

  • Use the sub-test feature to give a hierarchical structure to the tests.
  • Use the httptest package that provides stuff for building and asserting HTTP Requests and Responses.
  • Use the sqlmock package. The de-facto standard when it comes to mocking a database.
  • Use the context to pass the sql client alongside the http.Request.
  • Assertions have been done with the package github.com/stretchr/testify/assert.

The same applies here: there is room for refactoring (e.g. you can rework the tests by using the Table-Driven Tests feature).

Outro

This can be considered an idiomatic way to write Go code. I know this can be very challenging, especially at the beginning. If you need further details on some parts just let me know and I'll be happy to help you, thanks!

huangapple
  • 本文由 发表于 2023年3月21日 15:28:46
  • 转载请务必保留本文链接:https://go.coder-hub.com/75798345.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定