메인 콘텐츠로 건너뛰기

VillageSQL is a drop-in replacement for MySQL with extensions.

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용 동등한 모듈이 있습니다; 설정은 언어마다 다르지만, 패턴(컨테이너 시작, 준비 대기, 스키마 실행, 테스트 실행, 정리)은 같습니다.

프로젝트 설정

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

스키마와 테스트 대상 코드

테스트할 가치가 있는 제약 조건이 있는 최소한의 스키마:
그리고 표준 라이브러리의 database/sql을 사용하는, 메서드 2개짜리 작은 데이터 접근 계층:
여기 있는 어떤 것도 자신이 테스트되고 있다는 것을 모릅니다. 뒤따르는 테스트는 애플리케이션이 호출하는 것과 정확히 같은 방식으로 이 코드를 실행합니다.

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

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

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

이것을 go test -v로 실행하면 실제 컨테이너를 시작하고, 스키마를 적용하고, CreateUser를 통해 행을 삽입하고, GetUserByEmail을 통해 다시 읽고, 컨테이너를 정리합니다:
이 실행은 이미 받아 둔 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 오류로 실패합니다:
이 테스트는 실제 컨테이너에 대해 mysqlErr.Number가 1062로 통과했는데, 이것은 MySQL이 고유 키 위반에 대해 반환하는 실제 ER_DUP_ENTRY 코드입니다. 모의 객체라면 정확히 이 오류를 정확히 이 형태로 반환하라고 미리 알려 줘야 합니다; 실제 데이터베이스는 제약 조건이 테스트가 아니라 스키마에 존재하기 때문에 그것을 강제합니다. age 열은 TINYINT UNSIGNED이므로 음수를 담을 수 없습니다. MySQL의 기본 strict 모드(MySQL 5.7부터 기본값)에서는, 서버가 범위를 벗어난 값을 조용히 0으로 맞추지 않고 거부합니다:
이 테스트도 실제 컨테이너에 대해 오류 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 ...입니다. 대기 전략을 그 줄에 맞게 재정의하면 충분합니다:
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 대신 정확한 태그를 고정하세요. 그래야 새 업스트림 이미지 릴리스가 모르는 사이에 테스트 결과를 바꾸지 못합니다:
VillageSQL에서는, 머물고 싶은 버전이 정해지면 같은 방식으로 고정하세요:
태그를 정하기 전에 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 파일을 받습니다. 그 디렉터리의 셸 스크립트는 호스트와 포트에 대해 실행할 수 있는 어떤 마이그레이션 도구든 호출할 수 있습니다. 그 도구가 컨테이너 안에 있거나 스크립트의 일부로 설치하기만 하면 됩니다.

문제 해결

함께 보기