方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【Go言語】におけるテスト駆動開発の実践:マイグレーションファイル作成からSQLインジェクション対策まで

go-test-driven-development
この記事は約21分で読めます。

はじめに

様々な方言を話すおしゃべり猫型ロボット『ミーア』を開発中。

https://mia-cat.com

今回は、次の大きな機能として

任意テキスト音声再生機能
ユーザーがアプリでミーアに話させたいフレーズを再生時刻とともに自由に入力すると、そのフレーズを指定した時刻に音声再生する機能

を開発しようと思う。結構大きな機能になるので、まずは、DB設計を行い、SQL文に関してテスト駆動開発を試みる。

マイグレーションファイルの作成

まず、データベースのマイグレーションファイルを作成する。

今回は、2つのテーブルを新規作成する

user_phrases テーブル:ユーザーが作成した全フレーズを管理し、公開/非公開を制御
phrase_schedules テーブル:各ユーザーごとのフレーズ再生スケジュールを管理

ユーザーがアプリで入力したテキストフレーズまたは録音した音声ファイルはuser_phrases テーブルに保存される。ユーザーはフレーズの公開/非公開を制御することができる(is_private フィールド)

ユーザーが公開にしたフレーズや録音した音声ファイルは、他のユーザーにも可視化され、他のユーザーは、「このフレーズ面白い!」と思ったら、そのフレーズをコピーして自分のフレーズ再生として取り込むことができる。

マイグレーションファイルの作成コマンド

ShellScript
$ cd migrations
$ migrate create -ext sql -format 2006010215 create_user_phrases_table
$ migrate create -ext sql -format 2006010215 create_phrase_schedules_table

マイグレーションファイルの内容

2024071305_create_user_phrases_table.up.sql

SQL
CREATE TABLE IF NOT EXISTS user_phrases (
    id INT NOT NULL AUTO_INCREMENT COMMENT 'フレーズID',
    user_id BIGINT UNSIGNED COMMENT 'ユーザーID',
    phrase TEXT NOT NULL COMMENT 'フレーズテキスト',
    voice_path VARCHAR(255) NOT NULL COMMENT 'フレーズ音声ファイルへのパス',
    recorded BOOLEAN NOT NULL DEFAULT FALSE COMMENT '録音データか否か',
    is_private BOOLEAN NOT NULL DEFAULT FALSE COMMENT '公開or非公開',
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '登録日時',
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新日時',
    PRIMARY KEY (id),
    FOREIGN KEY (user_id) REFERENCES users(id)
);

2024071305_create_user_phrases_table.down.sql

ShellScript
DROP TABLE IF EXISTS user_phrases;

2024071306_create_phrase_schedules_table.up.sql

SQL
CREATE TABLE IF NOT EXISTS phrase_schedules (
    id INT NOT NULL AUTO_INCREMENT COMMENT 'スケジュールID',
    user_id BIGINT UNSIGNED COMMENT 'ユーザーID',
    phrase_id INT COMMENT 'フレーズID',
    time TIME COMMENT '再生時間',
    days VARCHAR(255) COMMENT '再生曜日',
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '登録日時',
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新日時',
    PRIMARY KEY (id),
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (phrase_id) REFERENCES user_phrases(id)
);

2024071306_create_phrase_schedules_table.down.sql

ShellScript
DROP TABLE IF EXISTS phrase_schedules;

テストコードの作成

次に、テストコードを作成する。テストコードは、期待される機能が正しく実装されていることを確認するために使用。

dockertestで本番DBに接続

DB周りの処理は、dockertestで本物のDBに接続してテストできると検証しやすい。

  • RunMySQLContainer の呼び出し: testutils.RunMySQLContainer 関数を呼び出して、MySQLコンテナを起動し、データベースに接続する。成功すると、containerMySQLContainer のインスタンスが返される。
  • データベース接続のセットアップ: db = container.DB により、db 変数がデータベース接続を保持する。これにより、テストコード内で db を使用してデータベースにアクセスできる。
  • テストの実行: m.Run() を呼び出して、すべてのテストを実行する。
  • クリーンアップ: テストが終了した後、container.Close() を呼び出して、データベース接続とDockerリソースをクリーンアップする。
