【Go Echo】テスト実装チュートリアル
Go言語とEchoフレームワークを使用して、基本的なCRUD(Create, Read, Update, Delete)操作を持つAPIサーバーを構築し、各操作に対するテストを実装する方法を学びましょう。
このチュートリアルでは、echo-api-test
というプロジェクト名で進め、コードに詳細なコメントを追加して理解を深めます。
前提条件
- Goがインストールされていること(インストールガイド)
- 基本的なGoの知識
プロジェクトのセットアップ
まず、新しいプロジェクトディレクトリを作成し、Goモジュールを初期化します。ターミナルを開いて以下のコマンドを実行してください。
mkdir echo-api-test
cd echo-api-test
go mod init github.com/yourusername/echo-api-test
このコマンドでecho-api-test
というディレクトリが作成され、Goモジュールが初期化されます。
EchoとTestifyのインストール
Echoフレームワークとテスト用のアサーションライブラリであるtestify
をインストールします。以下のコマンドを実行してください。
go get github.com/labstack/echo/v4
go get github.com/stretchr/testify
go get github.com/stretchr/testify/assert@v1.9.0
go get github.com/labstack/echo/v4/middleware@v4.12.0
これで、プロジェクトに必要な依存関係が追加されました。
APIサーバーの実装
main.go
というファイルを作成し、基本的なCRUD APIエンドポイントを実装します。コードには理解を深めるためのコメントを追加しています。
// main.go
package main
import (
"net/http"
"sort"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
// User はユーザー情報を表す構造体です
type User struct {
ID string `json:"id"` // ユーザーID
Name string `json:"name"` // ユーザー名
}
// サンプルユーザーデータを保持するマップ
var users = map[string]User{
"1": {ID: "1", Name: "Alice"},
"2": {ID: "2", Name: "Bob"},
}
func main() {
// Echoのインスタンスを作成
e := echo.New()
// ミドルウェアの設定
e.Use(middleware.Logger()) // ログ記録ミドルウェア
e.Use(middleware.Recover()) // パニック回復ミドルウェア
e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
// シンプルなベーシック認証の例
// 実際のプロジェクトでは、より安全な認証方法を使用してください
if username == "admin" && password == "password" {
return true, nil
}
return false, nil
}))
// ルートエンドポイントとハンドラを登録
e.GET("/hello", helloHandler) // GET /hello
e.GET("/users", getUsersHandler) // GET /users
e.GET("/users/:id", getUserHandler) // GET /users/:id
e.POST("/users", createUserHandler) // POST /users
e.PUT("/users/:id", updateUserHandler) // PUT /users/:id
e.DELETE("/users/:id", deleteUserHandler) // DELETE /users/:id
// サーバーをポート8080で開始
e.Start(":8080")
}
// helloHandler は /hello エンドポイントのハンドラです
func helloHandler(c echo.Context) error {
// JSON形式でメッセージを返す
return c.JSON(http.StatusOK, map[string]string{
"message": "Hello, World!",
})
}
// getUsersHandler は /users エンドポイントのハンドラです
func getUsersHandler(c echo.Context) error {
// ユーザーリストをスライスに変換
userList := []User{}
for _, user := range users {
userList = append(userList, user)
}
// ユーザーリストをIDでソート
sort.Slice(userList, func(i, j int) bool {
return userList[i].ID < userList[j].ID
})
// JSON形式でソートされたユーザーリストを返す
return c.JSON(http.StatusOK, userList)
}
// getUserHandler は /users/:id エンドポイントのハンドラです
func getUserHandler(c echo.Context) error {
// URLパラメータからユーザーIDを取得
id := c.Param("id")
user, exists := users[id]
if !exists {
// ユーザーが存在しない場合は404を返す
return c.JSON(http.StatusNotFound, map[string]string{
"error": "User not found",
})
}
// ユーザー情報をJSON形式で返す
return c.JSON(http.StatusOK, user)
}
// createUserHandler は /users エンドポイントのハンドラです
func createUserHandler(c echo.Context) error {
user := new(User)
// リクエストボディをUser構造体にバインド
if err := c.Bind(user); err != nil {
// バインドに失敗した場合は400を返す
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid input",
})
}
// 必須フィールドのチェック
if user.ID == "" || user.Name == "" {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Missing fields",
})
}
// ユーザーが既に存在するか確認
if _, exists := users[user.ID]; exists {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "User already exists",
})
}
// ユーザーをマップに追加
users[user.ID] = *user
// 作成したユーザー情報を201で返す
return c.JSON(http.StatusCreated, user)
}
// updateUserHandler は /users/:id エンドポイントのハンドラです
func updateUserHandler(c echo.Context) error {
// URLパラメータからユーザーIDを取得
id := c.Param("id")
user, exists := users[id]
if !exists {
// ユーザーが存在しない場合は404を返す
return c.JSON(http.StatusNotFound, map[string]string{
"error": "User not found",
})
}
updatedUser := new(User)
// リクエストボディをUser構造体にバインド
if err := c.Bind(updatedUser); err != nil {
// バインドに失敗した場合は400を返す
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid input",
})
}
// 必須フィールドのチェック
if updatedUser.Name == "" {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Missing fields",
})
}
// ユーザー情報を更新
user.Name = updatedUser.Name
users[id] = user
// 更新したユーザー情報を200で返す
return c.JSON(http.StatusOK, user)
}
// deleteUserHandler は /users/:id エンドポイントのハンドラです
func deleteUserHandler(c echo.Context) error {
// URLパラメータからユーザーIDを取得
id := c.Param("id")
_, exists := users[id]
if !exists {
// ユーザーが存在しない場合は404を返す
return c.JSON(http.StatusNotFound, map[string]string{
"error": "User not found",
})
}
// ユーザーをマップから削除
delete(users, id)
// 削除成功を204で返す(ボディなし)
return c.NoContent(http.StatusNoContent)
}
コードのポイント
-
Echoのインスタンス作成:
echo.New()
を使用してEchoのインスタンスを作成します。 -
ルーティング:
e.GET
、e.POST
、e.PUT
、e.DELETE
を使用してエンドポイントとハンドラを登録します。 - ハンドラ関数: 各エンドポイントに対応するハンドラ関数でリクエストを処理します。
-
JSONレスポンス:
c.JSON
を使用してJSON形式のレスポンスを返します。 - エラーハンドリング: 入力の検証や存在確認を行い、適切なHTTPステータスコードとメッセージを返します。
テストの実装
main_test.go
というファイルを作成し、CRUD操作に対するテストを実装します。
// main_test.go
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/stretchr/testify/assert"
)
// 初期ユーザーデータ
var initialUsers = map[string]User{
"1": {ID: "1", Name: "Alice"},
"2": {ID: "2", Name: "Bob"},
}
// setupEcho はEchoのインスタンスを初期化し、ルートエンドポイントを登録します
func setupEcho() *echo.Echo {
// 初期ユーザーデータで users マップをリセット
users = make(map[string]User)
for k, v := range initialUsers {
users[k] = v
}
e := echo.New()
// ミドルウェアの設定
e.Use(middleware.Logger()) // ログ記録ミドルウェア
e.Use(middleware.Recover()) // パニック回復ミドルウェア
e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
// テスト用のベーシック認証設定
if username == "admin" && password == "password" {
return true, nil
}
return false, nil
}))
e.GET("/hello", helloHandler)
e.GET("/users", getUsersHandler)
e.GET("/users/:id", getUserHandler)
e.POST("/users", createUserHandler)
e.PUT("/users/:id", updateUserHandler)
e.DELETE("/users/:id", deleteUserHandler)
return e
}
// 1. 基本的なGET /helloのテスト
func TestHelloHandler(t *testing.T) {
e := setupEcho()
// テスト用のGETリクエストを作成
req := httptest.NewRequest(http.MethodGet, "/hello", nil)
// レスポンスを記録するレコーダーを作成
rec := httptest.NewRecorder()
// Echoのコンテキストを作成
c := e.NewContext(req, rec)
// ハンドラを呼び出す
if assert.NoError(t, helloHandler(c)) {
// ステータスコードが200であることを確認
assert.Equal(t, http.StatusOK, rec.Code)
// レスポンスボディが期待通りであることを確認
assert.JSONEq(t, `{"message":"Hello, World!"}`, rec.Body.String())
}
}
// 2. GET /users のテスト
func TestGetUsersHandler(t *testing.T) {
e := setupEcho()
// テスト用のGETリクエストを作成
req := httptest.NewRequest(http.MethodGet, "/users", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// ハンドラを呼び出す
if assert.NoError(t, getUsersHandler(c)) {
// ステータスコードが200であることを確認
assert.Equal(t, http.StatusOK, rec.Code)
// レスポンスボディが期待通りであることを確認
expected := `[{"id":"1","name":"Alice"},{"id":"2","name":"Bob"}]`
assert.JSONEq(t, expected, rec.Body.String())
}
}
// 3. GET /users/:id の成功ケースのテスト
func TestGetUserHandler_Success(t *testing.T) {
e := setupEcho()
// 存在するユーザーIDでリクエストを作成
req := httptest.NewRequest(http.MethodGet, "/users/1", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// パラメータを設定
c.SetParamNames("id")
c.SetParamValues("1")
// ハンドラを呼び出す
if assert.NoError(t, getUserHandler(c)) {
// ステータスコードが200であることを確認
assert.Equal(t, http.StatusOK, rec.Code)
// レスポンスボディが期待通りであることを確認
expected := `{"id":"1","name":"Alice"}`
assert.JSONEq(t, expected, rec.Body.String())
}
}
// 4. GET /users/:id の失敗ケースのテスト(ユーザーが存在しない)
func TestGetUserHandler_NotFound(t *testing.T) {
e := setupEcho()
// 存在しないユーザーIDでリクエストを作成
req := httptest.NewRequest(http.MethodGet, "/users/999", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// パラメータを設定
c.SetParamNames("id")
c.SetParamValues("999")
// ハンドラを呼び出す
if assert.NoError(t, getUserHandler(c)) {
// ステータスコードが404であることを確認
assert.Equal(t, http.StatusNotFound, rec.Code)
// レスポンスボディが期待通りであることを確認
expected := `{"error":"User not found"}`
assert.JSONEq(t, expected, rec.Body.String())
}
}
// 5. POST /users の成功ケースのテスト
func TestCreateUserHandler_Success(t *testing.T) {
e := setupEcho()
// 新しいユーザーを作成
user := User{ID: "3", Name: "Charlie"}
// ユーザーをJSONにエンコード
body, _ := json.Marshal(user)
// テスト用のPOSTリクエストを作成
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// ハンドラを呼び出す
if assert.NoError(t, createUserHandler(c)) {
// ステータスコードが201であることを確認
assert.Equal(t, http.StatusCreated, rec.Code)
// レスポンスボディが期待通りであることを確認
assert.JSONEq(t, `{"id":"3","name":"Charlie"}`, rec.Body.String())
// ユーザーがマップに追加されていることを確認
assert.Contains(t, users, "3")
}
}
// 6. POST /users の失敗ケース(既に存在するユーザーID)のテスト
func TestCreateUserHandler_UserAlreadyExists(t *testing.T) {
e := setupEcho()
// 既に存在するユーザーIDでリクエストを作成
user := User{ID: "1", Name: "Alice Duplicate"}
body, _ := json.Marshal(user)
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// ハンドラを呼び出す
if assert.NoError(t, createUserHandler(c)) {
// ステータスコードが400であることを確認
assert.Equal(t, http.StatusBadRequest, rec.Code)
// レスポンスボディが期待通りであることを確認
expected := `{"error":"User already exists"}`
assert.JSONEq(t, expected, rec.Body.String())
}
}
// 7. POST /users の失敗ケース(無効なJSON)のテスト
func TestCreateUserHandler_InvalidJSON(t *testing.T) {
e := setupEcho()
// 不正なJSONデータを作成
invalidJSON := `{"id": "4", "name": }` // JSONの構文エラー
// テスト用のPOSTリクエストを作成
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader([]byte(invalidJSON)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// ハンドラを呼び出す
if assert.NoError(t, createUserHandler(c)) {
// ステータスコードが400であることを確認
assert.Equal(t, http.StatusBadRequest, rec.Code)
// レスポンスボディが期待通りであることを確認
expected := `{"error":"Invalid input"}`
assert.JSONEq(t, expected, rec.Body.String())
}
}
// 8. POST /users の失敗ケース(フィールド不足)のテスト
func TestCreateUserHandler_MissingFields(t *testing.T) {
e := setupEcho()
// 必須フィールドが不足しているユーザーを作成
user := User{ID: "", Name: "NoID"}
// ユーザーをJSONにエンコード
body, _ := json.Marshal(user)
// テスト用のPOSTリクエストを作成
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// ハンドラを呼び出す
if assert.NoError(t, createUserHandler(c)) {
// ステータスコードが400であることを確認
assert.Equal(t, http.StatusBadRequest, rec.Code)
// レスポンスボディが期待通りであることを確認
expected := `{"error":"Missing fields"}`
assert.JSONEq(t, expected, rec.Body.String())
}
}
// 9. PUT /users/:id の成功ケースのテスト
func TestUpdateUserHandler_Success(t *testing.T) {
e := setupEcho()
// 既存のユーザーを更新
updatedUser := User{Name: "Alice Updated"}
body, _ := json.Marshal(updatedUser)
req := httptest.NewRequest(http.MethodPut, "/users/1", bytes.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// パラメータを設定
c.SetParamNames("id")
c.SetParamValues("1")
// ハンドラを呼び出す
if assert.NoError(t, updateUserHandler(c)) {
// ステータスコードが200であることを確認
assert.Equal(t, http.StatusOK, rec.Code)
// レスポンスボディが期待通りであることを確認
assert.JSONEq(t, `{"id":"1","name":"Alice Updated"}`, rec.Body.String())
// ユーザーが更新されていることを確認
assert.Equal(t, "Alice Updated", users["1"].Name)
}
}
// 10. PUT /users/:id の失敗ケースのテスト(ユーザーが存在しない)
func TestUpdateUserHandler_NotFound(t *testing.T) {
e := setupEcho()
// 存在しないユーザーIDでリクエストを作成
updatedUser := User{Name: "Nonexistent User"}
body, _ := json.Marshal(updatedUser)
req := httptest.NewRequest(http.MethodPut, "/users/999", bytes.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// パラメータを設定
c.SetParamNames("id")
c.SetParamValues("999")
// ハンドラを呼び出す
if assert.NoError(t, updateUserHandler(c)) {
// ステータスコードが404であることを確認
assert.Equal(t, http.StatusNotFound, rec.Code)
// レスポンスボディが期待通りであることを確認
expected := `{"error":"User not found"}`
assert.JSONEq(t, expected, rec.Body.String())
}
}
// 11. DELETE /users/:id の成功ケースのテスト
func TestDeleteUserHandler_Success(t *testing.T) {
e := setupEcho()
// 既存のユーザーを削除
req := httptest.NewRequest(http.MethodDelete, "/users/2", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// パラメータを設定
c.SetParamNames("id")
c.SetParamValues("2")
// ハンドラを呼び出す
if assert.NoError(t, deleteUserHandler(c)) {
// ステータスコードが204であることを確認
assert.Equal(t, http.StatusNoContent, rec.Code)
// ユーザーがマップから削除されていることを確認
assert.NotContains(t, users, "2")
}
}
// 12. DELETE /users/:id の失敗ケースのテスト(ユーザーが存在しない)
func TestDeleteUserHandler_NotFound(t *testing.T) {
e := setupEcho()
// 存在しないユーザーIDでリクエストを作成
req := httptest.NewRequest(http.MethodDelete, "/users/999", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// パラメータを設定
c.SetParamNames("id")
c.SetParamValues("999")
// ハンドラを呼び出す
if assert.NoError(t, deleteUserHandler(c)) {
// ステータスコードが404であることを確認
assert.Equal(t, http.StatusNotFound, rec.Code)
// レスポンスボディが期待通りであることを確認
expected := `{"error":"User not found"}`
assert.JSONEq(t, expected, rec.Body.String())
}
}
// 13. テーブル駆動テストの例(GET /hello と GET /users)
func TestHelloHandler_TableDriven(t *testing.T) {
e := setupEcho()
// テストケースの定義
tests := []struct {
name string // テストケースの名前
method string // HTTPメソッド
target string // リクエストURL
expectedStatus int // 期待するステータスコード
expectedBody string // 期待するレスポンスボディ
}{
{
name: "Valid GET /hello",
method: http.MethodGet,
target: "/hello",
expectedStatus: http.StatusOK,
expectedBody: `{"message":"Hello, World!"}`,
},
{
name: "Valid GET /users",
method: http.MethodGet,
target: "/users",
expectedStatus: http.StatusOK,
expectedBody: `[{"id":"1","name":"Alice"},{"id":"2","name":"Bob"}]`,
},
// 他のエンドポイントのテストケースもここに追加可能
}
// 各テストケースを実行
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// テスト用のリクエストを作成
var req *http.Request
if tt.method == http.MethodGet {
req = httptest.NewRequest(tt.method, tt.target, nil)
} else {
req = httptest.NewRequest(tt.method, tt.target, nil)
}
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
var handler echo.HandlerFunc
// リクエストURLに応じてハンドラを選択
switch tt.target {
case "/hello":
handler = helloHandler
case "/users":
handler = getUsersHandler
// 他のエンドポイントのハンドラをここに追加
default:
t.Fatalf("Unknown target: %s", tt.target)
}
// ハンドラを呼び出す
if assert.NoError(t, handler(c)) {
// ステータスコードが期待通りであることを確認
assert.Equal(t, tt.expectedStatus, rec.Code)
// レスポンスボディが期待通りであることを確認
assert.JSONEq(t, tt.expectedBody, rec.Body.String())
}
})
}
}
// 14. 認証が必要なエンドポイントへのアクセステスト
func TestAuthentication(t *testing.T) {
e := setupEcho()
// 正しい認証情報でのテスト
t.Run("Valid Authentication", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users", nil)
req.SetBasicAuth("admin", "password")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
})
// 誤った認証情報でのテスト
t.Run("Invalid Authentication", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users", nil)
req.SetBasicAuth("admin", "wrongpassword")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
// 認証情報なしでのテスト
t.Run("No Authentication", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
}
テストコードのポイント
- setupEcho関数: テストごとにEchoのインスタンスとルートエンドポイントを初期化します。
- ハンドラの直接テスト: 各ハンドラ関数を直接呼び出し、その動作を確認します。
-
リクエストとレスポンスのシミュレーション:
httptest.NewRequest
とhttptest.NewRecorder
を使用して、実際のHTTPリクエストとレスポンスをシミュレートします。 -
パラメータの設定:
c.SetParamNames
とc.SetParamValues
を使用してURLパラメータを設定します。 -
アサーション:
testify
のassert
パッケージを使用して、期待する結果と実際の結果を比較します。 - テーブル駆動テスト: 複数のテストケースを効率的に実行するためのパターンを使用しています。
テストの実行
すべてのテストが正しく実装されたら、以下のコマンドでテストを実行します。
go test
成功すると、以下のような出力が表示されます。
PASS
ok github.com/yourusername/echo-api-test 0.XXXs
各テストケースがパスしていることを確認してください。
まとめ
このチュートリアルでは、GoとEchoを使用してecho-api-test
というAPIサーバーを構築し、CRUD操作に対する包括的なテストを実装する方法を学びました。
ソースコードに追加したコメントにより、各部分の動作や目的が明確になったと思います。
学んだこと
- Echoフレームワークを使用した基本的なAPIサーバーの構築方法
-
testify
を使用した効果的なテストの実装方法 - CRUD操作(Create, Read, Update, Delete)に対するテストケースの理解
- コードにコメントを追加することで、可読性と理解度を向上させる方法
この基本を元に、さらに複雑なエンドポイントやビジネスロジックを追加し、テストを充実させていくことで、堅牢で信頼性の高いAPIを開発することができます。