출처: https://tech.mercari.com/entry/2018/08/08/080000
TL; DR
- Go 테스트에서는 테스트 대상과 테스트 코드를 다른 패키지로 할 수 있다.
- 다른 패키지로 하면 비공개 기능(패키지에 내에서만 공개되는 메소드나 변수 등)에 접근할 수 없다.
- 테스트 대상이 동일한 패키지의 export_test.go 라는 파일을 통해 비공개 기능을 테스트 코드로만 공개하는 패턴이 있다
테스트 코드 패키지
Go는 기본적으로 하나의 디렉토리에 있는 코드는 하나의 패키지로 구성되어 있어야한다. 그러나 테스트의 경우는 예외로, 다음과 같이 mypkg 패키지의 테스트 코드는 mypkg_test이지만 문제 없다.
package mypkg
func Hoge() string { return "hoge" } package mypkg_test
import ( "testing"
"mypkg" )
func TestHoge(t *testing.T) { if s := mypkg.Hoge(); s != t.Error( } } |
물론 테스트 중인 코드와 테스트 코드의 패키지는 동일 할 수 있다. 그래도 테스트 대상과 테스트 코드의 패키지를 별도로 하는 장점은 무엇일까? 생갈할 수 있는 이점으로는 패키지를 분할해서 테스트 대상과 테스트 코드가 느슨하게 결합 할 수 있다는 점을 들 수 있다.
느슨하게 결합하여 테스트 코드는 어디까지나 테스트 중인 패키지의 사용자라는 입장에서 테스트를 쓸 수 있다. 이렇게하면 테스트 중인 패키지를 이용하는 측의 관점에서 볼 수 있고, "사용하기 쉬운 API를 하려면?' 라는 생각을 하면서 코드를 작성할 수 있다.
이런 이유로 나는 테스트 대상과 테스트 코드의 패키지를 나눌 수 있다면 적극적으로 분할해야 한다고 생각한다.
비공개(unexported) 기능 테스트
테스트 대상과 테스트 코드의 패키지는 분리하는 것이 좋다는 이야기를 했다. 그러나 아무래도 패키지를 함께 하고 싶은 경우가 있다. 비공개 변수의 값을 변경하거나 비공개 메서드를 호출하는 경우이다.
사실 go build와 go test는 빌드 대상 파일이 다르다. go build는 _test.go 접미사를 가진 테스트 파일은 빌드 대상에서 제외한다. 따라서 export_test.go 같은 이름을 붙여서 패키지 이름을 테스트 대상과 동일하게 하는 것으로 테스트 시에만 액세스 할 수 있는 변수와 함수를 만들 수 있다.
예를 들어 다음과 같은 구성의 패키지를 생각한다.
mypkg
├── mypkg.go
├── mypkg_test.go
└── export_test.go
mypkg.go 에는 아래와 같이 maxValue 라는 상수가 있을 경우 테스트 때만 이 값을 참조하고자 한다.
// mypkg.go package mypkg
const maxValue = 100 |
export_test.go에서 아래와 같이 정의하여 maxValue는 ExportMaxValue로 테스트 코드에 노출된다.
// export_test.go package mypkg // 테스트 대상과 같은 패키지
const ExportMaxValue = maxValue |
테스트 코드는 아래와 같이 공개된 상수를 참조 할 수 있다.
// mypkg_test.go package mypkg_test // 테스트 대상이 다른 패키지
import ( "testing"
"mypkg" )
func TestMypkg(t * testing.T) { // maxValue 대신 ExportMaxValue를 참조 if doSomething ()> mypkg.ExportMaxValue { t.Error ( "Error" ) } } |
export_test.go는아 go test의 때만 빌드 되기 때문에 일반 빌드에 ExportMaxValue는 포함되지 않으며, 테스트 코드 이외의 곳에서 볼 수 없다. 이처럼 export_test.go로 공개하는 곳을 한정함으로써 예기치 않게 사용 하는 것을 방지 할 수 있다. 이 export_test.go를 사용한 패턴은 실제로 표준 패키지인 net/http 패키지와 reflect 패키지에서 이용되고 있다. 덧붙여 export_test.go라는 파일 이름의 export의 부분에는 특별히 정해진 것은 아니지만 표준에서 배운 것처럼 export_test.go 라고 해 두는 것이 알기 쉽다.
그럼이 export_test.go를 이용한 비공개 기능을 사용한 테스트의 재미 있는 패턴을 볼려고 한다.
비공개 변수의 값을 변경하기
테스트 할 때 변수 값을 바꾸고 싶을 때가 있다. 예를 들어, 다음과 같이 서버의 URL이 설정인 경우를 생각한다.
// mypkg.go package mypkg
var baseURL = "https://example.com/api/v2" |
이 URL을 테스트 때만 변경하고자 한다. export_test.go에 아래와 같이 작성하여 SetBaseURL 함수를 사용하면 baseURL을 변경 할 수 있다. SetBaseURL 함수는 인수로 받은 URL을 baseURL로 설정한다. 그리고 SetBaseURL는 반환 값으로 baseURL을 원래대로 되돌리는 함수를 반환한다.
// export_test.go
package mypkg
func SetBaseURL (s string ) (resetFunc func ()) { var tmp string tmp, baseURL = baseURL, s return func () { baseURL = tmp } } |
아래와 같이 테스트 전에 defer SetBaseURL("http://localhost:8080/")() 처럼 호출 해 두는 것으로, 테스트 시에는 baseURL을 "http://localhost:8080" 으로 바꾸어 놓고, 테스트 함수가 종료 직전에 baseURL를 되 돌릴 수 있다.
// mypkg_test.go
package mypkg_test
import ( "testing"
"mypkg" )
func TestClient (t * testing.T) { // SetBaseURL에서 돌아온 함수를 defer 호출 defer mypkg.SetBaseURL("http : // localshot : 8080")()
// 다음에 테스트 코드 } |
비공개 메서드 호출
다음과 같은 테스트 대상 코드가 있는 경우, Counter 타입의 reset 메소드를 테스트 호출하고 싶다고 하는 경우를 생각한다.
// mypkg.go
package mypkg
type Counter struct { n int }
func (c * Counter) Count () { c.n ++ }
func (c * Counter) reset () { cn = 0 } |
메소드는 아래와 같이 메소드 값으로 변수에 넣을 수 있다.
func main () { var c Counter var reset func () = c.reset reset () } |
또한 아래와 같이 레시버를 속박 하지 않아도 변수에 넣을 수 있다. 이 때, 변수의 타입은 func (c *Counter) 처럼, 레시버가 제 1 인자이다. 또한 인수가 있는 메소드의 경우 레시버가 제 1 인수, 제 1 인수가 제 2 인수로 처럼 하나씩 밀려나는 형태이다.
func main () { var reset func (c * Counter) = (* Counter) .reset reset (& c) // 레시버가 속박 되지 않기 때문에 제 1 인자로 지정 } |
그런데 이를 바탕으로 다음과 같이 export_test.go에 쓰는 것으로 메서드를 노출 할 수 있다.
// export_test.go
package mypkg
var ExportCounterReset = (* Counter) .reset |
제 1 인수에 *Counter 타입의 값을 지정하여 호출 할 수 있다.
비공개 필드에 액세스 하기
테스트에서 비공개 필드에 액세스 하는 경우는 어떻게 해야할까? export_test.go는 테스트 대상 패키지와 동일한 패키지이기 때문에 다음과 같이 메서드를 추가 할 수 있다.
// export_test.go
package mypkg
func (c * Counter) ExportN () int { return cn } |
필드의 값을 바꾸려면 다음과 같이 setter를 만들면 좋을 것이다.
// export_test.go
package mypkg
func (c * Counter) ExportSetN (n int ) { cn = n } |
비공개 타입을 사용하기
테스트 대상의 패키지에 다음과 같은 타입이 정의되어 있고,이 타입을 테스트에 사용하고 싶다고 한다.
// mypkg.go
package mypkg
type response struct { Vaue string `json : "value"` } |
Go1.9에서 들어간 타입 별칭 기능을 사용하면 타입에 별명을 붙일 수 있다. 다음과 같이 별칭 이름을 대문자로 시작하여 테스트 코드에 노출 될 수 있다.
// export_test.go
package mypkg
type ExportResponse = response |
이렇게 함으로써 response 타입과 ExportResponse 타입은 완전히 동일한 타입으로 취급 되기 때문에 캐스팅 할 필요 없이 인수나 변수에 넣을 수 있다. 물론 필드나 메소드도 원래의 타입처럼 사용할 수 있다.
예 : 클라이언트 라이브러리 테스트
여기까지 비공개 기능을 사용한 테스트 방법을 설명했다. 마지막으로, 구체적인 예를 소개한다.
예를 들어, 다음과 같은 클라이언트 라이브러리를 작성하고 있다고 하자. Client 타입이 Get 메소드를 호출하면 서버에 요청이 날아간다. 요청 매개 변수로 n을 전달하고, 응답을 JSON으로 받는다. 또한 길어지므로 섬세한 오류 처리는 빼 먹고 있지만, 본래라면 상태 확인 등을 하는 것이 좋다.
// client.go package mypkg
import ( "encoding/json" "net/http" "net/url" "strconv" )
var baseURL = "https://example.com/api/v2"
type Client struct { // fields HTTPClient *http.Client }
func (cli *Client) httpClient() *http.Client { if cli.HTTPClient != nil { return cli.HTTPClient } return http.DefaultClient }
type getResponse struct Value string `json:"value"` }
func (cli *Client) Get(n int) (string, error v := url.Values{} v.Set("n", strconv.Itoa(n)) requestURL := baseURL + "/get?" + v.Encode() resp, err := cli.httpClient().Get(requestURL) if err != nil { return "", err } defer resp.Body.Close()
var gr getResponse dec := json.NewDecoder(resp.Body) if err := dec.Decode(&gr); err != nil { return "", err } return gr.Value, nil } |
그런데, 이 Client 타입의 테스트를 만들어 가보자. 아래와 같이 net/http/httptest 패키지를 사용하여 모의 서버를 세우고 거기에 기대 요청이 오는지 체크하려고 한다.
// client_test.go package mypkg_test
import ( "fmt" "net/http" "net/http/httptest" "strconv" "testing"
"mypkg" )
func TestGet(t *testing.T) { cases := map[string]struct { n int hasError bool }{ "100": {n: 100}, "200": {n: 200}, }
for n, tc := range cases { tc := tc t.Run(n, func(t *testing.T) { var requested bool s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requested = true if r.FormValue("n") != strconv.Itoa(tc.n) { t.Errorf("param n want %s got %d", r.FormValue("n"), tc.n) } fmt.Fprint(w, `{"value":"hoge"}`) })) defer s.Close()
cli := mypkg.Client{HTTPClient: s.Client()} _, err := cli.Get(tc.n) switch { case err != nil && !tc.hasError: t.Error("unexpected error:", err) case err == nil && tc.hasError: t.Error("expected error has not occurred") }
if !requested { t.Error("no request") } }) } } |
테스트를 실행하자. 실행하면 테스트를 실패한다. 아무래도 https://example.com/api/v2/get 접근 시도가 실패한 것 같다. 이 URL에 액세스 해 버리면 httptest.NewServer에서 만든 모의 서버에 액세스 할 수 없다.

