Wails 실전 강좌 #8 테스트 전략 — 서비스와 저장소를 검증하기
#1에서 층을 나누며 “서비스 로직을 Wails 없이 순수 Go로 테스트할 수 있다"고 약속했습니다. 이번 글은 그 약속을 지킵니다. 실전 트랙이 테스트를 건너뛰면 “실전"이라는 이름값을 못 하므로, 지금까지 쌓은 서비스와 저장소를 실제로 검증하는 테스트를 작성합니다. 좋은 소식은, #1에서 층을 나눠 둔 덕에 이 일이 쉽다는 것입니다.
이 시리즈는 총 10편, 2부 구성입니다.
왜 테스트가 쉬워졌나: 층 분리의 보상 #
#1에서 바인딩·서비스·저장소를 나누고 의존을 한 방향으로 둔 이유가 여기서 드러납니다. 만약 모든 로직이 App 구조체에 붙어 있고 그 안에서 Wails 런타임을 직접 불렀다면, 테스트하려면 Wails 앱 전체를 띄워야 했을 것입니다. 서비스와 저장소는 Wails에 의존하지 않는 순수 Go 코드이므로, go test만으로 검증됩니다. 이것이 층을 나눈 실질적 보상입니다.
테스트는 두 갈래로 갑니다. 서비스는 도메인 규칙을, 저장소는 실제 DB 동작을 검증합니다.
단위 테스트: 서비스의 규칙 #
서비스 로직 중 DB 없이 검증할 수 있는 것부터 봅니다. #2에서 만든 Create의 규칙(제목이 비면 거부, 앞뒤 공백 제거)이 대표적입니다. 테이블 주도 테스트로 여러 입력을 한 번에 훑습니다. Go에서 가장 널리 쓰는 테스트 형태입니다.
func TestCreateValidation(t *testing.T) {
cases := []struct {
name string
title string
wantErr bool
}{
{"정상 제목", "회의록", false},
{"빈 제목", "", true},
{"공백뿐인 제목", " ", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
svc := NewNoteService(newTestRepo(t)) // 임시 DB 기반 저장소
_, err := svc.Create(c.title, "본문")
if (err != nil) != c.wantErr {
t.Errorf("Create(%q): err=%v, wantErr=%v", c.title, err, c.wantErr)
}
})
}
}각 케이스에 t.Run으로 이름을 붙이면, 실패했을 때 어느 입력이 깨졌는지 바로 보입니다. 새 규칙을 추가할 때 케이스 한 줄만 더하면 되므로, 규칙과 테스트가 같이 자랍니다.
통합 테스트: 임시 SQLite로 저장소 #
저장소는 실제 SQL을 돌리므로 DB가 필요합니다. 여기서 #2의 순수 Go 드라이버가 또 이득을 줍니다. CGO가 없으니 테스트 환경에서 C 툴체인 없이 그냥 돌고, 테스트마다 임시 파일 DB를 새로 만들어 서로 간섭하지 않게 합니다. t.TempDir()가 테스트가 끝나면 자동으로 지워지는 디렉터리를 줍니다.
func newTestRepo(t *testing.T) *NoteRepository {
t.Helper()
dir := t.TempDir() // 테스트 종료 시 자동 삭제
db, err := sql.Open("sqlite", filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
repo := &NoteRepository{db: db}
if err := repo.migrate(); err != nil { // #2의 마이그레이션을 그대로
t.Fatal(err)
}
return repo
}
func TestInsertAndList(t *testing.T) {
repo := newTestRepo(t)
if _, err := repo.Insert(Note{Title: "첫 노트", Body: "본문", CreatedAt: time.Now(), UpdatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
notes, err := repo.List()
if err != nil {
t.Fatal(err)
}
if len(notes) != 1 {
t.Errorf("노트 수 = %d, 원함 1", len(notes))
}
}임시 DB에는 migrate를 그대로 태우므로, #2·#3에서 만든 스키마와 FTS5 트리거가 실제와 똑같이 적용됩니다. 즉 이 통합 테스트는 마이그레이션까지 함께 검증합니다.
무엇을 테스트할 가치가 있나 #
전부를 테스트하려 하면 지쳐서 아무것도 안 남습니다. 우선순위를 정합니다.
- 규칙과 경계: 빈 제목 거부, 공백 처리처럼 조건이 갈리는 지점. 회귀가 잦은 곳입니다.
- 검색의 정확성: #3의 FTS5 검색이 접두 매칭(
단어*)을 제대로 하는지, 빈 검색어가 전체 목록으로 가는지. 데이터가 늘면 조용히 깨지기 쉬운 곳입니다. - 마이그레이션: 기존 버전 DB가 다음 버전으로 올바로 올라가는지. 사용자 데이터가 걸린 곳이라 실패 비용이 큽니다.
반대로 화면 렌더링이나 단순 getter는 테스트 비용 대비 이득이 작습니다. 프론트엔드는 핵심 흐름의 스모크 테스트 정도로 두고, 검증의 무게는 데이터가 걸린 Go 층에 싣습니다.
CI에 넣기: 릴리스 전에 자동으로 #
#6에서 만든 릴리스 워크플로는 태그를 밀면 바로 빌드·서명합니다. 그 앞에 테스트를 세워, 테스트가 통과해야 릴리스가 진행되게 합니다. 별도 워크플로로 push·PR마다 go test를 돌리는 것도 함께 둡니다.
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.23" }
- run: go test ./... -race # 경쟁 조건까지 검사-race 플래그는 고루틴 사이의 데이터 경쟁을 잡아 줍니다. #4에서 트레이·단축키를 별도 고루틴으로 돌렸으므로, 이 검사가 의미가 있습니다. 순수 Go 드라이버 덕에 이 테스트 잡도 CGO 설정 없이 ubuntu-latest에서 그냥 돕니다.
정리 #
- 서비스·저장소는 Wails에 의존하지 않는 순수 Go라
go test로 검증됩니다. #1의 층 분리가 준 실질적 보상입니다. - 서비스 규칙은 테이블 주도 단위 테스트로, 여러 입력을
t.Run으로 이름 붙여 한 번에 훑습니다. - 저장소는
t.TempDir의 임시 SQLite로 격리해 통합 테스트합니다.migrate를 그대로 태워 스키마·FTS5까지 검증합니다. - 테스트 우선순위는 규칙·경계, 검색 정확성, 마이그레이션입니다. 화면·getter는 뒤로 미룹니다.
go test ./... -race를 push·PR마다 CI로 돌리고, 릴리스 앞단에 세워 통과해야 배포되게 합니다.- 다음 글에서 배포된 앱에 새 버전을 안전하게 전달하는 자동 업데이트를 구현합니다.