> ## Documentation Index
> Fetch the complete documentation index at: https://villagesql.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Testcontainers로 MySQL 테스트하기

> 데이터베이스를 모킹하는 대신 Testcontainers를 사용해서 임시 MySQL 또는 VillageSQL 컨테이너에 대해 실제 통합 테스트를 실행하는 방법: 설정, 스키마 로드, 정리, 그리고 모의 데이터베이스가 잡아내지 못하는 것입니다.

> [card] [VillageSQL is a drop-in replacement for MySQL with extensions.](https://villagesql.com/docs/en/8.4/stable/install-server.md)
>
> All examples on this page work on VillageSQL. Install Now →

데이터베이스 드라이버를 모킹하면 테스트가 빠르게 실행되지만, 모의 객체는 반환하라고 지시한 것만 반환할 수 있습니다. 고유 제약 조건을 강제하거나, strict 모드를 위반하는 값을 거부하거나, 실제 스키마를 적용할 수 없습니다. Testcontainers는 테스트를 위해 실제 일회용 MySQL 컨테이너를 시작하고 끝난 뒤 제거하므로, 테스트는 애플리케이션이 프로덕션에서 사용하는 것과 같은 서버에 대해 실행됩니다.

## Testcontainers가 테스트 스위트에 들어맞는 방식

Testcontainers는 별도의 서비스가 아니라 라이브러리입니다. 개발 머신이나 CI 러너에서 이미 실행 중인 Docker 데몬과 통신하고, 테스트 본문이 실행되기 전에 컨테이너를 시작하고, 컨테이너가 실제로 연결을 받을 준비가 될 때까지 기다리고, 테스트가 끝나면 컨테이너를 정리합니다. 각 테스트(또는 범위를 어떻게 잡느냐에 따라 각 테스트 파일)는 자기만의 컨테이너를 가지므로, 테스트끼리 상태를 공유하지 않고, 누군가 잊지 않고 초기화해야 하는 공유 테스트 데이터베이스도 필요 없습니다.

이 가이드는 Go와 공식 `testcontainers-go` MySQL 모듈을 사용합니다. Go의 `testing` 패키지가 컨테이너 수명 주기를 전체적으로 보여주기 쉽기 때문입니다. Testcontainers에는 Java, Python, Node.js/TypeScript용 동등한 모듈이 있습니다; 설정은 언어마다 다르지만, 패턴(컨테이너 시작, 준비 대기, 스키마 실행, 테스트 실행, 정리)은 같습니다.

## 프로젝트 설정

```bash theme={null}
go mod init villagesql/testcontainers-guide
go get github.com/testcontainers/testcontainers-go/modules/mysql
go get github.com/go-sql-driver/mysql
```

이 명령은 MySQL 모듈의 전이 의존성으로 `testcontainers-go` 자체를 가져오고, 테스트가 실제로 연결하고 쿼리를 실행하는 데 사용하는 `go-sql-driver/mysql` 드라이버도 가져옵니다.

## 스키마와 테스트 대상 코드

테스트할 가치가 있는 제약 조건이 있는 최소한의 스키마:

```sql theme={null}
-- schema.sql
CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    name VARCHAR(255) NOT NULL,
    age TINYINT UNSIGNED NOT NULL,
    UNIQUE KEY uq_users_email (email)
);
```

그리고 표준 라이브러리의 `database/sql`을 사용하는, 메서드 2개짜리 작은 데이터 접근 계층:

```go theme={null}
// store.go
package store

import (
	"database/sql"
	"fmt"
)

type User struct {
	ID    int
	Email string
	Name  string
	Age   int
}

type UserStore struct {
	db *sql.DB
}

func NewUserStore(db *sql.DB) *UserStore {
	return &UserStore{db: db}
}

func (s *UserStore) CreateUser(email, name string, age int) (int, error) {
	result, err := s.db.Exec(
		"INSERT INTO users (email, name, age) VALUES (?, ?, ?)",
		email, name, age,
	)
	if err != nil {
		return 0, fmt.Errorf("insert user: %w", err)
	}
	id, err := result.LastInsertId()
	if err != nil {
		return 0, fmt.Errorf("read generated id: %w", err)
	}
	return int(id), nil
}

func (s *UserStore) GetUserByEmail(email string) (*User, error) {
	row := s.db.QueryRow(
		"SELECT id, email, name, age FROM users WHERE email = ?",
		email,
	)
	var u User
	if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Age); err != nil {
		return nil, fmt.Errorf("query user: %w", err)
	}
	return &u, nil
}
```

여기 있는 어떤 것도 자신이 테스트되고 있다는 것을 모릅니다. 뒤따르는 테스트는 애플리케이션이 호출하는 것과 정확히 같은 방식으로 이 코드를 실행합니다.

## 컨테이너 시작과 스키마 로드

```go theme={null}
// store_test.go
package store

import (
	"context"
	"database/sql"
	"errors"
	"path/filepath"
	"testing"

	"github.com/go-sql-driver/mysql"
	tcmysql "github.com/testcontainers/testcontainers-go/modules/mysql"
)

func newTestDB(t *testing.T) *sql.DB {
	t.Helper()

	ctx := context.Background()

	container, err := tcmysql.Run(ctx,
		"mysql:8.0.36",
		tcmysql.WithDatabase("appdb"),
		tcmysql.WithUsername("appuser"),
		tcmysql.WithPassword("apppass"),
		tcmysql.WithScripts(filepath.Join("schema.sql")),
	)
	if err != nil {
		t.Fatalf("failed to start MySQL container: %s", err)
	}
	t.Cleanup(func() {
		if err := container.Terminate(ctx); err != nil {
			t.Logf("failed to terminate container: %s", err)
		}
	})

	connStr, err := container.ConnectionString(ctx, "parseTime=true")
	if err != nil {
		t.Fatalf("failed to build connection string: %s", err)
	}

	db, err := sql.Open("mysql", connStr)
	if err != nil {
		t.Fatalf("failed to open database connection: %s", err)
	}
	t.Cleanup(func() { db.Close() })

	if err := db.Ping(); err != nil {
		t.Fatalf("failed to ping database: %s", err)
	}

	return db
}
```

`tcmysql.WithScripts`는 `schema.sql`을 컨테이너의 `/docker-entrypoint-initdb.d/` 디렉터리에 마운트하고, 공식 MySQL 이미지는 컨테이너가 빈 데이터 디렉터리로 처음 시작할 때 한 번 이 디렉터리를 자동으로 실행합니다. `container.ConnectionString`은 사용자 이름, 비밀번호, 호스트, 매핑된 포트가 이미 채워진, 바로 쓸 수 있는 DSN을 반환합니다. `t.Cleanup` 호출은 테스트의 성공 여부와 관계없이 후입선출 순서로 실행되며, 이것이 컨테이너를 정리합니다: 잊어버릴 별도의 정리 단계가 없습니다.

## 실제 데이터베이스에 대해 테스트 실행하기

```go theme={null}
func TestCreateAndGetUser(t *testing.T) {
	db := newTestDB(t)
	s := NewUserStore(db)

	id, err := s.CreateUser("ada@example.com", "Ada Lovelace", 36)
	if err != nil {
		t.Fatalf("CreateUser failed: %s", err)
	}
	if id == 0 {
		t.Fatalf("expected a nonzero generated id")
	}

	got, err := s.GetUserByEmail("ada@example.com")
	if err != nil {
		t.Fatalf("GetUserByEmail failed: %s", err)
	}
	if got.Name != "Ada Lovelace" || got.Age != 36 {
		t.Fatalf("got unexpected user: %+v", got)
	}
}
```

이것을 `go test -v`로 실행하면 실제 컨테이너를 시작하고, 스키마를 적용하고, `CreateUser`를 통해 행을 삽입하고, `GetUserByEmail`을 통해 다시 읽고, 컨테이너를 정리합니다:

```
=== RUN   TestCreateAndGetUser
2026/08/28 12:53:15 🐳 Creating container for image mysql:8.0.36
2026/08/28 12:53:16 ⏳ Waiting for container id ec9a06c2dd5d image: mysql:8.0.36. Waiting for: all of: [log message "port: 3306  MySQL Community Server"]
2026/08/28 12:53:21 🔔 Container is ready: ec9a06c2dd5d
2026/08/28 12:53:23 🐳 Terminating container: ec9a06c2dd5d
--- PASS: TestCreateAndGetUser (7.51s)
PASS
```

이 실행은 이미 받아 둔 `mysql:8.0.36` 이미지를 사용했습니다. 그 이미지를 한 번도 받지 않은 머신에서의 첫 실행은 눈에 띄게 더 오래 걸립니다: 여기서 측정한 한 실행에서, 스위트의 첫 번째 테스트는 처음부터 끝까지(이미지 다운로드 포함) 25.95초가 걸렸고, 같은 `go test` 호출 안의 이후 모든 테스트는 6.8초에서 7.5초가 걸렸습니다. Docker는 이미지를 한 번만 받고, Testcontainers는 이후의 모든 `Run` 호출에서 이미 로컬에 있는 이미지로 새 컨테이너를 시작하기 때문입니다. 구체적으로 "컨테이너 시작됨"에서 "컨테이너 준비됨"까지, 이 실행에서 MySQL 컨테이너가 연결을 받기까지 약 5초가 걸렸습니다.

## 데이터베이스를 모킹하지 않는 이유

`database/sql`의 모의 객체는 반환하라고 지시한 것은 무엇이든 반환합니다. 제약 조건 엔진도 strict 모드도 없으므로, 코드가 MySQL이 거부하는 값을 보냈을 때 MySQL이 실제로 하는 일을 재현할 수 없습니다. 이 프로젝트의 `store_test.go`에 있는, 실제 컨테이너에 대한 2개의 테스트가 그것을 직접 보여줍니다.

스키마는 `UNIQUE KEY uq_users_email (email)`을 선언합니다. 실제 컨테이너에 같은 이메일을 두 번 삽입하면 구체적이고 실제적인 MySQL 오류로 실패합니다:

```go theme={null}
func TestDuplicateEmailIsRejected(t *testing.T) {
	db := newTestDB(t)
	s := NewUserStore(db)

	if _, err := s.CreateUser("grace@example.com", "Grace Hopper", 40); err != nil {
		t.Fatalf("first CreateUser failed: %s", err)
	}

	_, err := s.CreateUser("grace@example.com", "Grace Hopper Duplicate", 41)
	if err == nil {
		t.Fatalf("expected an error inserting a duplicate email, got nil")
	}

	var mysqlErr *mysql.MySQLError
	if !errors.As(err, &mysqlErr) {
		t.Fatalf("expected a *mysql.MySQLError, got %T: %s", err, err)
	}
	if mysqlErr.Number != 1062 {
		t.Fatalf("expected MySQL error 1062 (ER_DUP_ENTRY), got %d: %s", mysqlErr.Number, mysqlErr.Message)
	}
}
```

이 테스트는 실제 컨테이너에 대해 `mysqlErr.Number`가 `1062`로 통과했는데, 이것은 MySQL이 고유 키 위반에 대해 반환하는 실제 `ER_DUP_ENTRY` 코드입니다. 모의 객체라면 정확히 이 오류를 정확히 이 형태로 반환하라고 미리 알려 줘야 합니다; 실제 데이터베이스는 제약 조건이 테스트가 아니라 스키마에 존재하기 때문에 그것을 강제합니다.

`age` 열은 `TINYINT UNSIGNED`이므로 음수를 담을 수 없습니다. MySQL의 기본 strict 모드(MySQL 5.7부터 기본값)에서는, 서버가 범위를 벗어난 값을 조용히 0으로 맞추지 않고 거부합니다:

```go theme={null}
func TestOutOfRangeAgeIsRejected(t *testing.T) {
	db := newTestDB(t)
	s := NewUserStore(db)

	_, err := s.CreateUser("negative-age@example.com", "Out Of Range", -1)
	if err == nil {
		t.Fatalf("expected an error inserting a negative age into TINYINT UNSIGNED, got nil")
	}

	var mysqlErr *mysql.MySQLError
	if !errors.As(err, &mysqlErr) {
		t.Fatalf("expected a *mysql.MySQLError, got %T: %s", err, err)
	}
	if mysqlErr.Number != 1264 {
		t.Fatalf("expected MySQL error 1264 (ER_WARN_DATA_OUT_OF_RANGE), got %d: %s", mysqlErr.Number, mysqlErr.Message)
	}
}
```

이 테스트도 실제 컨테이너에 대해 오류 `1264`, `ER_WARN_DATA_OUT_OF_RANGE`로 통과했습니다. 이 두 동작 중 어느 것도 애플리케이션 코드에서 오지 않습니다. 둘 다 MySQL 자체에서 오며, 바로 그 때문에 데이터베이스 수준의 모의 객체는 이 특정 사례 하나를 가짜로 만들도록 직접 코딩하지 않는 한 이것을 재현할 수 없습니다.

## Testcontainers를 VillageSQL 이미지로 향하게 하기

VillageSQL은 MySQL과 같은 와이어 프로토콜을 사용하므로, 같은 `testcontainers-go` MySQL 모듈이 기본 `mysql` 컨테이너 대신 `villagesql/server` 컨테이너를 구동할 수 있습니다. 이렇게 하는 데 변경 하나가 필요했습니다. 이 모듈의 기본 준비 검사는 `port: 3306  MySQL Community Server`라는 로그 줄 그대로를 기다리는데, VillageSQL의 시작 로그는 이 줄을 결코 출력하지 않습니다; VillageSQL 자체의 준비 줄은 `... ready for connections. Version: '8.4.11-villagesql-0.0.6' ... port: 3306 ...`입니다. 대기 전략을 그 줄에 맞게 재정의하면 충분합니다:

```go theme={null}
import (
	"github.com/testcontainers/testcontainers-go"
	tcmysql "github.com/testcontainers/testcontainers-go/modules/mysql"
	"github.com/testcontainers/testcontainers-go/wait"
)

container, err := tcmysql.Run(ctx,
	"villagesql/server:latest",
	tcmysql.WithDatabase("appdb"),
	tcmysql.WithUsername("appuser"),
	tcmysql.WithPassword("apppass"),
	tcmysql.WithScripts(filepath.Join("schema.sql")),
	testcontainers.WithWaitStrategy(
		wait.ForLog("ready for connections").WithOccurrence(2),
	),
)
```

`WithOccurrence(2)`가 필요한 이유는 엔트리포인트 스크립트가 init 스크립트를 실행하기 위해 먼저 임시 서버를 시작하고, 그다음 컨테이너가 계속 실행하는 실제 서버를 시작하기 때문입니다. 두 번째 출현이 실제로 무엇인지 알아 두세요: 로그에는 일치하는 줄이 3개 있는데, 실제 서버의 시작이 X Plugin용 한 줄과 `mysqld` 자체용 한 줄을 출력하기 때문입니다. 출현 2는 X Plugin 줄이며, `mysqld`가 스스로를 알리기 1밀리초도 안 되는 시간 전에 나타나므로, 그것을 기다리는 것은 실제로 충분히 가깝습니다. 대기를 서버 줄 자체에 고정하고 싶다면 `"/usr/bin/mysqld: ready for connections"`를 대신 일치시키세요. 그 문자열은 정확히 두 번 나타납니다. 이 대기 전략을 적용한 상태에서, 여기서 캡처한 한 실행에서 컨테이너는 6.81초 만에 시작하고, `schema.sql`을 적용하고, 연결을 받았으며, `SELECT VERSION()`은 `8.4.11-villagesql-0.0.6`을 반환했습니다. 나머지 모든 것, `WithDatabase`, `WithUsername`, `WithPassword`, `WithScripts`, `ConnectionString`은 변경 없이 동작했습니다.

## 이미지와 버전 선택하기

CI에서 실행되는 모든 테스트에는 `latest` 대신 정확한 태그를 고정하세요. 그래야 새 업스트림 이미지 릴리스가 모르는 사이에 테스트 결과를 바꾸지 못합니다:

```go theme={null}
tcmysql.Run(ctx, "mysql:8.0.36", ...)
```

VillageSQL에서는, 머물고 싶은 버전이 정해지면 같은 방식으로 고정하세요:

```go theme={null}
tcmysql.Run(ctx, "villagesql/server:mysql-8.4_0.0.6", ...)
```

태그를 정하기 전에 `docker manifest inspect <image>:<tag>`를 실행해서 태그가 존재하는지, 어떤 아키텍처를 게시하는지 확인하세요; 이 글을 쓰는 시점에 `mysql:8.0.36`과 `villagesql/server:latest` 모두 `amd64`와 `arm64` 매니페스트를 게시했으므로, 같은 태그가 플랫폼 재정의 없이 Intel과 Apple Silicon CI 러너에서 실행됩니다.

## 자주 묻는 질문

#### Testcontainers에는 실제 Docker 데몬이 필요한가요, 아니면 모의 객체로도 동작하나요?

테스트가 실행되는 곳에서 접근할 수 있는 실제 Docker 데몬이 필요합니다: 로컬에서는 Docker Desktop, CI에서는 Docker-in-Docker 또는 Docker 소켓이 마운트된 러너입니다. 테스트를 실행하는 머신에서 `docker info`가 실패하면, Testcontainers는 컨테이너 시작에 실패하고 테스트는 여러분의 코드에 닿기도 전에 연결 오류로 실패합니다.

#### 각 테스트가 자기만의 컨테이너를 갖나요, 아니면 테스트끼리 하나를 공유할 수 있나요?

그것은 Testcontainers가 강제하는 것이 아니라 테스트 코드에서 여러분이 하는 선택입니다. 위에 보인 것처럼 각 `Test...` 함수 안에서 컨테이너 시작 헬퍼를 호출하면 모든 테스트가 자기만의 컨테이너를 갖고 가장 강한 격리를 얻습니다. `TestMain`에서 컨테이너를 한 번 시작하고 패키지의 테스트들이 공유하면 더 빠르지만, 그러면 각 테스트는 다른 테스트의 데이터를 보지 않도록 자신이 삽입한 행을 정리할 책임이 있습니다.

#### 컨테이너가 준비되기까지 얼마나 걸리나요?

이 가이드를 위해 캡처한 실행에서, 이미지가 이미 로컬에 있을 때 `mysql:8.0.36` 컨테이너는 "컨테이너 시작됨"에서 "연결 준비됨"까지 약 5초가 걸렸고, `villagesql/server:latest` 컨테이너도 비슷한 시간이 걸렸습니다. 이미지를 한 번도 받지 않은 `go test` 실행에서 처음 시작하는 컨테이너는 이미지 다운로드 비용도 치르기 때문에 훨씬 더 오래 걸립니다; 여기서 측정한 한 실행에서는 그 첫 번째 컨테이너에 25.95초가 걸렸습니다.

#### 일반 SQL 파일 대신 이미 사용 중인 스키마 마이그레이션 도구를 실행할 수 있나요?

예. `WithScripts`는 단지 파일을 `/docker-entrypoint-initdb.d/`에 마운트할 뿐이고, 이 디렉터리는 `.sql`과 `.sh` 파일을 받습니다. 그 디렉터리의 셸 스크립트는 호스트와 포트에 대해 실행할 수 있는 어떤 마이그레이션 도구든 호출할 수 있습니다. 그 도구가 컨테이너 안에 있거나 스크립트의 일부로 설치하기만 하면 됩니다.

## 문제 해결

| 문제 | 해결 방법 |
| :- | :- |
| Docker 연결 오류와 함께 `failed to start MySQL container` | 테스트를 실행하기 전에 `docker info`를 실행해서 Docker 데몬이 실행 중이고 접근할 수 있는지 확인 |
| 컨테이너는 시작되지만 대기 전략이 결코 일치하지 않고, 테스트가 기한 초과로 실패함 | 기본 `testcontainers-go` MySQL 대기 전략은 MySQL 고유의 로그 줄을 찾음; 시작 로그가 다른 비표준 이미지에는 위의 VillageSQL 절에 보인 것처럼 자체 `testcontainers.WithWaitStrategy(...)`가 필요함 |
| `WithScripts` 파일이 실행되지 않는 것 같음 | init 스크립트는 컨테이너가 빈 데이터 디렉터리로 처음 시작할 때 한 번만 실행됨; 기존 볼륨으로 중지했다가 다시 시작한 컨테이너는 다시 실행하지 않음 |
| 특히 CI에서 테스트가 느림 | CI 러너에 이미지가 캐시되어 있거나 빠르게 받을 수 있는지 확인; 일부 샌드박스 CI 환경은 Docker 직접 접근을 막으므로, 러너에 Docker 소켓이 있는지도 확인 |
| 삽입이 분명히 실패했는데도 `*mysql.MySQLError` 타입 단언이 실패함 | import가 `github.com/go-sql-driver/mysql`인지 확인하고, `database/sql`이 드라이버 오류를 감쌀 수 있으므로 직접 타입 단언이 아니라 `errors.As`를 사용 |

## 함께 보기

* [Docker에서의 MySQL](https://villagesql.com/docs/ko/guides/mysql-on-docker.md) — Testcontainers가 기반으로 삼는 이미지, 환경 변수, 볼륨 동작
* [MySQL의 스키마 마이그레이션](https://villagesql.com/docs/ko/guides/schema-migrations.md) — 이 가이드가 `WithScripts`로 로드하는 것과 같은 스키마 변경을 프로덕션에서 실행하기
* [ORM과 함께 쓰는 MySQL](https://villagesql.com/docs/ko/guides/mysql-with-orms.md) — Testcontainers가 관리하는 데이터베이스에 대해 ORM 자체의 연결 풀과 마이그레이션 도구 사용하기


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.