🤔

GoのValidationエラーはどう返してテストでどう検証しますか

2025/02/26に公開

はじめに

こんにちは、kopherです。

元Javaの開発者がGoにスキルチェンジが欲しくて個人プロジェクトをGoで作りながら
思ったことを投稿しています。

今回はController層でValidationをかける時エラーの検証はどうするのか、そもそもエラーはどうクライアントに返すのかを考えた内容です。

テストから

個人ブログを作るためのプロジェクトなのでまずはPostが登録できるPost Controllerから作っていきます。

post.go
type PostCreate struct {
	Title   string `json:"title"`
	Content string `json:"content"`
}
post_controller_test.go
func Test_PostController_Post(t *testing.T) {
    // given
	r := gin.Default()
	postCreate := &domain.PostCreate{
		Title:   "吉祥寺マンション",
		Content: "吉祥寺マンションを購入したいです。",
	}
	var buf bytes.Buffer
	_ = json.NewEncoder(&buf).Encode(postCreate)

	resp := httptest.NewRecorder()
	req, _ := http.NewRequest(http.MethodPost, "/posts", &buf)
	req.Header.Set("Content-Type", "application/json")

    // when
	postController := NewPostController()
	r.POST("/posts", postController.PostCreate)
	r.ServeHTTP(resp, req)

    // then
	assert.Equal(t, http.StatusOK, resp.Code)
	assert.Equal(t, "Hello World", resp.Body.String())
}

TDDなので一旦"Hello World"を返すようにしておきます。

post_controller.go
type PostController interface {
	PostCreate(ctx *gin.Context)
}

type postController struct {
}

func NewPostController() PostController {
	return &postController{}
}

func (p *postController) PostCreate(ctx *gin.Context) {
	ctx.String(http.StatusOK, "Hello World")
}

Validationをかける

https://github.com/go-playground/validator
goでよく使われるValidationのパッケージはgo-playground/validatorみたいなのでこれを使います。

要件は以下になります。

  • Titleは必須である
  • Contentは必須である
post.go
type PostCreate struct {
	Title   string `json:"title" binding:"required"`
	Content string `json:"content" binding:"required"`
}

こうつければいいらしいですね

また、テスト

Controllerをよいします。