Go
package clocky_be_test

import (
	"log"
	"os"
	"testing"

	"github.com/EarEEG-dev/clocky_be/testutils"
	"github.com/jmoiron/sqlx"
)

var db *sqlx.DB

func TestMain(m *testing.M) {
	container, err := testutils.RunMySQLContainer()
	if err != nil {
		if container != nil {
			container.Close()
		}
		log.Fatal(err)
	}
	db = container.DB
	code := m.Run()
	container.Close()
	os.Exit(code)
}

user_phrases_db_test.go におけるテストコード作成

このセットアップを利用して、具体的なテストコードを作成する。以下は、user_phrases_db_test.go におけるテストコードの例(CRUDのうちCreateとGetのみ)。

user_phrases_db_test.go

Go
package clocky_be_test

import (
	"errors"
	"testing"

	"github.com/EarEEG-dev/clocky_be"
	"github.com/jmoiron/sqlx"
)

func resetDBForUserPhrases(db *sqlx.DB) {
	db.MustExec(`DELETE FROM phrase_schedules`)
	db.MustExec(`DELETE FROM user_phrases`)
	db.MustExec(`DELETE FROM users`)
}

func TestCreateUserPhrase(t *testing.T) {
	t.Run("Create new user phrase", func(t *testing.T) {
		resetDBForUserPhrases(db)
		uid := "unique_user_id"
		user, err := clocky_be.CreateUser(db, uid)
		if err != nil {
			t.Fatalf("failed to create user: %v", err)
		}

		up, err := clocky_be.CreateUserPhrase(db, user.ID, "sample phrase", "sample/path", false, true)
		if err != nil {
			t.Fatalf("failed to create user phrase: %v", err)
		}

		if up.UserID != user.ID || up.Phrase != "sample phrase" || up.VoicePath != "sample/path" || up.Recorded != false || up.IsPrivate != true {
			t.Errorf("unexpected user phrase data: %+v", up)
		}
	})
}

func TestGetUserPhrase(t *testing.T) {
	t.Run("Get specific user phrase", func(t *testing.T) {
		resetDBForUserPhrases(db)
		uid := "existing_user_id"
		user, err := clocky_be.CreateUser(db, uid)
		if err != nil {
			t.Fatalf("failed to create user: %v", err)
		}

		up, err := clocky_be.CreateUserPhrase(db, user.ID, "sample phrase", "sample/path", false, true)
		if err != nil {
			t.Fatalf("failed to create user phrase: %v", err)
		}

		gotUP, err := clocky_be.GetUserPhrase(db, up.ID, user.ID)
		if err != nil {
			t.Fatalf("failed to get user phrase: %v", err)
		}

		if gotUP.ID != up.ID || gotUP.UserID != user.ID || gotUP.Phrase != up.Phrase || gotUP.VoicePath != up.VoicePath {
			t.Errorf("unexpected user phrase data: %+v", gotUP)
		}
	})
}

SQLクエリの作成方法

SQLクエリを作成する際には、SQLインジェクション対策が重要。そのため、文字列連結ではなく、プレースホルダーを使う。軽量なsquirrelというクエリビルダーを使う。

squirrel パッケージの利用

squirrel は、SQLクエリを安全かつ簡潔に組み立てるためのライブラリ。

https://github.com/Masterminds/squirrel

user_phrases_db.go

Go
package clocky_be

import (
	"github.com/Masterminds/squirrel"
	"github.com/jmoiron/sqlx"
)

type UserPhrase struct {
	ID        uint64 `db:"id" json:"id"`
	UserID    uint64 `db:"user_id" json:"user_id"`
	Phrase    string `db:"phrase" json:"phrase"`
	VoicePath string `db:"voice_path" json:"voice_path"`
	Recorded  bool   `db:"recorded" json:"recorded"`
	IsPrivate bool   `db:"is_private" json:"is_private"`
	CreatedAt string `db:"created_at" json:"created_at"`
	UpdatedAt string `db:"updated_at" json:"updated_at"`
}

