# Goの実DBを用いたAPIテストの導入 ###### tags: `ブログ記事` - [ ] 公開(ブログ公開担当者がいじるやつ) 太字斜体で書いてある内容を埋めて行ってください. 文章,画像は太字斜体の下の行に入れてください. 最初に書く時はREADMEを読んだら読むといいと思います. <br> ## 表示されない情報 ***書いた人の名前(自己紹介文と同じ名前)*** { くぼ } ***記事の簡単な説明(検索した時にタイトルの下に出てくる文章)*** { Goで実DBを用いたAPIテストを導入する方法を紹介します。実際の現場では、自動テストは多くが行われていると思いますが、NUTMEGでは導入されていないので、概要やメリット・デメリットなどもさらっと書きます! } <br> ## 表示される部分 ***サムネイル画像*** { ![adocare-3adokare3 (1)](https://hackmd.io/_uploads/H1Hg1WeNke.png) } ***カテゴリ*** 以下の中から該当しそうなカテゴリを選択してください ※一つだけ選択してください - [ ] 対外活動 - [ ] 活動の様子 - [ ] メンバーの趣味 - [ ] 実務訓練体験記 - [x] NUTMEG Advent Calendar 2023 ***タグ*** 以下の中から該当しそうなカテゴリを選択してください.当てはまる物がない場合は適宜追加してください. 言語 - [ ] HTML - [ ] CSS - [ ] Python - [x] Go - [ ] Ruby - [ ] JavaScript - [ ] TypeScript - [ ] Dart - [ ] Rust - [ ] Kotlin - [ ] Swift フレームワーク・ライブラリ - [ ] Ruby on rails - [ ] Vue.js - [ ] Nuxt.js - [ ] React.js - [ ] Next.js - [ ] Gin - [ ] Flluter ツール - [ ] GitHub - [ ] ターミナル - [ ] WSL - [ ] Ubuntu - [x] Docker - [ ] Raspberry Pi - [ ] Figma 分野 - [ ] チームづくり - [ ] フロントエンド - [ ] バックエンド - [ ] インフラ - [ ] Web-design - [x] API関係 --- ***以下に本文を記載してください*** こんにちは! NUTMEGのくぼ(kubosaka)です! NUTMEGのアドカレ企画の6日を担当します NUTMEGのプロダクトでは、おそらく導入されていない自動テスト導入しているとこはなかったと思います。 そこで、今回は自動テストについての紹介と運用しているプロダクト(FinanSu)でGoの実DBを用いたAPIテストを導入したので、ブログにします。 まず、自動テストについて簡単に紹介します # テストで自動化について ### テスト自動化の概要 テスト自動化ツールやテストスクリプトを活用し、ソフトウェア評価におけるテストの実行や結果の確認といった、一連の工程を自動化することです。 以下のようなメリットとデメリットがあります! ### メリット - 品質の向上 - バグを早い段階で発見でき、リリース前に修正が可能です - 過去に修正したバグが再発していないかを容易に確認できます - 開発速度の向上 - 手動で動作確認を繰り返す必要がなくなります - 継続的インテグレーションとデプロイを支援し、リリースサイクルを短縮できます - 長期的なコスト削減 - テストスイートが信頼できるドキュメントの役割を果たし、新しい開発者がプロジェクトに参加しやすくなる。 - リリース後の重大な不具合を防ぐことで、修正にかかるコストを抑えられます。 ### デメリット - 初期コスト - 機能実装のたびにテストコードも作成しなければならず、コストがかかります - テストツールやフレームワークの学習が必要になります - メンテナンス - プロダクトの仕様変更によりテストコードの更新も必要になります。 - テストを実行するライブラリなどのバージョン管理などもに必要になります。 運用しているプロダクトであれば、品質を高めるためにもテストの自動化は導入しましょう! では本題です。 # Goの実DBを用いたAPIテスト導入 FinanSuにAPIテストの自動化を導入します。 使用している技術・フレームワークなどはこちら - Next.js - TypeScript - Go - MySQL - Docker - MIniO 今回は実DBを用いたAPIのテストを自動化を導入します。 DB側にモックを使うことも考えたのですが、サーバーとDB間の接続も確認したいと思いました。 RailsのRspecのcontorollerテストのようなものを想定して作成しました。 goのテストの際には、以下のパッケージを使いました。 - testfixtures - testify ## 導入の手順 1. テスト用DB作成 2. GoのAPIテスト環境作成 3. テストコード作成 ## 1. テスト用DBの作成 開発環境のDBは、Dockerコンテナ上でMySQLを起動しています。 APIのテストを行う際DBが必要になりますが、テストで使うDBは開発環境で使うDBとは分けたいので、テストDBを作成します。 dokcerの起動には、docker composeを使用しています。 dockerのMySQL image では `/docker-entrypoint-initdb.d/` というディレクトリ内に初期化用のSQLやスクリプトを置くことで、最初にコンテナを起動したときにDBの初期化を自動的に行うことができます。 この機能を使いテストDBの作成とテスト用テーブルの作成を行います。 開発環境のDBは以下のdbディレクトリのvolumeをdocker-entrypoint-initdb.dにマウントしています。 docker-compose.yml ``` services: db: image: mysql:8.0 container_name: "nutfes-finansu-db" volumes: - ./mysql/db:/docker-entrypoint-initdb.d # 初期データディレクトリ - ./my.cnf:/etc/mysql/conf.d/my.cnf environment: MYSQL_DATABASE: *** MYSQL_USER: *** MYSQL_PASSWORD: *** MYSQL_ROOT_PASSWORD: *** TZ: "Asia/Tokyo" ports: - "3306:3306" ``` dbディレクトリ配下のsqlがコンテナ起動時に実行されます。 ``` mysql ── db │  ├── activities.sql │ . │  └── users.sql │ └─────── docker-compose.yml ``` dbディレクトリ配下にテスト用DB作成に関するsqlファイルを追加すれば解決しそうですが、テスト用と開発用のディレクトリは明示的に分けたいと思い以下のようにしました。 ``` mysql ── db │  ├── activities.sql │ . │  ├── users.sql │ └── init_create_db.sh │ ├────── testdb │ ├── 01_create_testdb.sql │ └── 02_test_users.sql │ └─────── docker-compose.yml ``` <br> docker-compose.ymlにテストdb用のディレクトリを新しく作成しvolumesでコンテナにマウントします。 docker-compose.yml ``` services: db: image: mysql:8.0 container_name: "nutfes-finansu-db" volumes: - ./mysql/db:/docker-entrypoint-initdb.d # 初期データディレクトリ - ./mysql/testdb:/docker-entrypoint-testdb.d # テスト用初期データ ←追加 - ./my.cnf:/etc/mysql/conf.d/my.cnf environment: MYSQL_DATABASE: *** MYSQL_USER: *** MYSQL_PASSWORD: *** MYSQL_ROOT_PASSWORD: *** TZ: "Asia/Tokyo" ports: - "3306:3306" ``` init_create_db.shはdocker-entrypoint-testdb.d内のsqlファイルを実行するスクリプトです。 init_create_db.sh ``` #!/bin/bash set -e # docker-entrypoint-testdb.d内のSQLファイルを順番に実行 for sql_file in docker-entrypoint-testdb.d/*.sql; do if [ -f "$sql_file" ]; then mysql -u root -p$MYSQL_ROOT_PASSWORD < "$sql_file" else echo "SQLファイルが見つかりません: docker-entrypoint-testdb.d" fi done echo "すべてのSQLファイルを実行" ``` <br> DBを作成し、ユーザーに権限を与えます。(サーバーから接続する際のユーザーを使っています。また、テーブルも作成します。 01_create_testdb.sql ``` CREATE DATABASE finansu_test_db; GRANT ALL PRIVILEGES ON `finansu_test_db`.* TO `{MYSQL_USER名}`@`%` ``` 02_test_users.sql ``` use finansu_test_db; CREATE TABLE users ( id int(10) unsigned not null auto_increment, name varchar(255) not null, bureau_id int(10) not null, role_id int(10) not null, is_deleted boolean DEFAULT false, created_at datetime not null default current_timestamp, updated_at datetime not null default current_timestamp on update current_timestamp, PRIMARY KEY (id) ); ``` ここまででdbを起動すると、テストのdbが作られると思います。 ``` mysql> show databases; +--------------------+ | Database | +--------------------+ | finansu_db | | finansu_test_db | | information_schema | | mysql | | performance_schema | | sys | +--------------------+ ``` ``` mysql> show tables; +---------------------------+ | Tables_in_finansu_test_db | +---------------------------+ | users | +---------------------------+ ``` <br> ## 2. GoのAPIテスト環境作成 次のgoのテスト環境を作成します。 テストでは、テスト用のHTTPサーバーをコード内でたてて、そのサーバーに対して、リクエストを行いテストを行います。 goは自動テストの仕組みが備わっており、`go test ~`で実行することができます。 テストファイルを作成する際の注意点 - ファイルの末尾は`_test.go`にする - テスト関数のシグネチャはfunc TestXxx(t *testing.T)とする - TestMain()は、テストの前後の処理を記述できます。 今回は、テストするインスタンスがDBと接続できないとエラーになるため、TestMainにDBの環境変数を定義しました。ここでは、テストDBの環境変数を定義してください。 sample_test.go ``` package test import ( "io" "net/http" "net/http/httptest" "os" "testing" "github.com/NUTFes/FinanSu/api/internals/di" "github.com/stretchr/testify/assert" ) func TestMain(m *testing.M) { // テスト前処理 os.Setenv("NUTMEG_DB_USER", "***") os.Setenv("NUTMEG_DB_PASSWORD", "***") os.Setenv("NUTMEG_DB_HOST", "***") os.Setenv("NUTMEG_DB_PORT", "3306") os.Setenv("NUTMEG_DB_NAME", "finansu_test_db") // テスト実行 code := m.Run() // テスト後処理 os.Exit(code) } const helloTestMessage = "healthcheck: ok" func TestSampleHelloHandler(t *testing.T) { // インスタンスの生成(DB接続、ルーティング) _, router := di.InitializeServer() // サーバを立てる testServer := httptest.NewServer(router) t.Cleanup(func() { testServer.Close() }) r, err := http.Get(testServer.URL + "/") if err != nil { t.Errorf("Error making request: %s", err) return } defer r.Body.Close() body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("Error reading response body: %s", err) return } // テスト assert.Equal(t, http.StatusOK, r.StatusCode) assert.Equal(t, helloTestMessage, string(body)) } ``` sample_test.goでは、APIのルートへのGETリクエストをテストしました。 `go test {ディレクトリのパス}`で実行します レスポンスが、200で、"healthcheck: ok"と返ってきたので、サーバーが起動し、テストをパスすることができました。 ``` # go test ./test ok github.com/NUTFes/FinanSu/api/test 0.018s ``` <br> DBの接続はdi.InitializeServer()内で行ってます。 Testでは、godotenv.Load("env/dev.env")でロードに失敗するので、(パスがカレントディレクトリになる)別ディレクトリに分けるか、今回のように直接指定してあげるのがいいかと思いました。 ``` err := godotenv.Load("env/dev.env") if err != nil { fmt.Println(err) } dbUser := os.Getenv("NUTMEG_DB_USER") dbPassword := os.Getenv("NUTMEG_DB_PASSWORD") dbHost := os.Getenv("NUTMEG_DB_HOST") dbPort := os.Getenv("NUTMEG_DB_PORT") dbName := os.Getenv("NUTMEG_DB_NAME") // MySQLに接続する // dbconf := "finansu:password@tcp(nutfes-finansu-db:3306)/finansu_db?charset=utf8mb4&parseTime=true" dbconf := dbUser + ":" + dbPassword + "@tcp(" + dbHost + ":" + dbPort + ")/" + dbName + "?charset=utf8mb4&parseTime=true" db, err := sql.Open("mysql", dbconf) ``` ## 3. テストコード作成 テスト環境が完了したので、テストコードを作成しましょう。 その前に、DBにテストデータを入れたいので、testfixturesを使いましょう。testfixturesはテストデータを作成するだけでなくテーブルのclean upもしてくれます。RspecのコントローラーテストのActiveRecord fixturesを参考に作られているみたいです。 https://github.com/go-testfixtures/testfixtures 使い方は`<table_name>.yml`ファイルを作るだけで、テストデータを作ってくれます。 ``` # users.yml - id: 1 name: テスト太郎 bureau_id: 1 role_id: 1 created_at: 2020-12-31 23:59:59 updated_at: 2020-12-31 23:59:59 - id: 2 name: テスト花子 bureau_id: 2 role_id: 2 created_at: 2020-12-31 23:59:59 updated_at: 2020-12-31 23:59:59 ``` 以下テストコード sample_test.go ``` package test import ( "database/sql" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "testing" "github.com/NUTFes/FinanSu/api/internals/di" "github.com/go-testfixtures/testfixtures/v3" "github.com/stretchr/testify/assert" ) var ( db *sql.DB fixtures *testfixtures.Loader ) func TestMain(m *testing.M) { var err error os.Setenv("NUTMEG_DB_USER", "finansu") os.Setenv("NUTMEG_DB_PASSWORD", "password") os.Setenv("NUTMEG_DB_HOST", "nutfes-finansu-db") os.Setenv("NUTMEG_DB_PORT", "3306") os.Setenv("NUTMEG_DB_NAME", "finansu_test_db") db, err = sql.Open("mysql", "{ユーザー名}:{パスワード}@tcp({ipアドレス}:{ポート番号})/{データベース名}") if err != nil { fmt.Println(err) } defer db.Close() fixtures, err = testfixtures.New( testfixtures.Database(db), testfixtures.Dialect("mysql"), testfixtures.Directory("fixtures"), // ここでymlファイルのディレクトリを指定する ) if err != nil { fmt.Printf("Error creating fixtures: %v\n", err) return } // テスト実行 code := m.Run() // テスト後処理 // db.Exec("DELETE FROM users") if err != nil { fmt.Print(err.Error()) } os.Exit(code) } func prepareTestDatabase(t *testing.T) { if err := fixtures.Load(); err != nil { fmt.Println(err) } } func TestGetUserHandler(t *testing.T) { prepareTestDatabase(t) _, router := di.InitializeServer() testServer := httptest.NewServer(router)る t.Cleanup(func() { testServer.Close() }) r, err := http.Get(testServer.URL + "/users") if err != nil { t.Errorf("Error making request: %s", err) return } defer r.Body.Close() body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("Error reading response body: %s", err) return } assert.Equal(t, http.StatusOK, r.StatusCode) assert.Contains(t, string(body), "テスト太郎") } ``` `/users`はユーザー一覧を取得するAPIです。テーブルにユーザーのテストデータを用意し、レスポンスにテスト太郎が返ってくるテストです。 最後にPOSTのテストも作成します。 ``` func TestAddUserHandler(t *testing.T) { prepareTestDatabase(t) _, router := di.InitializeServer() testServer := httptest.NewServer(router) t.Cleanup(func() { testServer.Close() }) u, err := url.Parse(testServer.URL + "/users") if err != nil { return } // クエリパラメータ追加 q := u.Query() q.Set("name", "技大太郎") q.Set("bureau_id", "1") q.Set("role_id", "1") u.RawQuery = q.Encode() fmt.Println(u.String()) r, err := http.Post(u.String(), "application/json", nil) if err != nil { t.Errorf("Error making request: %s", err) return } defer r.Body.Close() body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("Error reading response body: %s", err) return } assert.Equal(t, http.StatusCreated, r.StatusCode) assert.Contains(t, string(body), "技大太郎") } ``` テスト実行後もテストDBのテーブルは空なので、clean upもしてくれてますね。 ``` mysql> select * from users; Empty set (0.00 sec) ``` 長くなりましたが、Goの実DBを使ったAPIテストの導入について紹介させていただきました! フロントのテストも導入したいですね。導入した際には、ブログを書くかもです。 まだまだNUTMEGのアドカレは毎日更新です。色々な内容があって面白いと思うのでぜひご覧ください! # 参考 [テスト自動化とは? ツール導入のメリットや流れを徹底解説](https://www.skygroup.jp/software/quality/article/02/) [Go言語でテストコードを書いてみよう](https://note.com/rescuenow_hr/n/n9ed7caf4646d) [GoのWebアプリをテストするノウハウ](https://zenn.dev/media_engine/articles/testing-go-applications) [Go言語のHTTPサーバのテスト事始め](https://qiita.com/theoden9014/items/ac8763381758148e8ce5) [Go Test Fixtures](https://pkg.go.dev/github.com/go-testfixtures/testfixtures#section-readme) [テスト(go test/testing)](https://www.twihike.dev/docs/golang-primer/testing)