Introduction.
I am now developing “Mia,” a talking cat-shaped robot that speaks in various dialects.
This time, the next major feature is
Arbitrary text voice playback function
When the user enters a phrase that he/she wants Mia to speak, along with the playback time, the app will play back the phrase at the specified time.
I want to develop a new database. Since it will be a fairly large function, we will first design the DB and try test-driven development for SQL statements.
Creating a Migration File
First, create a database migration file.
This time, create two new tables
user_phrases table: which manages all phrases created by users and controls whether they are public or private
phrase_schedules table: which manages phrase playback schedules for each user
Text phrases or recorded audio files entered by the user in the app are stored in the user_phrases table. The user can control whether the phrase is public or private (is_private field)
Phrases and recorded audio files made public by users are visible to other users, and other users can say, “This phrase is interesting! and other users can copy the phrase and incorporate it as their own phrase playback.
Migration file creation command
$ cd migrations
$ migrate create -ext sql -format 2006010215 create_user_phrases_table
$ migrate create -ext sql -format 2006010215 create_phrase_schedules_table
Migration file contents
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;
Creating Test Codes
Next, the test code is created. The test code is used to verify that the expected functionality is implemented correctly.
Connect to production DB with dockertest
Processing around DB makes it easy to verify if dockertest can connect to a real DB for testing.
- Calling
RunMySQLContainer
: Call thetestutils.RunMySQLContainer
function to start the MySQL container and connect to the database. If successful, an instanceMySQLContainer
is returned incontainer
. - Database connection setup:
db
= container.DB
causes thedb
variable to hold the database connection. This allows us to usedb
in our test code to access the database. - Running tests: Call
m.Run()
to run all tests. - Cleanup: After the test is finished, call
container.Close()
to clean up the database connection and Docker resources.
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)
}
Test code creation in user_phrases_db_test.go
This setup is used to create concrete test code. The following is an example of a test code in user_phrases_db_test.go
(only Create and Get out of CRUD).
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)
}
})
}
How to create an SQL query
When creating SQL queries, it is important to protect against SQL injection. Therefore, use placeholders instead of string concatenation. Use the lightweight query builder called squirrel
.
Using the squirrel package
squirrel
is a library for safely and concisely assembling SQL queries.
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
}
Running tests with the go test command
Use the go test
command to run the test.
go test -v
- The
-v
option is for displaying detailed output. The output is displayed for both successful and unsuccessful tests.
It was executed like this
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
summary
The basic steps of test-driven development using the Go language, how to create SQL queries, and how to run tests are described.
In this example, we only had to Create and Get methods for user phrases, so I will also include Update and Delete methods, as well as a method to copy from public phrases and test code.