📌

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

2024/07/14に公開

はじめに

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

https://mia-cat.com/

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

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

を開発しようと思う。結構大きな機能になるので、まずは、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コンテナを起動し、データベースに接続する。成功すると、containerMySQLContainer のインスタンスが返される。
  • データベース接続のセットアップ: 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 におけるテストコードの例

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

続きはこちらで記載しています。
https://kazulog.fun/dev/go-test-driven-development/

Discussion