🐁

Go で E2E テストを軽率に実行しよう ~ sqlite を添えて ~

2024/05/06に公開

はじめに

zitadel/oidc という OSS にコントリビュートしたときに net/http/httptest という存在を知りました。 
このライブラリは Go が標準機能として提供しているものであり、 テスト実行時にサーバーを起動することができます。

私はこれまで E2E テストを実行するときには docker compose でローカル環境をガチガチに構築してそこに対して実行していました。
CI でも同様にサーバーとデータベースを立ち上げて実行していました。
まあ地味にめんどくさいです。

今回はちゃちゃっと httptest を使って Go サーバーに対して E2E テストを書いてみたのでご紹介です。
データベースに関しても modernc.org/sqlite というライブラリを使って貫通して実行しています。

go version

go version go1.22.2 darwin/arm64

modernc.org/sqlite

Package sqlite is a cgo-free port of SQLite.

公式サイトによると cgo に依存せず SQLite を利用することができるライブラリとのことです。
ブランクインポートにより依存関係を含め sql.Open() にて接続設定を行います。

package test

import (
	"database/sql"
	"os"
	"testing"

	"github.com/google/uuid"

	_ "modernc.org/sqlite"

func Test(t *testing.T) {
	t.Parallel()

	file := uuid.NewString()

	db, err := sql.Open("sqlite", "file:"+file+"?cache=shared")
	if err != nil {
		t.Fatal(err)
	}

	if db.Ping() != nil {
		t.Fatal(err)
	}

	t.Cleanup(func() {
		if err := db.Close(); err != nil {
			t.Error(err)
		}

		if err := os.Remove(file); err != nil {
			t.Error(err)
		}
	})

	ddl := `
	CREATE TABLE users (
		id   TEXT PRIMARY KEY,
		name TEXT NOT NULL,
		created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
		updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
	)
	`

	if _, err := db.Exec(ddl); err != nil {
		t.Fatal(err)
	}
}

テストに使うデータベースは uuid を使ってテストごとに作成しています。
マイグレーションは DDL を用意してクエリを実行します。
(標準ライブラリでの実装しか考えていないです。マイグレーションツールを活用している場合は exec.Command() を使って外部コマンドを実行するとよさそうです。)

net/http/httptest

httptest は大きく分けて 2 つの形式でテストが実行可能です。

  1. NewServer で起動したサーバーを利用した検証
  2. ResponseRecoder を利用した検証

今回はサーバーを起動する方式で行っています。
エンドポイントが多い場合は ResponseRecoder を活用した方が良さそうです。
(ResponseRecoder に関して、私の検証不足ですが Go 1.22 で追加された Request.PathValue をうまく動かせませんでした。)

実装

今回テストに使ったディレクトリ構成は以下になります。

└── internal
    ├── gateway
    │   ├── gateway.go
    │   └── gateway_test.go
    └── handler
        ├── handler.go
        └── handler_test.go

gateway (DB操作が責務) は sql.DB への依存を持ちます。

https://github.com/otakakot/sample-go-server-db-test/blob/main/internal/gateway/gateway.go

handler (リクエスト操作が責務) は gateway への依存を持ちます。

https://github.com/otakakot/sample-go-server-db-test/blob/main/internal/handler/handler.go

interface は一切宣言していないです。 sql.Open で接続先を切り替えることができるので本番では PostgreSQL を設定、テストでは SQLite を設定するように実装が可能となります。
( mock の実装を必要とせずに切り替えられるの便利。 )

E2E テスト

package handler_test

import (
	"bytes"
	"database/sql"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"testing"

	"github.com/google/uuid"

	_ "modernc.org/sqlite"

	"github.com/otakakot/sample-go-server-db-test/internal/gateway"
	"github.com/otakakot/sample-go-server-db-test/internal/handler"
)

func Test(t *testing.T) {
    t.Parallel()

    // データベースの初期化処理をここで行う。省略

    gw := gateway.New(db)

    hdl := handler.New(gw)

    // Handler の設定
    mux := http.NewServeMux()
    mux.HandleFunc("POST /users", hdl.CreateUser)
    mux.HandleFunc("GET /users/{id}", hdl.ReadUser)
    mux.HandleFunc("PUT /users/{id}", hdl.UpdateUser)
    mux.HandleFunc("DELETE /users/{id}", hdl.DeleteUser)

    // テスト用のサーバーを起動
    srv := httptest.NewServer(mux)

    // 以下で URL が取得できる
    // srv.URL

    // 以下のような実装で実際にクライアントからリクエストを送信する
    req, err := http.NewRequest(http.MethodPost, srv.URL+"/users", bytes.NewBufferString(`{"name":"`+name+`"}`))
    if err != nil {
        t.Fatal(err)
    }

    res, err := http.DefaultClient.Do(req)
    if err != nil {
        t.Fatal(err)
    }

    if res.StatusCode != http.StatusOK {
        t.Errorf("got %d, want %d", res.StatusCode, http.StatusOK)
    }

このような実装で実際にサーバーを起動してテストを行うことができます。

GitHub Actions での実行

以下で実際に確認してみましたが問題なく動作しました。

https://github.com/otakakot/sample-go-server-db-test/actions/runs/8965031980

おわりに

システムのファーストユーザーはテストコードなんだからテストを書きやすいように実装しちゃっていいですよね ... ???

SQLite が PostgreSQL や MySQL とどこまで互換性あるかわかっていないです。
ここが辛いようなら ory/dockertest の出番ですね。

今回実装したコードは以下に置いておきます。

https://github.com/otakakot/sample-go-server-db-test

Discussion