유닛 테스트에서 파일 I/O를 피하기 위한 Go 코드 리팩터링
원문은 Matthias Endler님이 에 게재했습니다. 이 블로그 구독하기
오늘 업무 중에 간단한 Go 코드를 더 테스트하기 쉽게 리팩터링했다. 데이터 입출력과 데이터 처리를 분리해 모킹이나 임시 파일을 사용하지 않고 유닛 테스트에서 파일 처리를 피하는 것이 핵심 아이디어였다.
출처: Marcus Olsson 일러스트 CC BY-NC-SA 4.0
StackOverflow 같은 사이트에서 간단한 설명을 찾지 못해 놀랐고, 그래서 앞으로 다른 사람들이 참고할 수 있도록 직접 정리해 두었다.
예제 코드
최초 버전은 이렇게 생겼다:
package main
import (
"bufio"
"io/ioutil"
"os"
)
func main() {
analyze("test.txt")
}
func analyze(file string) error {
handle, err := os.Open(file)
if err != nil {
return err
}
defer handle.Close()
scanner := bufio.NewScanner(handle)
for scanner.Scan() {
// Do something with line
_ = scanner.Text()
}
return nil
}보다시피 파일 이름을 입력으로 받아 analyze 함수 안에서 해당 파일을 열어 내용으로 무언가를 처리한다.
코드에 대한 첫 테스트 작성하기
해당 코드에 대한 일반적인 테스트 하네스는 이렇게 생겼을 것이다:
package main
import "testing"
func Test_analyze(t *testing.T) {
t.Run("Test something", func(t *testing.T) {
if err := analyze("test.txt"); (err != nil) != false {
t.Errorf("analyze() error = %v", err)
}
})
}겉보기엔 문제없어 보인다?
문제점
이 방식도 동작은 하지만, 테스트를 실행하는 동안 파일 I/O를 하는 것이 항상 좋은 생각은 아니다. 예컨대 제한된 환경에서 실행 중이라 파일에 접근할 수 없는 경우도 있다. 이를 피하기 위해 임시 파일을 사용할 수도 있다.
하지만 디스크 I/O 자체가 문제를 일으켜 테스트가 간헐적으로 실패하고 좌절감을 줄 수도 있다.
테스트 중에 다른 프로세스가 파일을 수정할 수도 있다. 이 모든 문제는 코드 자체와는 아무 상관이 없다.
게다가 테스트만 봐서는 정확히 무슨 일이 일어나고 있는지 알기 어렵다. 텍스트 파일을 먼저 열어봐야 한다.
많은 사람들이 대신 모킹을 제안한다. 이를 위한 강력한 라이브러리도 꽤 있는데, spf13/afero 같은 것이 대표적이다. 이런 패키지들은 백그라운드에서 임시 파일을 생성하고 나중에 정리해 준다.
개인적으로 모킹은 테스트에서 최후의 수단이어야 한다고 생각한다. 모킹을 하기 전에 코드에서 올바른 추상화를 사용하고 있는지 점검해 보자. 인터페이스를 기반으로 구현하거나 의존성 주입(Dependency Injection)을 사용하면 컴포넌트 간 결합도를 낮추는 데 도움이 될 수도 있다. 대체로 관심사를 명확히 분리하는 것만으로도 충분한 경우가 많다.
테스트를 더 쉽게 만들기 위한 리팩터링
위 예제에서는 파일 I/O와 분석 로직을 분리하면 모킹이나 임시 파일을 쉽게 피할 수 있다. analyze 함수가 io.Reader를 받는 doSomething을 호출하도록 리팩터링하면 된다. (당장은 문자열 배열을 사용해도 된다.)
이제 main.go는 이렇게 보인다:
package main
import (
"bufio"
"io"
"os"
)
func main() {
analyze("test.txt")
}
func analyze(file string) error {
handle, err := os.Open(file)
if err != nil {
return err
}
defer handle.Close()
return doSomething(handle)
}
func doSomething(handle io.Reader) error {
scanner := bufio.NewScanner(handle)
for scanner.Scan() {
// Do something with line
_ = scanner.Text()
}
return nil
}이제 실제 분석 로직을 격리해서 테스트할 수 있다:
package main
import (
"strings"
"testing"
)
func Test_analyze(t *testing.T) {
t.Run("Test something", func(t *testing.T) {
if err := doSomething(strings.NewReader("This is a test string")); (err != nil) != false {
t.Errorf("analyze() error = %v", err)
}
})
}analyze("test.txt")를 doSomething(strings.NewReader("This is a test string"))로 바꿨다. (물론 analyze()에 대해서도 별도의 테스트를 작성해야 하지만, 여기서는 데이터 소스에 구애받지 않는 부분을 분리하는 데 초점을 맞춘다.)
결과
코드를 약간 리팩터링하는 것만으로 다음과 같은 이점을 얻었다:
- 간단한 테스트 용이성: 모킹이나 임시 파일이 필요 없다.
- 관심사 분리: 각 함수가 정확히 하나의 역할만 한다.
- 더 쉬운 코드 재사용:
doSomething()함수는 어떤io.Reader와도 동작하며 다른 곳에서도 호출할 수 있다. 원한다면 별도 라이브러리로 옮길 수도 있다.
Reddit에서 사용자 soapysops가 중요한 지적을 남겼다:
일반적으로 API에서 파일 이름을 받는 것을 선호하지 않는다. 파일 이름만으로는 사용자에게 충분한 제어권을 주지 못한다. 예를 들어 특이한 인코딩이나 특수한 파일 권한, 실제 파일 대신 bytes.Buffer를 사용하는 것 등을 할 수 없다. 파일 이름을 받는다는 것은 코드에 파일 시스템이라는 거대한 의존성을 추가하는 것이며, 그에 딸린 OS별 세부 사항까지 모두 떠안게 된다.
그래서 아마 파일 이름 기반 API는 없애고 io.Reader 기반 API만 노출했을 것이다. 그렇게 하면 완전한 코드 커버리지를 확보하고, 테스트도 빨라지며, 신경 써야 할 엣지 케이스도 훨씬 줄어든다.
이 의견에 전적으로 동의한다.
하지만 사용자-facing API를 쉽게 바꾸지 못하는 경우도 많다. API가 공개되어 이미 사용자가 있을 수도 있기 때문이다. 위 리팩터링은 더 나은 아키텍처를 향한 첫걸음에 불과하다. Go로 견고하고 테스트가 잘 된 시스템을 만들기 위해 할 수 있는 일은 분명 더 많이 있다.
참고 자료
관심이 생겼다면 같은 주제를 다루는 justforfunc #29: dependency injection in a code review도 확인해 보자:
추천하고 싶은 훌륭한 자료로 Learn Go with Tests가 있다. Go로 테스트 주도 개발을 배우고 TDD의 기초를 다지는 데 도움이 된다.
또 다른 자료는 Unix로 유명한 Brian W. Kernighan이 공동 집필한 The Go Programming Language 책이다. 실제 문제를 해결하기 위해 명확하고 관용적인 Go 코드를 작성하는 방법을 보여준다. 인터페이스와 테스팅에 대한 별도 장이 있으며, io.Reader도 더 자세히 다룬다.

글을 무작위로 읽기

댓글
로그인하고 댓글 남기기