func CreateUserPhrase(db *sqlx.DB, userID uint64, phrase, voicePath string, recorded, isPrivate bool) (*UserPhrase, error) {
	query := squirrel.Insert("user_phrases").
		Columns("user_id", "phrase", "voice_path", "recorded", "is_private", "created_at", "updated_at").
		Values(userID, phrase, voicePath, recorded, isPrivate, squirrel.Expr("CURRENT_TIMESTAMP"), squirrel.Expr("CURRENT_TIMESTAMP")).
		PlaceholderFormat(squirrel.Question)

	sql, args, err := query.ToSql()
	if err != nil {
		return nil, err
	}

	result, err := db.Exec(sql, args...)
	if err != nil {
		return nil, err
	}

	id, err := result.LastInsertId()
	if err != nil {
		return nil, err
	}

	return GetUserPhrase(db, uint64(id), userID)
}

func GetUserPhrase(db *sqlx.DB, id, userID uint64) (*UserPhrase, error) {
	query := squirrel.Select("id", "user_id", "phrase", "voice_path", "recorded", "is_private", "created_at", "updated_at").
		From("user_phrases").
		Where(squirrel.Eq{"id": id, "user_id": userID}).
		PlaceholderFormat(squirrel.Question)

	sql, args, err := query.ToSql()
	if err != nil {
		return nil, err
	}

	var up UserPhrase
	err = db.Get(&up, sql, args...)
	if err != nil {
		return nil, err
	}

	return &up, nil
}

go testコマンドでテスト実行

go test コマンドを使用して、テストを実行する。

ShellScript
go test -v
  • v オプションは、詳細な出力を表示するためのもの。テストが成功した場合と失敗した場合の両方の出力が表示される。

このような感じで実行された

ShellScript
2024/07/13 20:14:48 success connecting to DB🚀
2024/07/13 20:14:48 mysql container start🐳
=== RUN   TestCreatePhraseSchedule
=== RUN   TestCreatePhraseSchedule/Create_new_phrase_schedule
=== RUN   TestCreatePhraseSchedule/Create_phrase_schedule_for_private_phrase_with_mismatched_user_id
--- PASS: TestCreatePhraseSchedule (0.05s)
    --- PASS: TestCreatePhraseSchedule/Create_new_phrase_schedule (0.03s)
    --- PASS: TestCreatePhraseSchedule/Create_phrase_schedule_for_private_phrase_with_mismatched_user_id (0.02s)
=== RUN   TestUpdatePhraseSchedule
=== RUN   TestUpdatePhraseSchedule/Update_existing_phrase_schedule
--- PASS: TestUpdatePhraseSchedule (0.01s)
    --- PASS: TestUpdatePhraseSchedule/Update_existing_phrase_schedule (0.01s)
=== RUN   TestDeletePhraseSchedule
=== RUN   TestDeletePhraseSchedule/Delete_existing_phrase_schedule
--- PASS: TestDeletePhraseSchedule (0.01s)
    --- PASS: TestDeletePhraseSchedule/Delete_existing_phrase_schedule (0.01s)
=== RUN   TestCreateUser
=== RUN   TestCreateUser/Create_unique_user
=== RUN   TestCreateUser/Create_duplicate_user
--- PASS: TestCreateUser (0.01s)
    --- PASS: TestCreateUser/Create_unique_user (0.01s)
    --- PASS: TestCreateUser/Create_duplicate_user (0.01s)
=== RUN   TestGetUser
=== RUN   TestGetUser/Get_existing_user
=== RUN   TestGetUser/Get_non-existing_user
--- PASS: TestGetUser (0.01s)
    --- PASS: TestGetUser/Get_existing_user (0.01s)
    --- PASS: TestGetUser/Get_non-existing_user (0.00s)