httptest.Server에 URL라는 필드가 있기 때문에 이곳을 baseURL로 설정하면 좋을 것 같다. baseURL에 대한 설정은 "비공개 변수의 값 변경"에서 소개한 바와 같이, export_test.go 함수를 제공한다.
// export_test.go package mypkg
func SetBaseURL (s string ) (resetFunc func ()) { var tmp string tmp, baseURL = baseURL, s return func () { baseURL = tmp } } |
SetBaseURL 함수는 테스트 코드에서만 액세스 함수에 인수로 전달된 값을 baseURL로 설정한다. 반환 값은 설정한 값을 원래대로 되돌리는 함수로 이것을 호출하여 baseURL을 원래 값으로 되돌릴 수 있다. 여기서 주의 할 점으로는 SetBaseURL은 여러 고루틴에서 불리는 것은 기대하지 않기 때문에 테스트를 병행으로 수행하는 경우에는 주의가 필요하다.
SetBaseURL 함수는 아래와 같이 사용한다. defer mypkg.SetBaseURL(s.URL)() 같이 defer로 호출하여 하위 검사 함수가 종료 한 때 baseURL의 값을 반환하고, 다음 하위 검사로 이동한다.
// client_test.go package mypkg_test
import ( "fmt" "net/http" "net/http/httptest" "strconv" "testing"
"mypkg" )
func TestGet(t *testing.T) { cases := map[string]struct { n int hasError bool }{ "100": {n: 100}, "200": {n: 200}, }
for n, tc := range cases { tc := tc t.Run(n, func(t *testing.T) { var requested bool s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requested = true if r.FormValue("n") != strconv.Itoa(tc.n) { t.Errorf("param n want %s got %d", r.FormValue("n" } fmt.Fprint(w, `{"value":"hoge"}`) })) defer s.Close() defer mypkg.SetBaseURL(s.URL)() // baseURL을 mock 서버의 것으로 교체한다
cli := mypkg.Client{HTTPClient: s.Client()} _, err := cli.Get(tc.n) switch { case err != nil && !tc.hasError: t.Error("unexpected error:", err) case err == nil && tc.hasError: t.Error("expected error has not occurred" }
if !requested { t.Error("no request") } }) } } |

