Nutmeg advent calendar 2024

Goの実DBを用いたAPIテストの導入

Goの実DBを用いた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

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ファイルを実行"

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                     |
+---------------------------+

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

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のアドカレは毎日更新です。色々な内容があって面白いと思うのでぜひご覧ください!

参考

テスト自動化とは? ツール導入のメリットや流れを徹底解説 Go言語でテストコードを書いてみよう GoのWebアプリをテストするノウハウ Go言語のHTTPサーバのテスト事始め Go Test Fixtures テスト(go test/testing)