23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
소프트웨어 개발에서 테스트는 선택이 아닌 필수입니다.
개발을 하던 중에, 어딘가의 코드 한 부분을 수정했는데 예상치 못한 곳에서 에러가 발생하는 경우를 모두 한번씩은 경험해 보셨을 겁니다.
그럴때 마다, 테스트 코드가 잘 구현된 코드가 얼마나 안전하고 오류를 줄일 수 있는지 깨닫게 됩니다.
그렇기에 많은 언어 환경에서 테스트를 잘 할 수 있도록 라이브러리를 많이 제공하는 편인데요.
특히 Go 언어는 간결함과 효율성을 철학으로 삼는 만큼, 테스트 또한 내장 도구로 간편하게 작성할 수 있도록 설계되어 있습니다.
이번 포스트에서는 Go의 테스트 문화와 철학부터 시작해서 Unit Test 작성 방법 부터 Integration Test 구성 방법까지 폭넓게 다뤄보겠습니다.
마지막으로, CI를 활용한 테스트 자동화와 테스트 베스트 프랙티스 및 유지보수 전략도 살펴볼 텐데요.
예제 코드를 곁들여 설명하니, Go로 테스트를 처음 작성해보는 분들도 쉽게 따라오실 수 있을 것입니다.
Go의 테스트 문화
Unit Test, 깔끔하게 시작하기
테스트의 편리함을 더하는 Testify
Mocking 기법으로 외부 의존성 다루기
여러 조각을 하나로: Integration Test 구성
CI로 완성하는 자동화 테스트 환경
알아두면 좋은 테스트 Best Practice
Go 언어는 태생부터 테스트를 중요시해 왔고, 표준 라이브러리에서 바로 테스트 프레임워크를 제공합니다.
Go 개발자들은 “테스트도 결국 코드다”라는 철학 아래, 추가 DSL이나 거창한 프레임워크 없이 표준 testing 패키지와 평범한 Go 코드만으로 테스트를 작성하는 미니멀리즘을 지향합니다.
실제로 Go의 테스트는 함수 앞에 Test라는 접두사를 붙이고 *testing.T를 인자로 받아서, 그 안에 일반적인 조건문과 함수 호출로 검증하는 식으로 작성되지요.
다른 언어처럼 어노테이션이나 복잡한 설정이 필요 없고, 테스트 초기화/정리(setup/teardown)를 위한 별도 문법도 최소한으로 줄여 두었습니다.
이런 단순함 덕분에 Go 개발자는 익숙한 Go 문법으로 곧바로 테스트를 작성할 수 있고, 새로운 테스트 전용 언어를 배우지 않아도 됩니다.
Go의 기본 테스트는 별도 프레임워크 없이 표준 라이브러리만으로 구현됩니다.
Go는 go test 명령과 testing 패키지를 통해 테스트를 자동으로 탐지하고 실행합니다.
테스트 코드는 다음 규칙을 따르면 자동 인식됩니다:
테스트 파일 이름은 _test.go로 끝나야 합니다 (예: math_test.go)
테스트 코드는 일반 코드와 같은 패키지에 두는 것이 관례입니다.
테스트 함수는 Test로 시작하고 *testing.T를 인자로 받습니다.
예를 들어, func TestAdd(t *testing.T) { ... } 형식입니다. 반환값은 없어야 하며, t.Errorf 등 testing.T의 메서드를 호출해 실패를 보고합니다.
동일한 패키지 내의 여러 테스트 함수들은 기본적으로 순차 실행되지만, 패키지 간 테스트는 CPU 코어 수만큼 병렬 실행됩니다.
작성한 테스트는 go test 명령 하나로 쉽게 실행할 수 있습니다.
기본적으로 성공한 테스트는 조용히 지나가고, 실패한 테스트만 출력됩니다. 자세한 로그를 보고 싶다면 -v 옵션을 사용하시면 됩니다:
-run [regex]: 정규식에 매치되는 특정 테스트들만 실행 (go test -run TestAdd 등).
-bench [expr]: 벤치마크 실행 (예: go test -bench .로 모든 벤치마크 실행)
-cover: 테스트 커버리지(coverage) 출력.
-count=N: 테스트 반복 실행 (flaky 테스트 확인 등에 유용).
-parallel N: 선언된 테스트 중 동시 실행 (기본값은 GOMAXPROCS)
아래는 간단한 Add 함수와 그에 대한 유닛테스트 예시입니다:
// main.go (혹은 math.go)
package main
func Add(a, b int) int {
return a + b
}// main_test.go
package main
import (
"testing"
)
func TestAdd(t *testing.T) {
// Arrange & Act
result := Add(2, 3)
// Assert
expected := 5
if result != expected {
t.Errorf("Add(2, 3) = %d; want %d", result, expected)
}
}Unit Test에서는 여러 시나리오를 하나의 테스트에 넣기 보다는 하나의 논리만을 검증하는 것이 좋습니다.
또한, 테스트 함수의 이름은 무엇을 테스트하는지 명시적으로 드러나게 지어야 디버깅과 협업에 용이할 수 있을 것입니다.
외부 자원 접근이나 환경에 의존하는 코드는 유닛테스트에서 최대한 격리하는 것이 좋습니다.
Unit Test의 장점은 실행이 매우 빠르고 특정 메서드(함수)의 문제를 정확히 찾아낼 수 있다는 점입니다.
다만, 시스템 전체의 관점에서 발생하는 이슈는 찾을 수 없고 다른 모듈과의 상호작용에서 발생하는 문제는 해당 테스트로는 검출하기 어렵다는 한계가 있습니다.
따라서, Unit Test로는 메서드 단위의 동작을 탄탄히 검증한 뒤, Integration Test로 보완하는 전략을 사용하는 것이 일반적입니다.
당연히 Go의 기본 내장 testing 패키지는 테스트를 작성할때 아주 직관적이고 간단하지만, 때로는 여러 번의 반복적인 코드를 작성하는데 있어 불편함이 있습니다.
이런 문제를 보완하여 더욱 간편하게 테스트 코드를 작성할 수 있게 해주는 것이 바로 Testify입니다.
실제로 개발하다 보면 거의 대부분의 상황에서 이 testify 라이브러리를 사용하는 것 같습니다. (특히, assert 기능..!)
Testify는 Go 언어용 테스트 라이브러리로, 기본 내장된 testing 패키지보다 더 편리하고 간결한 테스트 작성을 지원합니다.
특히 다음과 같은 기능을 제공합니다:
Assertions: 보다 직관적이고 명확하게 값을 검증하는 메서드를 제공
Mock: 모의 객체를 쉽게 생성하고 관리하는 기능 제공
Suite: 관련된 여러 테스트들을 묶어서 관리 가능
Testify의 가장 큰 장점 중 하나는 강력한 Assertion 기능입니다.
기존 Go 기본 내장 테스트와 비교하여 아래와 같이 더 깔끔한 문법으로 테스트를 작성할 수 있습니다.
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestAdd(t *testing.T) {
result := Add(2, 3)
assert.Equal(t, 5, result, "Add(2,3)은 5가 되어야 합니다.")
}
그 외에도 테스팅에 있어 편리함을 주는 다양한 기능들이 있으니,
관심이 있으신 분들은 Testify 공식 문서(https://pkg.go.dev/github.com/stretchr/testify)을 통해 살펴보기면 좋겠습니다.
테스트할 코드가 외부 시스템이나 DB, 네트워크 호출 등에 의존한다면 Mocking 기법이 필요합니다.
Mock(모의 객체)은 실제 구현 대신 동작을 흉내 내는 객체로, 해당 의존성을 대체하여 테스트 대상 로직만 검증할 수 있게 도와줍니다.
Go는 언어 차원에서 Mock을 지원하지는 않지만, 몇 가지 대표적인 서드 파티 라이브러리가 널리 쓰이고 있습니다: testify/mock과 Mockery가 그 예입니다.
testify의 mock 패키지를 이용하면 인터페이스에 대한 모의 객체를 쉽게 만들 수 있으며, 사용 방법의 개요는 다음과 같습니다:
인터페이스 정의: 먼저 모킹 대상이 되는 인터페이스를 정의합니다. 예를 들어 메시지를 보내는 인터페이스 Messenger가 있다고 가정해보겠습니다.
type Messenger interface {
SendMessage(to, message string) error
}
func Notify(m Messenger, to, msg string) error {
return m.SendMessage(to, msg)
}Mock 구조체 구현: testify/mock에서는 인터페이스를 모킹하기 위해 해당 인터페이스를 만족하는 구조체 타입을 정의하고 mock.Mock을 임베드(embed)합니다.
import "github.com/stretchr/testify/mock"
type MockMessenger struct {
mock.Mock
}
func (m *MockMessenger) SendMessage(to, message string) error {
// 호출된 인자를 기록하고 사전 정의된 값을 리턴
args := m.Called(to, message)
return args.Error(0) // 반환값으로 error를 기대
}테스트에서 Expectation 설정: 이제 테스트 함수에서 이 Mock 객체를 생성하고, 원하는 동작을 지정합니다.
import "github.com/stretchr/testify/assert"
func TestNotify(t *testing.T) {
// Mock 객체 생성
mockMessenger := new(MockMessenger)
// Messenger.SendMessage가 "receiver", "Hello!" 인자로 호출되면 nil을 반환하도록 기대치 설정
mockMessenger.On("SendMessage", "receiver", "Hello!").Return(nil)
// 실제 테스트 대상 함수 호출
err := Notify(mockMessenger, "receiver", "Hello!")
// 결과 확인 및 Mock 호출 여부 검증
assert.NoError(t, err)
mockMessenger.AssertExpectations(t) // 모든 On 설정이 충족되었는지 검사
}위 코드에서는 mockMessenger.On(...).Return(...)으로 특정 인자 호출 시 반환값을 정의하고,
Notify 예시 함수를 호출하면 내부에서 mockMessanger.SendMessage 가 불리면서 우리가 설정한대로 동작합니다.
마지막에는 AssertExpectations() 함수를 통해 정의한 기대호출이 실제로 이루어졌는지 검증합니다.
예상치 못한 호출이 발생한다면, 해당 함수에서는 검증 실패로 나오게 됩니다.
mockery는 Go 인터페이스에 대한 mock 구현체를 자동 생성해주는 CLI 도구입니다.
Go의 공식 go generate 도구와 연동하거나, Makefile/CI 등에서 쉽게 사용할 수 있어 많은 Go 프로젝트에서 표준처럼 활용되고 있습니다.
설치 및 인터페이스 정의: 실제 비즈니스 로직에서 사용하는 UserRepository라는 인터페이스입니다.
테스트에서는 이 인터페이스의 구현체를 직접 만들지 않고, 대신 Mock 객체를 만들어 사용합니다.
// 1. 설치
go install github.com/vektra/mockery/v2@latest
// 2. service/user.go에 인터페이스 정의
package service
type UserRepository interface {
FindByID(id int64) (*User, error)
}
// 3. monkery 생성
mockery --name=UserRepository인터페이스에서 사용하는 서비스 정의: UserService는 외부에서 UserRepository를 주입받아 사용합니다.
// service/user_service.go
package service
type UserService struct {
repo UserRepository
}
func (s *UserService) GetUserName(id int64) (string, error) {
user, err := s.repo.FindByID(id)
if err != nil {
return "", err
}
return user.Name, nil
}테스트 코드 작성:
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"yourproject/mocks"
"yourproject/service"
)
func TestGetUserName(t *testing.T) {
// 1. mock 객체 생성
mockRepo := new(mocks.UserRepository)
// 2. 기대하는 동작 정의 (mock 설정)
mockRepo.
On("FindByID", int64(1)). // 이 인자로 호출되면
Return(&service.User{Name: "Alice"}, nil). // 이렇게 응답하라
// 3. 테스트 대상 객체 생성 (의존성 주입)
svc := service.UserService{repo: mockRepo}
// 4. 실제 함수 호출
name, err := svc.GetUserName(1)
// 5. 검증
assert.NoError(t, err)
assert.Equal(t, "Alice", name)
// 6. mock이 예상대로 호출되었는지 확인
mockRepo.AssertExpectations(t)
}mockery를 생성하는 명령어를 실행하면 mocks/UserRepository.go 파일이 생성됩니다.
이 파일은 MockUserRepository라는 구조체를 포함하고 있고, 내부에 FindByID 함수를 stub 형태로 구현해줍니다.
또한, 하나의 인터페이스를 여러 테스트에서 사용한다면, mockery를 Makefile이나 go generate로 자동화하는 것도 추천합니다!
(관련 링크 : https://vektra.github.io/mockery/latest/configuration/)
Integration Test는 애플리케이션의 여러 구성 요소가 실제 외부 시스템(데이터베이스, 외부 API 등)과 제대로 상호작용하는지 검증하는 테스트입니다.
Unit Test가 애플리케이션 내부 로직의 정확성을 확인한다면, Integration Test는 시스템 간 연동에 초점을 맞춥니다.
예를 들어 DAO(Data Access Object)나 Repository 코드가 실제 DB에 쿼리를 올바르게 수행하는지, 또는 HTTP 클라이언트 코드가 외부 API와 잘 통신하는지를 테스트할 수 있습니다.
Integration Test는 보통 환경 셋업이 필요하고 실행 속도가 Unit Test보다 느릴 수 있지만, 그만큼 실제 상황과 가까운 신뢰도를 제공합니다.
Go 테스트에서 Integration Test를 구현하는 일반적인 방법은 외부 의존성을 실제처럼 동작하게 준비하는 것입니다.
특히 데이터베이스의 경우, 테스트를 위해 로컬에 DB를 설치하거나, 인메모리 DB를 사용하거나,
Docker 컨테이너를 활용 (링크 : https://docs.docker.com/guides/golang/run-tests/)하는 방안 등을 고려할 수 있습니다.
아래는 메모리 데이터베이스(SQLite)를 사용한 간단한 Integration Test 예시입니다:
import (
"database/sql"
"testing"
_ "github.com/mattn/go-sqlite3"
)
func TestDatabaseIntegration(t *testing.T) {
// 외부 의존성 세팅: 메모리 DB 생성
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
defer db.Close()
// 가상의 테이블 생성 및 데이터 입력 (예시)
_, err = db.Exec("CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT);")
if err != nil {
t.Fatal(err)
}
db.Exec("INSERT INTO users(name) VALUES('Alice'),('Bob');")
// 실제 코드 호출: 예를 들어 GetUsers 함수가 db를 활용한다고 가정
names, err := GetUsers(db)
if err != nil {
t.Fatalf("GetUsers error: %v", err)
}
if len(names) != 2 || names[0] != "Alice" {
t.Errorf("unexpected result: %v", names)
}
}이처럼 Integration Test에서는 실제 DB나 서버를 실행하여 전체 시나리오를 테스트할 수도 있습니다.
물론 매번 실제 자원을 쓰면 테스트가 느리고 환경 셋업이 번거로울 수 있으니, 가능한 한 가벼운 대체 수단을 쓰는 게 좋습니다
(예: 실제 Postgres 대신 트랜잭션 롤백을 이용하거나, 외부 API 대신 로컬 테스트 서버 httptest.NewServer 등을 활용).
Go에서는 Integration Test를 따로 구분하기 위해서 Build Tag를 사용하기도 합니다.
예를 들어, Integration Test 파일 상단에 //go:build integration라고 적어두고, 일반 go test에서는 제외되게 한 다음,
통합 테스트를 돌리고 싶을 때만 go test -tags=integration으로 실행하는 식입니다.
이렇게 하면 CI에서도 Unit Test는 자주 돌리면서 Integration Test는 리소스가 허용 될때만 돌리는 식으로 분리 운용이 가능해 집니다.
Integration Test에서는 테스트 간 공유 상태나 외부 시스템의 상태에 주의해야 합니다.
예를 들어, 테스트 DB의 데이터를 매 케이스마다 초기화하거나, 포트 충돌을 피하기 위해 서버 포트를 동적으로 할당하는 등 신경 써야 할 부분이 늘어납니다.
Go의 testing 패키지에서 t.Cleanup을 적절히 활용해서 테스트 환경을 깔끔하게 정리하는 것이 중요합니다.
테스트를 잘 작성했다면, 이제 남은 일은 지속적으로 테스트를 실행하는 것입니다.
개발자가 일일이 수동 실행하지 않아도 코드 변경 시마다 자동으로 테스트를 돌려주는 환경, 즉 지속적 통합(CI) 파이프라인을 구축하면 품질 관리에 큰 도움이 됩니다.
Go 프로젝트는 비교적 CI 구성도 간단한 편인데요, GitHub Actions를 이용한 예시를 살펴보겠습니다.
name: Go CI
on: [push, pull_request] # 코드 push나 PR 생성 시 CI 실행
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # 소스 코드 체크아웃
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.20' # Go 버전 설정
- name: Install dependencies
run: go get -t -v ./... # 필요한 모듈 다운로드 (테스트용 모듈 포함)
- name: Build
run: go build -v ./... # 코드 빌드해보기 (컴파일 확인)
- name: Test
run: go test -v ./... # 전체 테스트 실행 (verbose 모드)
위 YAML은 GitHub Actions 워크플로우를 정의한 것입니다.
간략히 설명하면, push나 pull_request 이벤트 발생 시 Ubuntu 최신 환경에서 Go를 설치하고, 소스를 받으며, 빌드 및 테스트를 실행합니다.
go test -v ./... 명령은 현재 모듈의 모든 패키지(./...)에 있는 테스트를 verbose 모드로 실행하라는 뜻이죠.
이처럼 CI에 테스트를 통합해 두면 개발자가 새 코드를 커밋할 때마다 자동으로 테스트를 돌려 문제가 없는지 확인해줍니다.
만약 테스트 실패 시에는 개발자에게 알림을 보낼 수도 있고 (GitHub 알림 또는 메일 등), PR에 상태 표시를 해주어 코드 리뷰 시에 품질을 한눈에 확인할 수도 있습니다.
CI 연동은 GitHub Actions 외에도 Jenkins, GitLab CI, CircleCI 등 다양한 도구를 활용할 수 있습니다.
중요한 것은 테스트 자동화로 사람의 실수나 깜빡함을 방지하고, 항상 일관된 환경에서 테스트가 실행되도록 하는 것입니다.
특히 Go는 빌드와 테스트가 빠르고 의존성 관리도 모듈로 깔끔하게 되기 때문에 CI에서 추가 설정 없이도 비교적 순조롭게 돌아가는 편입니다.
마지막으로, 테스트 코드를 효과적으로 관리하기 위한 몇 가지 팁과 권장 사항을 정리하며 글을 마무리하고자 합니다.
테스트하기 쉬운 구조로 개발하기 : 함수는 한 가지 논리만 처리할 수 있도록 작게 유지하고, 복잡한 의존은 인터페이스로 분리하여 설계하세요. 그렇게 해야 보다 깨끗한 코드가 되며 나중에 테스트 코드 작성에 용이 합니다.
명확한 테스트케이스 명명 : 테스트 함수나 케이스의 이름은 결국 무엇을 검증하는지 나타내는 것입니다. 명명에 입력상황과 기대결과를 담는 다면 나중에 실패 로그만 보더라도 어떤 에러가 발생하였는지 파악할 수 있습니다.
AAA 패턴으로 구조화: 테스트 코드를 작성할 때 Arrange-Act-Assert 순서로 구분하면 가독성이 높아집니다. 필요한 준비(Arrange), 기능 호출(Act), 결과 검증(Assert) 부분을 개행 등으로 나눠주거나 주석을 추가하면 처음 보는 사람도 흐름을 이해하기 쉽습니다. (저희 팀은 AAA보다는 Given-When-Then 순서를 지켜 코드를 구현합니다.)
빠르고 자주 돌릴 수 있게: 테스트는 가능한 한 속도가 빨라야 합니다. Integration Test는 꼭 필요한 범위로만 작성하고, 대부분의 유닛 테스트는 수초 내에 끝날 수 있도록 최적화하세요. 또한 모든 커밋에 대해 테스트 실행(예: pre-commit hookI)을 습관화하면 버그를 초기에 잡을 수 있습니다.
테스트도 꾸준히 리팩터링: 중복되는 검증 로직은 함수로 빼거나 테스트 헬퍼 함수로 만들고, Table-Driven 패턴이나 서브 테스트를 통해 구조를 개선하세요. 시간이 지날수록 테스트 코드도 함께 복잡해질 수 있으니, 주기적으로 정리하는 노력이 필요합니다.
지금까지 Go 언어의 테스트 자동화 전략과 구현 방법을 폭넓게 살펴보았습니다.
Unit Test에서 시작해 Integration Test까지 다양한 계층의 테스트를 작성하고, Mocking으로 외부 의존성을 제어하고, CI 파이프라인으로 자동화하는 흐름이 한 눈에 그려지셨나요?
테스트는 초기 투자 비용이 들지만, 장기적으로 버그로 인한 비용을 절약해주고 개발 속도를 높여주는 든든한 버팀목입니다.
Go의 철학대로 군더더기 없이 심플한 방식으로, 그러나 필요한 기법은 적재적소에 활용하면서, 여러분의 Go 프로젝트를 한층 신뢰성 있게 만들어 보세요.
이제 여러분도 "코드 안정성, 완벽하게 검증"할 차례입니다.
이미지 출처:
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.