📝

docker-compose + Github ActionsでRepositoryのテスト環境構築を楽にする

2022/08/19に公開

Repositoryのテストをする際、
ローカルでテスト用のDBを立てて、CIでも同じ環境を再現するためにyamlなどの定義ファイルをいじって…
ってけっこうめんどくさいです
今回はdocker-composeGithub Actions
単一のdocker-compose.yamlでローカル/CIの両環境を構築できるようにし、楽かつクリーンを目指します

結論だけ先に書いてしまうと、
Github Actions上でdocker composeが実行可能なのでそれをするだけだったりします

ソースコード全文はこちら
https://github.com/TadayoshiOtsuka/go_ci_sample

Repositoryのテストコードの準備

Goでやっていきます
ただ今回テストコード自体にはあまり意味はないので読み飛ばしても大丈夫です

環境変数, DB関連の定義
app/pkg/config/config.go
package config

import (
	"os"
)

type Config struct {
	DB struct {
		Driver   string
		Port     string
		Host     string
		UserName string
		Password string
		DBName   string
	}
}

var AppConfig *Config

func init() {
	AppConfig = newConfig()
}

func newConfig() *Config {
	c := &Config{}
	c.DB.Driver = os.Getenv("DB_DRIVER")
	c.DB.Port = os.Getenv("DB_PORT")
	c.DB.Host = os.Getenv("DB_HOST")
	c.DB.UserName = os.Getenv("DB_USER")
	c.DB.Password = os.Getenv("DB_PASSWORD")
	c.DB.DBName = os.Getenv("DB_NAME")

	return c
}

app/pkg/rdb/rdb.go
package rdb

import (
	"fmt"
	"log"

	"github.com/TadayoshiOtsuka/go_test_sample/app/pkg/config"
	"github.com/TadayoshiOtsuka/go_test_sample/app/pkg/rdb/orm/ent"
	_ "github.com/go-sql-driver/mysql"
)

type SQLHandler struct {
	Client *ent.Client
}

func NewSQLHandler() *SQLHandler {
	dsn := fmt.Sprintf(
		"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=true&loc=Local",
		config.AppConfig.DB.UserName,
		config.AppConfig.DB.Password,
		config.AppConfig.DB.Host,
		config.AppConfig.DB.Port,
		config.AppConfig.DB.DBName,
	)

	c, err := ent.Open(config.AppConfig.DB.Driver, dsn)
	if err != nil {
		log.Panicf("DB Connection Error:%v", err)
	}

	return &SQLHandler{Client: c}
}

Repositoryの定義と実装
app/individual/repository/user.go
package repository

import (
	"context"

	"github.com/TadayoshiOtsuka/go_test_sample/app/individual/server/entity"
	"github.com/TadayoshiOtsuka/go_test_sample/app/pkg/rdb"
)

type IUserRepository interface {
	Create(ctx context.Context, user *entity.User) (*entity.User, error)
}

type UserRepository struct {
	db *rdb.SQLHandler
}

func NewUserRepository(db *rdb.SQLHandler) IUserRepository {
	return &UserRepository{
		db: db,
	}
}

func (r *UserRepository) Create(ctx context.Context, user *entity.User) (*entity.User, error) {
	u, err := r.db.Client.User.
		Create().
		SetName(user.Name).
		Save(ctx)
	if err != nil {
		return nil, err
	}

	return entity.NewUser(u.ID, u.Name)
}

Repositoryのテスト
/app/individual/server/repository/test/user_test.go
package repository_test

import (
	"context"
	"reflect"
	"testing"

	"github.com/TadayoshiOtsuka/go_test_sample/app/individual/server/entity"
	"github.com/TadayoshiOtsuka/go_test_sample/app/individual/server/repository"
	"github.com/TadayoshiOtsuka/go_test_sample/app/pkg/rdb"
)

const wantErr, noErr = true, false