=== RUN   TestUpdateUser
=== RUN   TestUpdateUser/Update_existing_user
=== RUN   TestUpdateUser/Update_non-existing_user
=== RUN   TestUpdateUser/Update_with_non-existing_device_id
=== RUN   TestUpdateUser/Update_with_existing_device_id_and_set_firmware_version
=== RUN   TestUpdateUser/Update_with_device_already_registered_to_another_user
--- PASS: TestUpdateUser (0.04s)
    --- PASS: TestUpdateUser/Update_existing_user (0.01s)
    --- PASS: TestUpdateUser/Update_non-existing_user (0.00s)
    --- PASS: TestUpdateUser/Update_with_non-existing_device_id (0.01s)
    --- PASS: TestUpdateUser/Update_with_existing_device_id_and_set_firmware_version (0.01s)
    --- PASS: TestUpdateUser/Update_with_device_already_registered_to_another_user (0.01s)
=== RUN   TestDeleteUser
=== RUN   TestDeleteUser/Delete_existing_user
=== RUN   TestDeleteUser/Delete_non-existing_user
--- PASS: TestDeleteUser (0.01s)
    --- PASS: TestDeleteUser/Delete_existing_user (0.01s)
    --- PASS: TestDeleteUser/Delete_non-existing_user (0.00s)
=== RUN   TestExistUser
=== RUN   TestExistUser/User_exists
=== RUN   TestExistUser/User_does_not_exist
--- PASS: TestExistUser (0.01s)
    --- PASS: TestExistUser/User_exists (0.00s)
    --- PASS: TestExistUser/User_does_not_exist (0.00s)
=== RUN   TestCreateUserPhrase
=== RUN   TestCreateUserPhrase/Create_new_user_phrase
--- PASS: TestCreateUserPhrase (0.00s)
    --- PASS: TestCreateUserPhrase/Create_new_user_phrase (0.00s)
=== RUN   TestGetUserPhrase
=== RUN   TestGetUserPhrase/Get_specific_user_phrase
--- PASS: TestGetUserPhrase (0.01s)
    --- PASS: TestGetUserPhrase/Get_specific_user_phrase (0.01s)
=== RUN   TestGetUserPhrases
=== RUN   TestGetUserPhrases/Get_all_user_phrases
--- PASS: TestGetUserPhrases (0.01s)
    --- PASS: TestGetUserPhrases/Get_all_user_phrases (0.01s)
=== RUN   TestCopyPublicPhrase
=== RUN   TestCopyPublicPhrase/Copy_public_phrase
=== RUN   TestCopyPublicPhrase/Copy_non-existent_public_phrase
--- PASS: TestCopyPublicPhrase (0.02s)
    --- PASS: TestCopyPublicPhrase/Copy_public_phrase (0.01s)
    --- PASS: TestCopyPublicPhrase/Copy_non-existent_public_phrase (0.00s)
=== RUN   TestUpdateUserPhrase
=== RUN   TestUpdateUserPhrase/Update_existing_user_phrase
--- PASS: TestUpdateUserPhrase (0.01s)
    --- PASS: TestUpdateUserPhrase/Update_existing_user_phrase (0.01s)
=== RUN   TestDeleteUserPhrase
=== RUN   TestDeleteUserPhrase/Delete_existing_user_phrase
--- PASS: TestDeleteUserPhrase (0.01s)
    --- PASS: TestDeleteUserPhrase/Delete_existing_user_phrase (0.01s)
PASS
2024/07/13 20:14:49 mysql container end🐳
ok      github.com/EarEEG-dev/clocky_be 14.257s

まとめ

Go言語を使用したテスト駆動開発の基本的な手順と、SQLクエリの作成方法、そしてテストの実行方法について記載した。

今回の例ではuserPhrasesのCreateとGetメソッドのみだったので、Update, Deleteメソッドと、公開フレーズからコピーするメソッドとテストコードも記載していこうと思う。

タイトルとURLをコピーしました