post_controller.go
func (p *postController) PostCreate(ctx *gin.Context) {
	request := &domain.PostCreate{}
	if err := ctx.ShouldBindJSON(request); err != nil {
		ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	ctx.String(http.StatusOK, "Hello World")
}

では、ValidationをかけてControllerもよいできたのでエラーがどう返ってくるのかみてみます。

> http -v :8080/posts \
title="" \
content=""
POST /posts HTTP/1.1
Accept: application/json, */*;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 28
Content-Type: application/json
Host: localhost:8080
User-Agent: HTTPie/3.2.4

{
    "content": "",
    "title": ""
}


HTTP/1.1 400 Bad Request
Content-Length: 192
Content-Type: application/json; charset=utf-8
Date: Wed, 26 Feb 2025 14:00:14 GMT

{
    "error": "Key: 'PostCreate.Title' Error:Field validation for 'Title' failed on the 'required' tag\nKey: 'PostCreate.Content' Error:Field validation for 'Content' failed on the 'required' tag"
}

これはやばいですね 🤣

post_controller_test.go
func Test_PostController_PostCreate_Title_Required(t *testing.T) {
	r := gin.Default()
	postCreate := &domain.PostCreate{
		Title:   "",
		Content: "吉祥寺マンション購入します。",
	}
	var buf bytes.Buffer
	_ = json.NewEncoder(&buf).Encode(postCreate)

	resp := httptest.NewRecorder()
	req, _ := http.NewRequest(http.MethodPost, "/posts", &buf)
	req.Header.Set("Content-Type", "application/json")

	postController := NewPostController()
	r.POST("/posts", postController.PostCreate)
	r.ServeHTTP(resp, req)

	assert.Equal(t, http.StatusBadRequest, resp.Code)
	assert.Equal(t, "{"error":"Key: 'PostCreate.Title' Error:Field validation for 'Title' failed on the 'required' tag"}", resp.Body.String())
}

こういう検証はよくないですね、もっといい方法はないでしょうか

respBody := struct {
		error string
	}{
		error: resp.Body.String(),
	}

こういう構造体を作るのも一つの方法と思いますが、それでもまだ足りないきがしました。
そもそもエラーメッセージが気に入らないですね。
では、エラーメッセージもエラーの返り値も綺麗に返すこの方法はどうでしょうか

ゴール

ゴールはこれです

{
    "validations": [
        {
            "field": "Title",
            "message": "必須です。"
        },
        {
            "field": "Content",
            "message": "必須です。"
        }
    ],
    "code": 400,
    "message": "間違ったリクエストです。"
}

また、テスト

検証が以前より綺麗にできて何をしているのかが明確になったと思います。

post_controller_test.go
func Test_PostController_PostCreate_Title_Required(t *testing.T) {
	r := gin.Default()
	postCreate := &domain.PostCreate{
		Title:   "",
		Content: "吉祥寺マンション購入します。",
	}
	var buf bytes.Buffer
	_ = json.NewEncoder(&buf).Encode(postCreate)

	resp := httptest.NewRecorder()
	req, _ := http.NewRequest(http.MethodPost, "/posts", &buf)
	req.Header.Set("Content-Type", "application/json")

	postController := NewPostController()
	r.POST("/posts", postController.PostCreate)
	r.ServeHTTP(resp, req)

	log.Println(resp.Body.String())
	var errors domain.ErrorResponse
	_ = json.NewDecoder(resp.Body).Decode(&errors)
	assert.Equal(t, http.StatusBadRequest, resp.Code)
	assert.Equal(t, "Title", errors.Validations[0].Field)
	assert.Equal(t, "必須です。", errors.Validations[0].Message)
}

ゴールを満たすためのエラー用の構造体を作ります。

error.go
type ErrorResponse struct {
	Code        int               `json:"code"`
	Message     string            `json:"message"`
	Validations []ValidationError `json:"validations"`
}

type ValidationError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
}

また、エラーメッセージのカスタムもしましょう

error.go
type ErrorResponse struct {
	Code        int               `json:"code"`
	Message     string            `json:"message"`
	Validations []ValidationError `json:"validations"`
}

type ValidationError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
}

var customMessages = map[string]string{
	"required": "必須です。",
}

ErrorResponseのValidationErrorにエラーgo-playground/validatorが返すエラーを追加する関数を作成します。

error.go
import (
	"github.com/go-playground/validator/v10"
)

type ErrorResponse struct {
	Code        int               `json:"code"`
	Message     string            `json:"message"`
	Validations []ValidationError `json:"validations"`
}

type ValidationError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
}

var customMessages = map[string]string{
	"required": "必須です。",
}

func GenerateValidationErrors(error error) []ValidationError {
	var validations []ValidationError
	if validationErrors, ok := error.(validator.ValidationErrors); ok {
		for _, e := range validationErrors {
			var msg string

			// カスタムメッセージがあれば交換
			if customMsg, exists := customMessages[e.Tag()]; exists {
				msg = customMsg
			}

			validations = append(validations, ValidationError{
				Field:   e.Field(),
				Message: msg,
			})
		}
	}
	return validations
}

Controllerに適用してみます。

post_controller.go
type PostController interface {
	PostCreate(ctx *gin.Context)
}

type postController struct {
}

func NewPostController() PostController {
	return &postController{}
}

func (p *postController) PostCreate(ctx *gin.Context) {
	request := &domain.PostCreate{}
	if err := ctx.ShouldBindJSON(request); err != nil {
		validationErrors := domain.GenerateValidationErrors(err)
		errorResponse := domain.ErrorResponse{
			Code:        http.StatusBadRequest,
			Message:     "間違ったリクエストです。",
			Validations: validationErrors,
		}
		ctx.JSON(http.StatusBadRequest, errorResponse)
		return
	}

	ctx.String(http.StatusOK, "Hello World")
}

サーバーを起動させてどう返ってくるのかみてみます。

> http -v :8080/posts \
title="" \
content=""
POST /posts HTTP/1.1
Accept: application/json, */*;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 28
Content-Type: application/json
Host: localhost:8080
User-Agent: HTTPie/3.2.4

{
    "content": "",
    "title": ""
}


HTTP/1.1 400 Bad Request
Content-Length: 170
Content-Type: application/json; charset=utf-8
Date: Wed, 26 Feb 2025 14:10:17 GMT

{
    "validations": [
        {
            "field": "Title",
            "message": "必須です。"
        },
        {
            "field": "Content",
            "message": "必須です。"
        }
    ],
    "code": 400,
    "message": "間違ったリクエストです。"
}

おお!

楽しいリファクタリング

この場合関数ではなくメソッドがもっとよさそうです!

error.go
func (e *ErrorResponse) AddValidationErrors(error error) {
	var validations []ValidationError
	if validationErrors, ok := error.(validator.ValidationErrors); ok {
		for _, e := range validationErrors {
			var msg string

			// カスタムメッセージがあれば交換
			if customMsg, exists := customMessages[e.Tag()]; exists {
				msg = customMsg
			}

			validations = append(validations, ValidationError{
				Field:   e.Field(),
				Message: msg,
			})
		}
	}
	e.Validations = validations
}

コントローラーでは

post_controller.go
type PostController interface {
	PostCreate(ctx *gin.Context)
}

type postController struct {
}

func NewPostController() PostController {
	return &postController{}
}

func (p *postController) PostCreate(ctx *gin.Context) {
	request := &domain.PostCreate{}
	if err := ctx.ShouldBindJSON(request); err != nil {
		errorResponse := domain.ErrorResponse{
			Code:    http.StatusBadRequest,
			Message: "間違ったリクエストです。",
		}
		errorResponse.AddValidationErrors(err)
		ctx.JSON(http.StatusBadRequest, errorResponse)
		return
	}

	log.Println("request =>", request)

	ctx.String(http.StatusOK, "Hello World")
}

最後に

これで、フロントエンドの方にも綺麗に返すようになりました。
また、Validationエラーが複数あっても対応できるし、メッセージもカスタムできました。
Goの勉強まだまだなのでGoらしくないとか、間違っている箇所があったら是非指摘いただけるとありがたいです。
ありがとうございました。🙏

Discussion