테스트를 잘 수행 할 수 있었다. 이렇게 해도 문제 없지만, fmt.Fprint(w, `{"value":"hoge"}`) 부분이 마음에 들지 않는다. 문자열 리터럴로 지정하는 것이 아니라 타입을 사용하여 JSON을 생성하고 싶다.
응답을 나타내는 getResponse 타입은 아래와 같이 정의되어 있고, 공개되지 않는다.
type getResponse struct { Value string `json : "value"` } |
이것을 공개하려면 export_test.go에서 아래와 같이 기술 할 필요가 있다.
// client_test.go package mypkg_test
import ( "encoding/json" "net/http" "net/http/httptest" "strconv" "testing"
"mypkg" )
func TestGet(t *testing.T) { cases := map[string]struct { n int hasError bool }{ "100": {n: 100}, "200": {n: 200}, }
for n, tc := range cases { tc := tc t.Run(n, func(t *testing.T) { var requested bool s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requested = true if r.FormValue("n") != strconv.Itoa(tc.n) { t.Errorf("param n want %s got %d", r.FormValue("n"), tc.n) } resp := &mypkg.ExportGetResponse{ Value: "hoge", } if err := json.NewEncoder(w).Encode(resp); err != nil { t.Fatal("unexpected error:", err) } })) defer s.Close() defer mypkg.SetBaseURL(s.URL)()
cli := mypkg.Client{HTTPClient: s.Client()} _, err := cli.Get(tc.n) switch { case err != nil && !tc.hasError: t.Error("unexpected error:", err) case err == nil && tc.hasError: t.Error("expected error has not occurred") }
if !requested { t.Error("no request") } }) } } |
위의 예와 비교하면 JSON을 생성하는 부분이 아래와 같이 변경되어 있다. ExportGetResponse 타입을 사용하여 JSON이 생성 되어 있는 것을 알 수 있다.
resp : = & mypkg.ExportGetResponse { Value : "hoge" , } if err : = json.NewEncoder (w) .Encode (resp); err! = nil { t.Fatal ( "unexpected error :" , err) } |