docker-compose + Github ActionsでRepositoryのテスト環境構築を楽にする
Repository
のテストをする際、
ローカルでテスト用のDBを立てて、CIでも同じ環境を再現するためにyaml
などの定義ファイルをいじって…
ってけっこうめんどくさいです
今回はdocker-compose
とGithub Actions
で
単一のdocker-compose.yaml
でローカル/CIの両環境を構築できるようにし、楽かつクリーンを目指します
結論だけ先に書いてしまうと、
Github Actions
上でdocker compose
が実行可能なのでそれをするだけだったりします
ソースコード全文はこちら
Repositoryのテストコードの準備
Goでやっていきます
ただ今回テストコード自体にはあまり意味はないので読み飛ばしても大丈夫です
環境変数, DB関連の定義
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
}
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の定義と実装
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のテスト
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のテストコマンドを実行したいからです
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
を使ってます
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)
}
}
package main
import (
"github.com/TadayoshiOtsuka/go_test_sample/app/individual/migration"
)
func main() {
migration.NewMigrateHandler().Migrate()
}
Dockerfile, docker-compose.yamlの準備
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 /go/src/bin/main /src/main
EXPOSE 8000
USER nonroot:nonroot
CMD ["/src/main"]
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 /go/src/bin/main /src/main
USER nonroot:nonroot
CMD ["/src/main"]
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_on
にcondition: 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上でローカルと同じコマンドを実行させるだけです
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
に定義がまとまるのでクリーン
で気に入ってます、何かの役に立てば幸いです
もっといいやり方あれば教えてもらえるとめちゃ嬉しいです
参考にさせていただいたリンク
Discussion