단위 테스트에서 파일 I/O를 피하기 위한 Go 코드 리팩터링
오늘 직장에서 간단한 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 같은 강력한 라이브러리도 꽤 있습니다. 이러한 패키지들은 백그라운드에서 임시 파일을 생성하고 나중에 정리합니다.
제 생각에 모킹은 테스트에 있어 최후의 수단이 되어야 합니다. 모킹을 하기 전에 코드에서 올바른 추상화를 사용하고 있는지 확인해 보세요. 인터페이스를 기반으로 구현하거나 의존성 주입을 사용하면 컴포넌트 간 결합도를 낮추는 데 도움이 될 수도 있습니다. 대부분의 경우 관심사를 명확하게 분리하는 것만으로 충분합니다.
테스트를 쉽게 만들기 위한 리팩터링
위의 사례에서는 파일 I/O와 분석 로직을 분리함으로써 목과 임시 파일을 쉽게 피할 수 있습니다. io.Reader를 받는 doSomething을 호출하도록 analyze 함수를 리팩터링하면 됩니다. (당장은 문자열 배열을 사용해도 됩니다.)
이제 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만 노출했을 겁니다. 그렇게 하면 완전한 코드 커버리지를 확보하고, 테스트도 빠르며, 신경 써야 할 엣지 케이스도 훨씬 줄어듭니다.
그 의견에 전적으로 동의합니다.
하지만 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에 대해서도 더 자세히 다룹니다.

글을 무작위로 읽기
