💉

JSONファイルに読み書きするようなリポジトリ層のテストのためにDIしたい

に公開

リポジトリ層の実装として、プロダクションコードではファイルに読み書きし、テストではインメモリに読み書きできるようにするため、インターフェースを活用し、テストとプロダクションの環境に応じて適切な実装をDIする仕組みを考えてみました。
DI(依存性注入)のメリットは、コードの柔軟性とテストの効率性を向上させることです。これにより、異なる環境に応じた実装の切り替えが容易になり、再利用性も高まります。
本記事では、その具体的な工夫をGo言語での実装方法とともに紹介します。

背景

実務ではあまりないかもしれませんが、お勉強の一環で何らかのデータをJSONファイルに読み書きするというケースがありました。オニオンアーキテクチャで実装しようとしたとき、JSONファイルに対してアクセスするのはリポジトリ層の責務になります。このとき、テストを高速に回すため、リポジトリのテストコードではファイル操作するのではなくインメモリで読み書きしたくなりました。
そこでリポジトリでDIできるようにします。

DIできるリポジトリの実装

インタフェースの定義

このようなリポジトリがあったとします。

domain/hoge_repository.go
type HogeRepository interface {
	Save(hoge *Hoge) error
	// その他メソッド
}

コンストラクタ

プロダクションコードでは os.File を、テストコードではインメモリな何か(後述)を渡せるように、対象のリポジトリをこのように定義します。

infra/hoge_json_repository.go
type TruncatableFile interface {
	io.Reader
	io.Writer
	io.Seeker
	io.Closer
	Truncate(size int64) error
}

type HogeJsonRepository struct {
	file  TruncatableFile
}

func NewHogeJsonRepository(file TruncatableFile) *HogeJsonRepository {
	return &HogeJsonRepository{file: file}
}

ここでのポイントは、独自のinterface TruncatableFile を定義したところです。 os.File には Truncate() が備わっていますが、 io.Reader 等には Truncate() がないためです。

Saveメソッド

Saveメソッドを次のように実装します。
JSONファイルに書き込む際、既存のデータを一度削除して、全データを書き見直すというアプローチを取ることにしました。今回はお勉強のためなので、コードのシンプルさを優先しています。他の選択肢としては部分的な更新や追記操作も考えられましたが、それらは実装が複雑になってしまいます。さらに、このアプローチならファイルの内容が完全に上書きされるため、古いデータが残るリスクを回避できます。

infra/hoge_json_repository.go
func (r *HogeJsonRepository) Save(hoge *domain.Hoge) error {
	hoges, err := r.readJson()
	if err != nil {
		return fmt.Errorf("failed to find all hoges: %w", err)
	}

	// 既にIdが一致するデータがあれば更新する
	for i, h := range hoges {
		if h.Id == hoge.Id {
			hoges[i] = *hoge
			return r.writeJson(hoges)
		}
	}

	hoges = append(hoges, *hoge)
	return r.writeJson(hoges)
}

func (r *HogeJsonRepository) readJson() ([]domain.Hoge, error) {
	// ファイルポインタを先頭に戻す
	if _, err := r.file.Seek(0, io.SeekStart); err != nil {
		return nil, fmt.Errorf("failed to seek file: %w", err)
	}

	// JSONを読み込んでデコード
	var hoges []domain.Hoge
	decoder := json.NewDecoder(r.file)
	if err := decoder.Decode(&hoges); err != nil && err != io.EOF {
		fmt.Println(err)
		return nil, fmt.Errorf("failed to decode JSON: %w", err)
	}
	return hoges, nil
}

func (r *HogeJsonRepository) writeJson(hoges []domain.Hoge) error {
	// ファイルポインタを先頭に戻す
	if _, err := r.file.Seek(0, io.SeekStart); err != nil {
		return fmt.Errorf("failed to seek file: %w", err)
	}
	// 一度データを全削除する
	if err := r.file.Truncate(0); err != nil {
		return fmt.Errorf("failed to truncate file: %w", err)
	}
	// JSONにエンコードしてファイルに書き込み
	encoder := json.NewEncoder(r.file)
	if err := encoder.Encode(hoges); err != nil {
		return fmt.Errorf("failed to encode hoge: %w", err)
	}
	return nil
}

テストコード

テストコードは以下のようになります。

infra/hoge_json_repository_test.go
func TestHogeJsonRepository(t *testing.T) {
	t.Run("Save", func(t *testing.T) {
		// ファイルの新規作成
		fs := afero.NewMemMapFs()
		file, createErr := fs.Create("test.json")
		if createErr != nil {
			t.Fatalf("failed to create file: %v", createErr)
		}
		defer file.Close()

		// 初期値のセットアップ
		repo := NewHogeJsonRepository(file)
		hoge := &domain.Hoge{Id: 1, Name: "hoge"}
		fuga := &domain.Hoge{Id: 2, Name: "hoge"}  // 後でNameを更新する
		if err := repo.Save(hoge); err != nil {
			t.Errorf("failed to save hoge: %v", err)
		}
		if err := repo.Save(fuga); err != nil {
			t.Errorf("failed to save fuga: %v", err)
		}

		// 特定の要素を更新して保存する
		fuga.Name = "fuga"
		if err := repo.Save(fuga); err != nil {
			t.Errorf("failed to save fuga: %v", err)
		}

		expected := []domain.Hoge{*hoge, *fuga}
		actual := []domain.Hoge{}

		// ファイルポインタを先頭に戻す
		file.Seek(0, io.SeekStart)

		// ファイルから読み込んでJSONをデコード
		decoder := json.NewDecoder(file)
		if decodeErr := decoder.Decode(&actual); decodeErr != nil {
			t.Fatalf("failed to decode json: %v", decodeErr)
		}
		// 更新後のデータも含めて一致していることをテスト
		if reflect.DeepEqual(expected, actual) == false {
			t.Errorf("unexpected saved data: %v", actual)
		}
	})
}

インメモリでファイル操作ができるようにaferoを利用しています。 os.File と高い互換性を持つメソッドが揃っており、独自実装の必要がない点が大きなメリットです。
ここで注意が必要なのは、 Save() でJSONが書き込まれるので、テスト側でそのJSONを読み込もうとする場合、一度ファイルの先頭にポインタを戻してあげる必要があるため、都度 Seek() してあげます。

まとめ

os.File と互換性のある afero を使ってインメモリでファイル操作できるようにしてテストを高速に実行できる方法について紹介しました。この方法の主なメリットは、ファイルシステムに依存しないためテストの実行速度が大幅に向上すること、そしてテスト環境での設定やクリーンアップが簡単になることです。
そもそもJSONファイルに保存しておくリポジトリを実装するケースが実務上は少ないかもしれませんが、大規模なアプリケーションにおいても、単体テストや統合テストではインメモリで読み書きするというアプローチは非常に有用です。例えば、CI/CDパイプライン内でテストを高速かつ効率的に実行する際に特に役立つでしょう。

Discussion