🤸

go testでgodotenv.Load()が.envを読み込んでくれない

に公開

Goで簡単なAPIサーバーを作成しているとき,テストを実行するとgodotenvで.envファイルが上手く読み込めないことがありました.その原因と解決方法を調べたのでまとめました.

godotenv

godotenvはGoで.envファイルを簡単に読みこむ機能を提供するライブラリーです.

.envファイルはある特定のアプリケーションが利用する環境変数を記述したファイルです.例えばプログラム中でデータベースに接続するとき,データベースの認証情報やユーザー名,パスワードが必要になります.これらのデータをハードコーディングしてしまうと,知られたくない情報がプログラムの内部に埋め込まれてしまいセキュリティの漏洩などにつながる恐れがあります.そこで.envファイルというテキストファイルをプログラムの外部に用意し,.envファイルの内部で記述した秘密情報をプログラムが実行時に参照する方法をとります.

.envファイルは簡単なテキストファイルなので直接Goのコードから読み込むことも可能ですが,godotenvを使うとたった1行で読み込めます.

.env
EXAMPLE_URL=example.com
main.go
import (
	os

	"github.com/joho/godotenv"
)

func main() {
	// これだけ
	if err := godotenv.Load(); err != nil {
		os.Exit(1)
	}

	url := os.Getenv("EXAMPLE_URL")
}

godotenv.Load()はプログラムを実行したカレントディレクトリーに存在する.envファイルを読み込みます.読み込んだ.envファイルに記載された環境変数はos.Getenv()で取得します.なお読み込む.envファイルはgodotenv.Load(パス)でファイルの指定が可能です.

問題のコード

APIサーバーを実装するとき,PostgreSQLデータベースへの接続に必要な情報をプロジェクトのルートディレクトリーの.envファイルに記載しました.また今回はデータベースへのアクセス処理をDDD(ドメイン駆動開発)におけるリポジトリーで行うことにし,repository/hoge_repository.goに処理を実装しました.

repository/hoge_repository.go
package repository

import (
	"log"
	"os"

	"github.com/joho/godotenv"
)

func init() {
	// .envから環境変数を読み込む
	godotenv.Load()

	if os.Getenv("EXAMPLE_URL") == "" {
		log.Panicln("EXAMPLE_URL is not set!")
	}

	// 初期化処理を行う
}

// ダミーの関数
func Hoge() string {
	return "a"
}

またリポジトリーのテストも行うため,repository/hoge_repository_test.goにテストを記述しました.

repository/hoge_repository_test.go
package repository

import "testing"

func TestHoge(t *testing.T) {
	if Hoge() != "a" {
		t.Fatal()
	}
}

このプロジェクトのルートディレクトリーでプログラムをgo run main.goで実行したとき,リポジトリーのinit()では問題なくプロジェクトのルートディレクトリーに存在する.envファイルを読み込みました.

しかし,go test ./...によるプログラムのテストを実行すると,"panic: EXAMPLE_URL is not set"というログが表示されてパニックが発生しました.

原因

この問題の原因は,go run main.gogo test ./...で実行されるプログラム中のカレントディレクトリーが異なるためです.

godotenv.Load()は内部で以下のコードを実行しています.

https://github.com/joho/godotenv/blob/3fc4292b58a67b78e1dbb6e47b4879a6cc602ec4/godotenv.go#L206-L214

このreadFile(filename)ではos.Open(filename)を使ってプログラムのカレントディレクトリーを起点にしたfilenameにあるファイルを読み込みます.

godotenv.Load()の場合はfilenameにデフォルトの値である ".env" が入ります.そのため実行しているプログラムのカレントディレクトリーに存在する.envファイルを読み込もうとします.

では実行しているプログラムのカレントディレクトリーはどこになるでしょうか.godotenv.Load()が呼ばれている関数でos.Getwd()を実行してカレントディレクトリーを表示してみます.

repository/hoge_repository.go
// .envから環境変数を読み込む
godotenv.Load()

+wd, _ := os.Getwd()
+log.Println(wd)

if os.Getenv("EXAMPLE_URL") == "" {
	log.Panicln("EXAMPLE_URL is not set!")
}

ここでプロジェクトのルートディレクトリーを$PROJ_DIRとすると,コマンドを実行例とそのときのos.Getwd()の出力結果は以下のようになります.

シェルのカレントディレクトリー コマンド os.Getwd()の戻り値
$PROJ_DIR go run main.go $PROJ_DIR
$PROJ_DIR/repository go run ../main.go $PROJ_DIR/repository
$PROJ_DIR go test ./... $PROJ_DIR/repository

go runの時のカレントディレクトリーは,コマンドを実行したときのシェルのカレントディレクトリーになります.一方でgo testのカレントディレクトリーは,テスト対象となるパッケージの存在するディレクトリーになります.つまりテストを実行すると,テスト対象の実行ごとにカレントディレクトリーが移動します.