func TestCreate(t *testing.T) {
	cases := map[string]struct {
		input   *entity.User
		want    *entity.User
		wantErr bool
	}{
		"success": {
			&entity.User{ID: 1, Name: "John Titer"},
			&entity.User{ID: 1, Name: "John Titer"},
			noErr,
		},
	}

	for name, tt := range cases {
		t.Run(name, func(t *testing.T) {
			db := rdb.NewSQLHandler()
			repo := repository.NewUserRepository(db)

			got, err := repo.Create(context.TODO(), tt.input)

			if tt.wantErr {
				if err == nil {
					t.Errorf("FAIL: wantErr: %v, but got: %v", tt.wantErr, err)
				}
			} else {
				if err != nil {
					t.Errorf("FAIL: wantErr: %v, but got: %v", tt.wantErr, err)
				}
			}
			if !reflect.DeepEqual(tt.want, got) {
				t.Errorf("FAIL: want: %v, but got: %v", tt.want, tt.want)
			}
		})
	}
}

仮のエントリーポイント

本来はhttpサーバーとかになると思いますが、
テストだけ実行できればいいので仮の実装をしておきます
channelの待ち受けをしているのは、
コンテナを生かし続けてdocker compose execでGoのテストコマンドを実行したいからです

app/cmd/server/main.go
package main

import (
	"log"
	"os"
	"os/signal"
	"syscall"
)

func main() {
	log.Print("running...")
	cs := make(chan os.Signal, 1)
	signal.Notify(cs, syscall.SIGINT)
	<-cs
	os.Exit(0)
}

DBのマイグレーションの準備

テストする時は毎回DBはまっさらな状態から行うのでスキーマのマイグレーションも都度やることにします
何らかのワークフローに組み込みたい時に便利なので、
別バイナリとしてbuildするパターンを想定します

Migrationの実装とエントリーポイント

ORMにentを使ってます

app/individual/migration/migration.go
package migration

import (
	"context"
	"log"

	"github.com/TadayoshiOtsuka/go_test_sample/app/pkg/rdb"
	"github.com/TadayoshiOtsuka/go_test_sample/app/pkg/rdb/orm/ent"
	"github.com/TadayoshiOtsuka/go_test_sample/app/pkg/rdb/orm/ent/migrate"
	_ "github.com/go-sql-driver/mysql"
)

type MigrateHandler struct {
	client *ent.Client
}

func NewMigrateHandler() *MigrateHandler {
	c := rdb.NewSQLHandler().Client

	return &MigrateHandler{client: c}
}

func (h *MigrateHandler) Migrate() {
	err := h.client.Schema.Create(
		context.Background(),
		migrate.WithDropIndex(true),
		migrate.WithDropColumn(true),
	)
	if err != nil {
		log.Fatalf("Migrate Error: %v", err)
	}
}
app/cmd/migration/main.go
package main

import (
	"github.com/TadayoshiOtsuka/go_test_sample/app/individual/migration"
)

func main() {
	migration.NewMigrateHandler().Migrate()
}

Dockerfile, docker-compose.yamlの準備

Server用のDockerfile
docker/server/Dockerfile
# ============
# Builder
# ============
FROM golang:1.18.3 as Builder

ENV TZ=Asia/Tokyo
ENV CGO_ENABLED=0
ENV GOOS=linux
ENV GOARCH=amd64
ENV ROOTPATH=/go/src

WORKDIR ${ROOTPATH}

COPY go.mod go.sum ./
RUN go mod download

COPY app/ ${ROOTPATH}/app/

RUN go build \
    -o ${ROOTPATH}/bin/main \
    -ldflags '-s -w' \
    ./app/cmd/server/main.go


# ============
# Deploy
# ============
FROM gcr.io/distroless/static-debian11
ENV TZ=Asia/Tokyo

COPY --from=Builder /go/src/bin/main /src/main

EXPOSE 8000
USER nonroot:nonroot
CMD ["/src/main"]
Migration用のDockerfile
docker/migration/Dockerfile
# ============
# Builder
# ============
FROM golang:1.18.3 as Builder

ENV TZ=Asia/Tokyo
ENV CGO_ENABLED=0
ENV GOOS=linux
ENV GOARCH=amd64
ENV ROOTPATH=/go/src

WORKDIR ${ROOTPATH}

COPY go.mod go.sum ./
RUN go mod download

COPY app/ ${ROOTPATH}/app/

