はじめに
今回は、次の大きな機能として
任意テキスト音声再生機能
ユーザーがアプリでミーアに話させたいフレーズを再生時刻とともに自由に入力すると、そのフレーズを指定した時刻に音声再生する機能
を開発しようと思う。結構大きな機能になるので、まずは、DB設計を行い、SQL文に関してテスト駆動開発を試みる。
マイグレーションファイルの作成
まず、データベースのマイグレーションファイルを作成する。
今回は、2つのテーブルを新規作成する
user_phrases テーブル:ユーザーが作成した全フレーズを管理し、公開/非公開を制御
phrase_schedules テーブル:各ユーザーごとのフレーズ再生スケジュールを管理
ユーザーがアプリで入力したテキストフレーズまたは録音した音声ファイルはuser_phrases テーブルに保存される。ユーザーはフレーズの公開/非公開を制御することができる(is_private フィールド)
ユーザーが公開にしたフレーズや録音した音声ファイルは、他のユーザーにも可視化され、他のユーザーは、「このフレーズ面白い!」と思ったら、そのフレーズをコピーして自分のフレーズ再生として取り込むことができる。
マイグレーションファイルの作成コマンド
$ 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
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
DROP TABLE IF EXISTS user_phrases;
2024071306_create_phrase_schedules_table.up.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
DROP TABLE IF EXISTS phrase_schedules;
テストコードの作成
次に、テストコードを作成する。テストコードは、期待される機能が正しく実装されていることを確認するために使用。
dockertestで本番DBに接続
DB周りの処理は、dockertestで本物のDBに接続してテストできると検証しやすい。
RunMySQLContainer
の呼び出し:testutils.RunMySQLContainer
関数を呼び出して、MySQLコンテナを起動し、データベースに接続する。成功すると、container
にMySQLContainer
のインスタンスが返される。- データベース接続のセットアップ:
db = container.DB
により、db
変数がデータベース接続を保持する。これにより、テストコード内でdb
を使用してデータベースにアクセスできる。 - テストの実行:
m.Run()
を呼び出して、すべてのテストを実行する。 - クリーンアップ: テストが終了した後、
container.Close()
を呼び出して、データベース接続とDockerリソースをクリーンアップする。
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
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
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
コマンドを使用して、テストを実行する。
go test -v
v
オプションは、詳細な出力を表示するためのもの。テストが成功した場合と失敗した場合の両方の出力が表示される。
このような感じで実行された
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メソッドと、公開フレーズからコピーするメソッドとテストコードも記載していこうと思う。