今回の場合,go run main.goのときは意図したとおり,プロジェクトのルートディレクトリーに存在する.envファイルを読み込みます.一方go test ./...のときは,テスト対象であるrepository/hoge_repository_test.goの存在するディレクトリーにある.envファイルを読みだそうとします.しかしそのようなファイルはないためEXAMPLE_URLは空のままになります.これが今回の問題の原因でした.

解決方法

解決方法はいくつか考えられます.

テスト用の.envを用意する

テスト実行時に.envファイルを読みたいので,テスト実行時に読みだされる場所に.envファイルを用意します.

通常のプログラム実行時と同じ環境変数を使うのであれば,元の.envファイルをそのままテスト対象のパッケージと同じディレクトリーにコピーします.テスト実行時に一部の環境変数を変更したい場合は,専用の.envファイルをその場所に作成すれば良いです.

.envファイルを探索する処理を実装する

プロジェクトのルートディレクトリーに存在する.envファイルをプログラムのカレントディレクトリーから探索し,取得した.envファイルのパスをgodotenv.Load()の引数に渡して.envファイルの読み込みを実行します.

テスト対象となるパッケージはルートディレクトリーのサブディレクトリーになります.つまりプログラムのカレントディレクトリーの親ディレクトリーのどこかに.envファイルが存在することになります.そこでカレントディレクトリーを探索起点として,.envファイルが見つかるまで再帰的に親ディレクトリーを探索し続ける処理を実装します.

repository/hoge_repository.go
func getEnvFilePath() (string, error) {
	// プログラムのカレントディレクトリーを探索起点にする
	dir, _ := os.Getwd()

	for {
		// 探索ディレクトリーに.envファイルが存在するか確認する
		envPath := filepath.Join(dir, ".env")
		if info, err := os.Stat(envPath); err == nil && !info.IsDir() {
			return envPath, nil
		}

		// 探索ディレクトリーを親ディレクトリーへ移動させる
		parentDir := filepath.Dir(dir)
		if parentDir == dir {
			return "", errors.New(".env file was not found")
		}

		dir = parentDir
	}
}

この関数から得られる.envファイルのパスをgodotenv.Load()の引数に渡すことで,プログラムのカレントディレクトリーに依らずプロジェクトのルートディレクトリーに存在する.envファイルを読み込めます.

repository/hoge_repository.go
-	godotenv.Load()
+	path, _ := getEnvFilePath()
+	godotenv.Load(path)

通常実行時とテスト時で.envファイルの読み込み処理を別々に実装する

init()関数で.envファイルを読み込む必要が無ければ,パッケージの外で.envファイルを読み込むようにします.そして通常実行時とテスト時でそれぞれ必要な.envファイルの読み込み処理を別々に実装します.

init()は主にパッケージ内の変数の初期化に使われ,通常のプログラム実行時及びテスト実行時のパッケージロードのタイミングで1度だけ実行されます.この動作は初期化用の関数を別に用意して,パッケージで公開している変数や関数をプログラムで使用する前に呼び出す方法でも実現できます.

この初期化用関数は,通常実行時にmain.goのmain()の内部で,テスト実行時には***_test.goのTestMain()Test***()の内部で呼び出すようにします.ここで初期化関数の呼び出し前に,読み出したい.envファイルへの適切なパスをgodotenv.Load()に渡します.こうすることで通常実行時とテスト時の双方で正しく.envファイルを読み込みます.

repository/hoge_repository.go
// 初期化に依存する環境変数をまとめた構造体
type Config struct {
	ExampleURL string
}

// 初期化関数
func Initialize(config *Config) {
	if config.ExampleURL == "" {
		log.Panicln("EXAMPLE_URL is not set")
	}

	// 初期化
}
main.go
	// .envファイルを読み込み初期化
	godotenv.Load()
	repository.Initialize(&repository.Config{
		ExampleURL: os.Getenv("EXAMPLE_URL"),
	})
repository/hoge_repository_test.go
func TestMain(m *testing.M) {
	// .envファイルのパスを指定して設定を読み込み初期化
	godotenv.Load("../.env")
	Initialize(&Config{
		ExampleURL: os.Getenv("EXAMPLE_URL"),
	})
}

今回はリポジトリーのデータベース接続設定をinit()で行わなくても良かったので,この方法で問題を解決しました.

まとめ

godotenvは簡単に.envファイルを読み込むことができて便利ですが,ファイル読み込みに関して実行環境依存になる部分があるため注意が必要です.読み込むファイルの指定はデフォルト設定に任せるのではなく,利用者が明示的にパスを指定するのが良さそうです.

Discussion