RUN go build \
    -o ${ROOTPATH}/bin/main \
    -ldflags '-s -w' \
    ./app/cmd/migration/main.go


# ============
# Deploy
# ============
FROM gcr.io/distroless/static-debian11
ENV TZ=Asia/Tokyo

COPY --from=Builder /go/src/bin/main /src/main

USER nonroot:nonroot
CMD ["/src/main"]
docker-compose.test.yaml
version: "3.9"

services:
  server_test:
    container_name: server-test
    build:
      context: .
      dockerfile: ./docker/server/Dockerfile
      target: Builder
    command: go run ./app/cmd/server/main.go
    env_file:
      - .env.test
    volumes:
      - type: bind
        source: app
        target: /go/src/app
    depends_on:
      migrate_test:
        condition: service_completed_successfully

  migrate_test:
    container_name: migrate-test
    build:
      context: .
      dockerfile: ./docker/migration/Dockerfile
      target: Builder
    command: go run ./app/cmd/migration/main.go
    env_file:
      - .env.test
    volumes:
      - type: bind
        source: app
        target: /go/src/app
    depends_on:
      db_test:
        condition: service_healthy

  db_test:
    container_name: mysql-test
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: test
    ports:
      - 3306:3306
    healthcheck:
      test: ["CMD", "mysqladmin", "ping"]
      interval: 10s
      retries: 20
    tmpfs:
      - /var/lib/mysql
    volumes:
      - type: volume
        source: mysql-volume
        target: /var/lib/mysql

volumes:
  mysql-volume:

ざっくり解説します

  • 処理の流れ
    => mysql起動 -> 起動確認後Migrate実行 -> Migrate実行成功後Server起動

  • 各serviceの起動順序と期待通りの起動タイミングの担保
    =>
    Migrateする前にmysqlのImageが起動している必要がありますが起動に時間がかかるためdepends_onで単に起動順序を定義するだけだとコケてしまいます。
    なので、depends_oncondition: service_healthy
    を追加して、ヘルスチェックが通ってからMigrateを実行するようにしています。
    ただmysqladmin pingが通った後もDBコネクションが確立できずにこけるケースがあるため、
    ヘルスチェックの間隔をintervalで少し長めに設定しています

ローカルでテストする

docker compose -f docker-compose.test.yaml --env-file .env.test up -d
docker compose -f docker-compose.test.yaml --env-file .env.test exec server_test go test -v -coverpkg=./... ./.../test

docker compose up -dでコンテナを立ち上げてから、docker compose execでテストコマンドを実行させます。
テスト環境向けの環境変数は.env.testから注入してあげます

本当はdocker-compose.yamlに直でテストコマンドを書いてdocker compose upだけで完結させたかったんですが、
docker-compose upで特定のサービスの終了コードを取得するための--exit-code-from SERVICEオプションが、
--abort-on-container-exit(どれか一つのサービスが終了したら全サービスを終了させる)も暗黙的に付与してしまうので、
Migrateのサービスが終了した時点で全サービスが終了してしまい、テストコマンド実行まで到達できませんでした。
終了コードが正しく取れないとCIに乗せることができないので今回は諦めました

Github Actionsでテストする

Github Actions上でローカルと同じコマンドを実行させるだけです

.github/workflows/test.yaml
name: test

on:
  push:
    branches:
      - "master"

jobs:
  test:
    name: Run tests
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v3
      - name: Checkout And Build
        run: docker compose -f docker-compose.test.yaml --env-file .env.test up -d

      - name: Test
        run: docker compose -f docker-compose.test.yaml --env-file .env.test exec server_test go test -v -coverpkg=./... ./.../test

おわりに

ほぼ備忘録なので駆け足になりましたがこれで終わりです
Github Actions独自のyamlをごちゃごちゃ書く必要がないから楽
・ローカルでテストするために定義したdocker-compose.yamlをほぼそのまま使えるから楽
・単一のdocker-compose.yamlに定義がまとまるのでクリーン
で気に入ってます、何かの役に立てば幸いです
もっといいやり方あれば教えてもらえるとめちゃ嬉しいです

参考にさせていただいたリンク

https://zenn.dev/snowcait/articles/0cc4a464610e61

Discussion