티스토리 뷰
개요
지금까지 handler, usecase, repository 계층을 둔 클린 아키텍처 구조의 백엔드를 구현하였고, 런타임에 설정을 가져와 데이터베이스에 연결하고, 이 연결을 repository에 주입하여 데이터베이스 작업을 하도록 하였다.
기능 구현에 집중하여 테스트 코드가 없이 구현을 해왔는데 이제는 코드의 안전성을 높이기 위해 유닛테스트를 추가해보자. 유닛 테스트를 작성하면 작은 코드 단위에서 문제를 빠르게 발견하고 수정할 수 있고, 리팩터링 시에 기존 기능이 정상 동작하는지 쉽게 검증할 수 있다. 그리고, 자동화된 테스트로 반복적인 수작업 검증을 줄여 개발 효율성이 높아진다.
링크
- GitHub 브랜치: https://github.com/nicewook/gocore/tree/3_unit-test
- 블로그 링크
목차
이번 글에서 다룰 내용은 다음과 같다.
- mockery로 mock 생성하기 - mockery로 인터페이스 기반 mock 객체를 자동 생성하는 방법
- 테스트 작성 - handler, usecase 계층 - handler와 usecase 계층의 유닛 테스트 작성 방법
- Testcontainers로 테스트용 데이터베이스 만들기 - Testcontainers로 테스트용 PostgreSQL 환경 구성하기
- 테스트 작성 - repository 계층 - 테스트 DB를 사용한 repository 계층 테스트 작성
- Makefile 작성 - make test - 테스트 자동화하는 Makefile
- pre-commit 작성 - 커밋 전 자동 테스트를 위한 pre-commit 훅 설정
mockery로 mock 생성하기
클린 아키텍처에서 handler, usecase 계층의 유닛 테스트를 위해 각각의 하위 계층인 usecase, repository를 mock으로 주입한다. 실제 데이터베이스나 외부 API에 의존하는 테스트는 느리고 불안정할 수 있다. mock을 사용하면 이러한 의존성을 제거하여 테스트 속도를 높이고, 특정 상황을 쉽게 시뮬레이션할 수 있다.
mockery는 인터페이스를 기반으로 mock 객체를 자동 생성해준다. 이렇게 생성된 mock을 활용하면 실제 구현체를 사용하지 않고 각 계층의 로직을 독립적으로 테스트할 수 있다.
mockery를 설치해보자. 현시점 버전은 v2.52.1 이다.
$ brew install mockery
$ mockery --version
v2.52.1
mockery는 CLI로 flag를 이용하여 직접 명령할 수도 있지만 mockery 라고만 실행하면 .mockery.yaml 파일을 찾아 파일 설정에 따라 실행된다.
// .mockery.yaml
all: true # 모든 인터페이스에 대해 mock 자동 생성
with-expecter: true # Expect() 메서드 생성으로 호출 검증 지원
resolve-type-alias: false # 타입 별칭 자동 해석 비활성화 (경고 방지)
issue-845-fix: true # Mockery의 #845 이슈 관련 경고 해결
packages:
github.com/nicewook/gocore/internal/domain: # 해당 패키지의 mock 설정
config:
dir: ./internal/domain/mocks # mock 파일 저장 경로
filename: "Mock{{.InterfaceName}}.go" # mock 파일 이름 패턴 지정
mockname: "{{.InterfaceName}}" # mock 구조체 이름 지정
outpkg: mocks # mock 파일의 패키지 이름 설정
이제 프로젝트 root 에서 mockery 명령을 실행하면 설정에 따라 mock이 생성된다.
인터페이스 정의는 다음과 같다.
// ./internal/domain/user.go
type UserRepository interface {
Save(user *User) (*User, error)
GetByID(id int64) (*User, error)
GetAll() ([]User, error)
}
type UserUseCase interface {
CreateUser(user *User) (*User, error)
GetByID(id int64) (*User, error)
GetAll() ([]User, error)
}
packages 아래의 dir, filename 설정에 따라 ./internal/domain/mocks 디렉토리에 MockUserRepository.go, MockUserUseCase.go, 두 파일이 생긴다. 그리고 mockname, outpkg 설정에 따라 패키지명과 구현체의 구조체명이 생성된다. 다음은 생성된 MockUserRepository.go 파일의 앞부분이다.
// Code generated by mockery v2.52.1. DO NOT EDIT.
package mocks
import (
domain "github.com/nicewook/gocore/internal/domain"
mock "github.com/stretchr/testify/mock"
)
// UserRepository is an autogenerated mock type for the UserRepository type
type UserRepository struct {
mock.Mock
}
테스트 작성 - handler, usecase 계층
생성한 mock을 바탕으로 handler, usecase 계층의 테스트를 만들어보자. 각각 테스트 하나씩만 들여다보고 테이블 테스트의 경우도 하나만 추려서 보겠다(전체 코드는 GitHub 참고).
internal/handler/userHandler_test.go
- echo context 생성
- 서버로 HTTP request가 들어오면 echo 프레임워크는 이를 echo context 로 만들어 핸들러에 전달한다. 이를 만들어주는 단계이다. POST /user 로 input 값이 들어온다.
- mock 생성, 설정및 핸들러 생성
- 이 부분이 mock을 이용한 테스트의 핵심이다. 현재 프로젝트는 UserUseCase 인터페이스의 구현체가 두 개가 있는데, 실제 구현체인 userUsecase 의 인스턴스가 아닌, mocks.UserUseCase 인스턴스를 생성하고 핸들러에 주입한다.
- mocks.UserUseCase 의 메서드 동작에 대한 정의가 또한 중요하다ㅏ.
- On 메서드는 CreateUser 메서드에 tt.mockInput이 주입되어야 함을 정의한다. mockInput 값은 handler가 어떤 파라미터를 usecase에 전달할지를 검증하는 중요한 요소이다.
- Return 메서드는 응답값으로 무엇을 내어줄지를 정의한다.
- 마지막으로 Maybe 메서드는 이 메서드가 호출될 수도 있고 아닐 수도 있음을 의미한다. handler.CreateUser 메서드가 실행된다 하더라도 usecase 호출 전에 요청의 검증 과정에서 리턴될 수도 있는 것이다. Maybe 말고도 한번만 호출된다는 Once 등을 쓸 수 있다.
- 핸들러 실행 및 검증: 테스트 준비가 끝난 상황에서 이제 핸들러를 실행하고 그 결과를 확인한다.
- mock 호출 검증: 마지막으로 mockUsecase 의 호출이 원하는대로 실행되었는지를 확인한다.
func TestCreateUser(t *testing.T) {
tests := []struct {
name string
input string
mockInput *domain.User
mockReturn interface{}
mockError error
expectedStatus int
}{
{
name: "Success",
input: `{"name":"John","email":"john@example.com"}`,
mockInput: &domain.User{Name: "John", Email: "john@example.com"},
mockReturn: &domain.User{Name: "John", Email: "john@example.com"},
mockError: nil,
expectedStatus: http.StatusCreated,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// echo context 생성
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(tt.input))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// mock 생성, 설정및 핸들러 생성
mockUseCase := new(mocks.UserUseCase)
mockUseCase.On("CreateUser", tt.mockInput).Return(tt.mockReturn, tt.mockError).Maybe()
handler := NewUserHandler(mockUseCase)
// 핸들러 실행 및 검증
err := handler.CreateUser(c)
assert.NoError(t, err)
assert.Equal(t, tt.expectedStatus, rec.Code)
// mock 호출 검증
mockUseCase.AssertExpectations(t)
})
}
}
internal/usecase/userUsecase_test.go
앞의 handler 에 대한 테스트 코드를 유심히 보았다면 이 코드는 어렵지 않을 것이다. 생성하고 주입하는 mock이 handler에 mockUsecase를 주입했던 것이, 이제는 usecase에 mockRepository 를 주입하는 것 뿐이다.
- mockRepo를 생성하고, Save 메서드에 어떤 파라미터가 들어가고, 응답할지를 On, Return 메서드에 정의한다.
- mockRepo를 UserUsecase에 주입한 다음 usecase의 CreateUser 메서드를 실행한다.
- 마지막으로 응답값이 기대값과 같은지를 확인하고, mockRepo의 호출이 기대한대로 동작했는지 확인한다.
func TestCreateUser(t *testing.T) {
tests := []struct {
name string
mockInput *domain.User
mockReturn *domain.User
mockError error
expected *domain.User
expectErr error
}{
{
name: "Success",
mockInput: &domain.User{Name: "John", Email: "john@example.com"},
mockReturn: &domain.User{Name: "John", Email: "john@example.com"},
mockError: nil,
expected: &domain.User{Name: "John", Email: "john@example.com"},
expectErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockRepo := new(mocks.UserRepository)
mockRepo.On("Save", tt.mockInput).Return(tt.mockReturn, tt.mockError).Maybe()
uc := NewUserUseCase(mockRepo)
result, err := uc.CreateUser(tt.mockInput)
assert.Equal(t, tt.expected, result)
assert.Equal(t, tt.expectErr, err)
mockRepo.AssertExpectations(t)
})
}
}
Testcontainers 로 테스트용 데이터베이스 만들기
handler, usecase 계층은 각각 mock으로 usecase, repository 계층을 만들어서 테스트할 수 있지만 repository 계층은 다른 접근이 필요하다. 실제 데이터베이스를 복제해 띄우거나 sqlite를 이용하는 전략등이 있겠지만 여기서는 실제 사용하는 PostgreSQL과 동일한 PostgreSQL을 Testcontainers 를 이용해 만들 것이다.
다음 의존성 패키지부터 설치하자.
$ go get github.com/testcontainers/testcontainers-go
$ go get [github.com/lib/pq](<http://github.com/lib/pq>)
internal/repository/postgres/testMain_test.go
TestMain
TestMain은 Go의 testing 패키지에서 테스트 실행 전후에 필요한 초기화 및 정리 작업을 수행할 수 있도록 하는 함수이다. m.Run을 호출하여 실제 테스트를 실행하며, 데이터베이스 초기화와 같은 설정을 먼저 해주거나 테스트종료 후 리소스 해제와 같은 정리 작업을 추가할 수 있다.
- 초기작업
- setupPostgresContainer: 테스트를 위한 컨테이너, 데이터베이스를 준비한다. testDB는 패키지 전역 변수이기에 패키지 내의 모든 테스트에서 사용할 수 있다.
- setupSchema: 테스트에 필요한 테이블을 만든다.
- m.Run: 실제 테스트를 실행한다.
- 정리작업: container.Terminate로 컨테이너를 정리하고 os.Exit로 테스트 실행 결과코드를 확인한다.
var testDB *sql.DB
func TestMain(m *testing.M) {
// PostgreSQL 컨테이너 시작
var (
ctx = context.Background()
container testcontainers.Container
err error
)
container, testDB, err = setupPostgresContainer(ctx)
if err != nil {
log.Fatalf("PostgreSQL 컨테이너 설정 실패: %v", err)
}
setupSchema() // 스키마 생성
code := m.Run() // 테스트 실행
// 테스트 종료 후 컨테이너 정리
if err := container.Terminate(ctx); err != nil {
log.Fatalf("컨테이너 종료 실패: %v", err)
}
os.Exit(code)
}
setupPostgresContainer
Testcontainers 는 여러 데이터베이스를 다룰 수 있는 범용 방식과 PostgreSQL과 같은 특정 DB를 생성하는 방식을 모두 제공하는데 여기서는 generic한 범용 방식으로 컨테이너, 테스트 데이터베이스를 생성한다. 코드 자체는 어렵지 않고 따라 읽으며 이해만 하면 되겠다.
- testcontainers.ContainerRequest: 만들고자 하는 컨테이너를 정의
- testcontainers.GenericContainer: 컨테이너 생성
- container.Host, container.MappedPort: host와 port 정의 및 추출
- DSN 생성 및 연결설정, Ping으로 연결 확인
func setupPostgresContainer(ctx context.Context) (testcontainers.Container, *sql.DB, error) {
req := testcontainers.ContainerRequest{
Image: "postgres:17.2", // 원하는 PostgreSQL 버전
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_USER": "testuser",
"POSTGRES_PASSWORD": "testpass",
"POSTGRES_DB": "testdb",
},
WaitingFor: wait.ForListeningPort("5432/tcp").WithStartupTimeout(30 * time.Second),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return nil, nil, fmt.Errorf("컨테이너 시작 실패: %w", err)
}
host, err := container.Host(ctx)
if err != nil {
return nil, nil, err
}
port, err := container.MappedPort(ctx, "5432")
if err != nil {
return nil, nil, err
}
dsn := fmt.Sprintf("host=%s port=%s user=testuser password=testpass dbname=testdb sslmode=disable", host, port.Port())
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, nil, err
}
// DB 연결이 준비될 때까지 대기
for i := 0; i < 10; i++ {
if err := db.Ping(); err == nil {
break
}
time.Sleep(1 * time.Second)
}
return container, db, nil
}
setupSchema, cleanDB
- setupSchema: 테스트 데이터베이스를 생성 후 테스트를 위한 테이블을 생성하는 함수이다. 추후 goose와 같은 migration 도구를 적용할 때 테스트 데이터베이스도 일괄 적용하는 것을 구현해볼까 한다.
- cleanDB: 테이블의 레코드를 모두 삭제해주는 함수이다. 패키지내의 모든 테스트 함수가 같은 데이터베이스를 사용하기에 매 테스트 시작시에 원하는 테이블을 정리해주기 위한 헬퍼 함수이다.
func setupSchema() {
const schema = `
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
`
if _, err := testDB.Exec(schema); err != nil {
log.Fatalf("테이블 생성 실패: %v", err)
}
}
// 데이터 초기화 함수
func cleanDB(t *testing.T, tables ...string) {
for _, table := range tables {
// "TRUNCATE TABLE " + table 은 인텔리제이가 에러라고 생각한다.
_, err := testDB.Exec("TRUNCATE TABLE" + " " + table + " RESTART IDENTITY CASCADE")
assert.NoError(t, err)
}
}
테스트 작성 - repository 계층
internal/repository/postgres/userRepository_test.go
리포지토리의 Save 메서드에 대한 테스트 코드를 보자. 첫 번째 테스트는 다음과 같다.
- 패키지 전역 변수인 testDB를 주입해준다.
- cleanDB 함수로 users 테이블의 레코드를 모두 지워준다. 이전에 테스트한 것이 남아있다면 지워두기 위함이다.
- Save 작업의 결과가 기대한 대로 인지 확인한다. savedUser.ID 에 데이터베이스로부터 아이디가 할당되었는지 확인하는 assert.NotZero가 특히 중요한 코드이다.
두 번째 테스트는 email이 중복되지 않아야 하는 규칙을 확인하는 테스트이다.
- email이 jain@example.com 인 User를 먼저 저장한 다음,
- 동일한 email을 가지지만 name은 다른 User를 저장 시도한다.
- 이때 에러가 발생하여야 하고, 에러는 domain.ErrAlearyExists 여야 한다.
func TestSaveUser(t *testing.T) {
repo := NewUserRepository(testDB)
cleanDB(t, "users")
t.Run("성공적으로 사용자 저장", func(t *testing.T) {
user := &domain.User{Name: "John Doe", Email: "john@example.com"}
savedUser, err := repo.Save(user)
assert.NoError(t, err)
assert.NotZero(t, savedUser.ID)
assert.Equal(t, user.Name, savedUser.Name)
assert.Equal(t, user.Email, savedUser.Email)
})
t.Run("이메일 중복으로 저장 실패", func(t *testing.T) {
user1 := &domain.User{Name: "Jane Doe", Email: "jane@example.com"}
_, err := repo.Save(user1)
assert.NoError(t, err)
user2 := &domain.User{Name: "Jane Smith", Email: "jane@example.com"}
_, err = repo.Save(user2)
assert.Error(t, err) // UNIQUE 제약 조건 위반
assert.ErrorIs(t, err, domain.ErrAlreadyExists)
})
}
Makefile 작성 - make test
테스트의 편의를 위해 테스트를 위한 Makefile을 만들었다(만드는 김에 docker compose로 PostgreSQL 데이터베이스를 실행하는 타겟도 함께 만들었다.
[참고] docker-compose 와 docker compose
docker-compose는 독립적인 CLI 도구로 설치해야 하는 반면, docker compose는 Docker CLI에 통합된 명령어로 별도 설치 없이사용 가능하다. docker compose는 성능 개선과 기능 확장성이 뛰어나며, 최신 Docker 버전에서 더 나은 지원을 받는다. 다만, 구버전 환경에서는 docker-compose가 호환성이 더 좋을 수 있다.새로운 프로젝트라면 docker compose를 사용하는 것을 추천한다.
make test 명령을 실행하면 mockery를 실행하여 혹시라도 인터페이스 변경이 있더라도 그에 맞추어서 mock 파일을 새로 생성하도록 한다. 그런 다음 go test ./… -v 명령으로 모든 테스트를 실행하도록 한다. 개발 중간중간에 이 명령으로 수시로 테스트를 하여 기존 테스트가 영향을 받아 실패하는 지 확인할 수 있다.
./Makefile
.PHONY: test
test:
@echo "Running tests..."
@echo "Make mock first..."
mockery
@echo "Running tests..."
go test ./... -v
# Makefile for Docker Compose Operations
.PHONY: up down down-v
up: # Docker Compose Up
docker compose up -d
down: # Docker Compose Down (without volume removal)
docker compose down
down-v: # Docker Compose Down with Volume Removal
docker compose down -v
pre-commit 작성
다음과 같이 pre-commit 파일을 생성하자. 다음 명령을 프로젝트 루트 터미널에서 붙여넣어 실행하면 된다. cat .git/hooks/pre-commit 명령으로 pre-commit 파일이 잘 생성되었는지 확인할 수 있다.
이제는 git commit을 실행하려 하면 그 전에 이 파일의 내용이 실행되고, make test 명령이 실패하게 되면 commit을 취소한다. mock을 업데이트 하는 것을 깜박하거나, 테스트가 실패하는 채로 git push 하는 것을 예방하는 효과가 있다.
mkdir -p .git/hooks
cat <<EOL > .git/hooks/pre-commit
#!/bin/bash
# 커밋 전에 테스트 실행
echo "✅ Pre-commit: 테스트를 실행합니다..."
if ! make test; then
echo "❌ 테스트 실패! 커밋이 중단되었습니다."
exit 1
fi
echo "🎉 테스트 성공! 커밋을 진행합니다."
EOL
chmod +x .git/hooks/pre-commit
마무리
유닛 테스트의 효용성과 장점은 아무리 강조해도 지나치지 않다. 다음에는 user 외에도 product, order 라는 도메인에 대한 코드와 그 테스트 코드를 작성한 다음, 여기서 시작하여 fx 라이브러리를 이용한 의존성 주입(DI, Dependency Injection)을 소개하겠다.
'golang' 카테고리의 다른 글
Go 백엔드 3: 데이터베이스 연결 (0) | 2025.02.09 |
---|---|
Go 백엔드 2: 설정 (0) | 2025.02.09 |
Go 백엔드 1: 클린 아키텍처 기본 (0) | 2025.02.06 |
Go, XORM, Soft delete, Unscoped (0) | 2024.02.14 |
Golang: http.Client의 Timeout (0) | 2023.12.20 |
![잡학툰 뱃지](https://tistory1.daumcdn.net/tistory/2908812/skin/images/badge.png)
- Total
- Today
- Yesterday
- intellij
- folklore
- 클린 애자일
- API
- 2024년
- 독서
- postgres
- 엉클 밥
- Bug
- 2023
- 티스토리챌린지
- OpenAI
- clean agile
- go
- Gin
- notion
- 오블완
- bun
- 노션
- websocket
- 인텔리제이
- agile
- 클린 아키텍처
- ChatGPT
- 영화
- 독서후기
- strange
- golang
- 잡학툰
- solid
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |