Skip to main content 집밥서선생

테스트 작성

Published: 2023-02-06

본 글은 Golang을 공부하며 주요 내용이라 생각되는 것들을 기록해둔 자료이며, Ubuntu 22.04 LTS 기준으로 작성되었습니다.



Introduction


정말 오랜만에 Golang 포스팅을 다시 하게 되었다. 학업에 치이다 보니 잠시 Golang을 멀리 했었는데, 오랜만에 쓰려니 어디까지 했었는지 감이 잘 안온다. 기존에 내가 썼던 글 다시 보고 최대한 감을 되찾아 써보려 한다.


지난 20년간 테스트의 자동화는 코드 품질을 향상시키는 데 지대한 공헌을 했다. Go에서 표준 라이브러리로 테스트에 관련된 기능을 포함하는 것은 그리 놀라운 일이 아니다. 게다가 아주 쉽기 때문에, 테스트를 기피할 변명거리도 없을 것이다. 이번 챕터에서는 우리가 작성한 Go 코드를 테스트하고, 테스트를 unit test 및 integration test로 묶고, 코드 커버리지를 평가하고, 벤치마크를 작성하고, race checker를 이용하여 코드에서 동시성 문제를 확인하는 방법을 알아볼 것이다. 이를 위해 테스트 가능한 코드를 작성하는 방법과, 이를 통해 코드 품질이 향상되는 이유에 대해 알아볼 것이다.



The Basics of Testing


Go의 테스트는 라이브러리와 도구로 나뉜다. 표준 라이브러리의 testing 패키지는 테스트를 작성하기 위한 타입과 함수를 제공한다. 한편 Go와 함께 번들로 제공되는 gotest라는 도구는 테스트를 실행하고 보고를 생성한다. 다른 언어와는 달리 Go의 테스트는 프로덕션 코드와 같은 디렉토리 및 같은 패키지에 배치한다. 테스트가 같은 패키지에 위치하기 때문에, export되지 않은 함수나 변수에 액세스하여 테스트할 있다. 또한 이후에는 공개된 API만을 테스트하는 방법에 대해서도 알아볼 것이다.

먼저, 간단한 함수를 작성해 보자.

package adder

func addNumbers(x, y int) int {
    return x + x
}

또한, 이에 대응하는 테스트 코드도 작성해준다.

package adder

import "testing"

func Test_addNumbers(t *testing.T) {
	result := addNumbers(2, 3)
	if result != 5 {
		t.Error("incorrect result: expected 5, got", result)
	}
}

모든 테스트는 _test.go로 끝나는 이름을 가진 파일에 작성된다. 만약 foo.go 파일에 대한 테스트를 하고자 한다면, 테스트는 foo_test.go라는 파일에 작성되어야 할 것이다.

테스트 함수는 Test로 시작하며, *testing.T 타입의 단일 파라미터를 받는다. 보통 이 파라미터의 이름은 t로 한다. 함수는 아무 것도 반환하지 않는다. 테스트의 이름은 그 대상을 문서화하기 위한 것이기 때문에, 테스트할 대상을 잘 설명할 수 있도록 지어주어야 한다. 함수에 대한 unit test를 작성할 때는 Test 다음에 함수 이름을 넣는 것이 일반적이다. 만약 export되지 않은 함수에 대한 테스트라면 Test와 함수 이름 사이에 언더바(_)를 넣기도 한다.

또한, 표준 Go 코드를 사용하여 테스트 중인 코드를 호출하고 응답이 예상대로 이루어지는지 확인한다. 만약 결과가 옳지 않다면, fmt.Print()와 유사하게 동작하는 t.Error() 메소드를 통해 에러 보고를 만든다. 다른 에러 보고 메소드에 대해서도 차차 살펴볼 것이다.

바이너리를 빌드할 때 go build, 실행할 때 go run 명령어를 치듯, 테스트 할 때는 go test를 치면 된다.

$ go test
--- FAIL: Test_addNumbers (0.00s)
    adder_test.go:8: incorrect result: expected 5, got 4
FAIL
exit status 1
FAIL    github.com/jhseoeo/learning-go/13/adder     0.001s

이렇듯 테스트가 성공하지 못했음을 확인하였다. 우리가 작성한 함수에서 버그를 찾아 고쳐주자. return x + xreturn x + y로 고쳐주고 다시 go test를 실행하면 테스트가 성공적으로 끝남을 확인할 수 있다.

$ go test
PASS
ok      github.com/jhseoeo/learning-go/13/adder     0.001s

go test 명령어는 어떤 패키지에서 테스트를 진행할 지 명시할 수 있다. ./...를 붙이면 현재 디렉토리와 모든 하위 디렉토리에 대해서 테스트를 진행한다. -v 플래그가 있으면 verbose output을 출력한다.



Reporting Test Failures

*testing.T에는 테스트 실패를 보고하기 위한 몇 개의 메소드가 있다. fmt.Print()와 유사한 Error()가 있듯, 포매팅을 할 수 있는 fmt.Printf()와 유사한 형태의 Errorf()가 존재한다.

t.Errorf("incorrect result: expected %d, but got %d", 5, result)

Error()Errorf()는 테스트가 실패했음을 명시하기 위해 사용되지만, 테스트되는 함수의 코루틴은 계속해서 실행된다. 만약 테스트가 실패하면 그 이상의 처리를 멈춰야 한다고 생각한다면 Fatal()Fatalf() 메소드를 사용하면 된다. Fatal()Error()와, Fatalf()Errorf()와 유사하다. 이들의 차이점은 테스트 실패 메시지가 생성되자마자 테스트 함수가 즉시 종료된다는 점이다. 다만 모든 테스트가 종료되는 것이 아니라, 현재 진행중인 테스트만 종료되는 것이다. 다른 나머지 테스트는 이후에 계속 진행된다.

언제 Error()/Errorf()를, 그리고 언제 Fatal()/Fatalf()를 써야 할까? 만일 테스트 실패로 인해 동일한 테스트 함수의 이후 테스트가 항상 실패하거나, 테스트가 panic 상태에 빠지는 경우 Fatal()/Fatalf()를 사용한다. 만약 여러 개의 서로 다른 항목에 대해 테스트하는 경우 Error()/Errorf()를 사용해야 최대한 많은 문제를 발견하여 테스트를 여러 번 돌릴 일 없게 만들어준다.



Setting Up and Tearing Down

테스트를 실행하기 전에 설정하고, 테스트가 완료되면 제거할 수 있는 특정 상태가 있을 수 있다. 이 경우 TestMain함수를 사용하여 이러한 상태를 관리한다.

package testmain

import (
	"fmt"
	"os"
	"testing"
	"time"
)

var testTime time.Time

func TestMain(m *testing.M) {
	fmt.Println("Set up stuff for tests here")
	testTime = time.Now()
	exitval := m.Run()
	fmt.Println("Clean up stuff after tests here")
	os.Exit(exitval)
}

func TestFirst(t *testing.T) {
	fmt.Println("TestFirst uses stuff set up in TestMain", testTime)
}

func TestSecond(t *testing.T) {
	fmt.Println("TestSecond uses stuff set up in TestMain", testTime)
}

*testing.M 타입의 파라미터를 받는 함수 TestMain()을 선언하였고, 패키지 레벨 변수 testTime을 참조하는 TestFirst(), TestSecond() 함수가 있다. TestMain() 함수가 있는 패키지에서 go test를 실행하면 테스트를 직접 호출하는 대신 TestMain() 함수가 호출된다. 일단 상태가 설정되면, *testing.MRun() 메소드를 호출한다. Run() 메소드는 exit code를 반환하며, 0은 모든 테스트에 통과했음을 나타낸다. 마지막으로 이 exit code와 함께 os.Exit()를 호출하면 된다.

go test를 실행한 결과는 다음과 같다.

$ go test
Set up stuff for tests here
TestFirst uses stuff set up in TestMain 2023-02-06 00:32:52.707847138 +0900 KST m=+0.000167689
TestSecond uses stuff set up in TestMain 2023-02-06 00:32:52.707847138 +0900 KST m=+0.000167689
PASS
Clean up stuff after tests here
ok      github.com/jhseoeo/learning-go/13/testmain  0.001s

TestMain()은 딱 한 번만 호출되며, 패키지당 하나의 TestMain()만 사용할 수 있다. TestMain()을 유용하게 사용할 만한 상황은 다음과 같다.

  • 데이터베이스 등, 외부 repository를 구축해야 하는 경우
  • 테스트하는 코드가 초기화해야 하는 패키지 레벨 변수에 의존하는 경우

이전에 언급하였듯 애초에 패키지 레벨 변수의 사용은 웬만하면 자제해야 한다. 프로그램에서 데이터가 어떻게 흘러가는지 이해하기 어렵게 만들기 때문이다. 따라서 TestMain()을 사용해야 하는 경우, 그보다 코드를 리팩토링하는 것을 먼저 고려해보는 것이 낫다.

*testing.TCleanup() 메소드는 하나의 테스트를 위해 생성된 임시 리소스를 청소하는 데 사용한다. Cleanup() 메소드는 반환값 및 파라미터가 없는 함수 하나를 파라미터로 받는데, 이 함수는 테스트가 완료되면 실행된다. 간단한 테스트라면 defer문을 사용하더라도 동일한 결과를 얻을 수 있을것이다. 하지만 아래와 같이 샘플 데이터를 설정할 때 helper function에 의존하는 경우 Cleanup()이 유용하다. defer처럼 Cleanup()을 여러 번 호출하여도 괜찮으며, 마지막에 추가된 함수가 먼저 호출된다.

package cleanup

import (
	"fmt"
	"os"
	"testing"
)

func createFile(t *testing.T) (string, error) {
	f, err := os.Create("tempfile")
	if err != nil {
		return "", err
	}
	t.Cleanup(func() {
		os.Remove(f.Name())
	})
	return f.Name(), nil
}

func TestFileProcessing(t *testing.T) {
	fName, err := createFile(t)
	if err != nil {
		t.Fatal(err)
	}
	fmt.Println(fName)
}


Storing Sample Test Data

go test 명령어는 현재의 패키지 디렉토리를 작업 디렉토리로 설정한다. 만약 특정 패키지에서 함수를 테스트하고자 할 때 샘플 데이터를 사용하고 싶다면, testdata라는 서브디렉토리를 만들면 된다. 이 디렉토리명은 Go에서 테스트 파일을 저장하는 공간으로 지정되어 있다. testdata에서 데이터를 읽어올 때는 반드시 상대경로를 사용한다. go test 명령어는 작업 디렉토리를 현재 패키지로 변경하기 때문에, 각각의 패키지는 상대 경로를 통해 testdata에 접근할 것이다.

예제 코드는 여기에서 찾아볼 수 있다.



Testing Your Public API

우리가 작성한 테스트 코드는 프로덕션 코드와 동일한 패키지에 위치하였다. 이 같은 방식으로 export된 함수나 export되지 않은 함수까지도 테스트할 수 있었다.

만약 패키지의 Public API만 테스트하려는 거라면, 이를 위한 Go의 규칙이 마련되어 있다. 프로덕션 코드와 같은 디렉토리에 테스트 코드를 두는 것은 동일하지만, 패키지 명으로 [패키지명]_test를 사용한다.

맨 처음 작성하였던 adder의 예제를 떠올려보자. 맨 처음의 adder는 export되지 않는 함수였다. 이번에는 export되게끔 아래와 같이 작성한다. 파일명은 adder_public.go로 하였다.

package adder

func AddNumbers(x, y int) int {
	return x + x
}

이렇게 export된 Public API에 대해, 테스트 코드를 아래와 같이 작성해준다. 파일명 또한 기존 테스트 코드처럼 뒤에 _test를 붙여, adder_public_test.go로 하였다.

package adder_test

import (
	"testing"

	"github.com/jhseoeo/learning-go/13-tests/adder"
)

func TestAddNumbers(t *testing.T) {
	result := adder.AddNumbers(2, 3)
	if result != 5 {
		t.Error("incorrect result: expected 5, got", result)
	}
}

주목할 점은 테스트 코드의 패키지명이 adder_test라는 것이다. 패키지가 다르기 때문에 AddNumbers()adder로부터 import해와야 하며, adder.AddNumbers()로 호출한다. 또한 테스트의 이름 설정 규칙에 따라, 테스트 함수의 이름은 AddNumbers() 함수와 매칭되어야 한다.

패키지 안에서 export된 함수를 호출할 수 있는 것처럼, 소스 코드와 동일한 패키지에서 Public API를 테스트할 수 있다. 위와 같은 _test 접미사 패키지를 사용하면 export된 함수, 메소드, 타입, 상수, 변수에만 접근이 가능해지기 때문에, 블랙 박스 테스팅이 강제된다. 또한 _test가 붙은 패키지와 붙지 않은 두 개의 패키지가 동일한 디렉토리에 있을 수 있다.



Use go-cmp to Compare Test Results

복합 유형 인스턴스끼리 정확히 비교하는 코드는 다소 길어질 수 있다. reflect.DeepEqual()을 사용하여 struct, map, slice를 비교할 수 있지만 더 나은 방법이 있다. 구글은 go-cmp라는 서드파티 모듈을 출시했다. go-cmp는 비교를 하여 일치하지 않는부분에 대한 상세한 설명을 제공한다. 간단한 struct와 함께 go-cmp를 사용해보자.

type Person struct {
    Name      string
    Age       int
    DateAdded time.Time
}

func CreatePerson(name string, age int) Person {
    return Person{
        Name:      name,
        Age:       age,
        DateAdded: time.Now(),
    }
}

먼저 구조체 타입과 이에 대한 Factory 함수를 정의해주었다. 이제 테스트 코드를 작성해보자.

package cmp_test

import (
	"testing"

	"github.com/google/go-cmp/cmp"
	testcmp "github.com/jhseoeo/learning-go/13-tests/cmp"
)

func TestCreatePerson(t *testing.T) {
	expected := testcmp.Person{
		Name: "Dennis",
		Age:  37,
	}

	result := testcmp.CreatePerson("Dennis", 37)
	if diff := cmp.Diff(expected, result); diff != "" {
		t.Error(diff)
	}
}

먼저, 테스트 코드를 실행하려면 github.com/google/go-cmp/cmp를 임포트해주어야 한다. cmp.Diff() 함수는 예상되는 출력값과 테스트하는 함수의 실제 출력값을 파라미터로 받는다. 그리고 두 입력 간의 불일치가 있다면 이에 대한 설명을 출력한다. 만약 입력이 일치한다면, 빈 문자열을 반환한다.

go test를 입력하여, go-cmp가 어떤 결과를 내놓았을 지 확인해보자.

$ go test
--- FAIL: TestCreatePerson (0.00s)
    cmp_test.go:18:   cmp.Person{
                Name:      "Dennis",
                Age:       37,
        -       DateAdded: s"0001-01-01 00:00:00 +0000 UTC",
        +       DateAdded: s"2023-02-07 00:47:22.742817119 +0900 KST m=+0.000517803",
          }

FAIL
exit status 1
FAIL    github.com/jhseoeo/learning-go/13-tests/cmp 0.001s

+-가 있는 라인에서 예측값과 실제 값의 차이를 나타낸다. 위 예제의 경우 DateAdded 필드가 일치하지 않았기 때문에 실패하였다. DateAdded 필드는 testcmp.CreatePerson()를 통해서만 제대로 처리할 수 있기 때문에, DateAdded 필드는 검사 대상에서 제외할 필요가 있다. 아래와 같이 비교 함수를 정의해서, 비교하고자 하는 필드를 지정할수 있다.

func TestCreatePerson_IgnoreDate(t *testing.T) {
	expected := testcmp.Person{
		Name: "Dennis",
		Age:  37,
	}
	result := testcmp.CreatePerson("Dennis", 37)
	comparer := cmp.Comparer(func(x, y testcmp.Person) bool {
		return x.Name == y.Name && x.Age == y.Age
	})
	if diff := cmp.Diff(expected, result, comparer); diff != "" {
		t.Error(diff)
	}
	if result.DateAdded.IsZero() {
		t.Error("DateAdded was not assigned")
	}

}

cmp.Comparer()에 두 인스턴스를 비교하는 익명 함수를 전달하여 비교 함수를 생성하였다. 전달되는 익명 함수는 반드시 동일한 타입의 두 파라미터를 입력으로 받아, bool 타입을 반환한다. 또한 symmetric(파라미터의 순서가 중요치 않음), deterministic(입력이 같다면 출력이 반드시 같음), pure(파라미터를 변경하지 않음)한 함수여야 한다. 위 예제에서는 NameAge 필드만 비교하여 DateAdded 필드는 생략하였다.

이후 cmp.Diff()를 호출할 때 비교 함수를 마지막 파라미터로 함께 보내주면 된다.

go-cmp에 대한 간략한 사용법을 리뷰하였다. 추가적인 사용 예는 문서를 참고해보자!



Table Tests


일반적으로 함수가 올바르게 동작하는 지 확인하기 위해서는 하나 이상의 테스트케이스가 필요한 법이다. 테스트 함수를 여러 개 작성하거나, 동일한 테스트 함수 내에서 여러 번의 테스트를 수행할 수 있지만 테스트 로직은 대개 반복적이다. 데이터와 함수를 설정하고, 입력을 명시하고, 출력을 확인하고, 예측값과 실제 출력값을 비교하는 절차를 반복하는 것이다. 이런 코드를 반복하여 작성하는 것보단 table test라 불리는 패턴을 사용하는 것이 좋을 수 있다.

가령, table 패키지에서 아래와 같은 함수를 테스트한다고 가정해보자.

package table

import "fmt"

func DoMath(num1, num2 int, op string) (int, error) {
	switch op {
	case "+":
		return num1 + num2, nil
	case "-":
		return num1 - num2, nil
	case "*":
		return num1 * num2, nil
	case "/":
		if num2 == 0 {
			return 0, fmt.Errorf("division by zero")
		}
		return num1 / num2, nil
	default:
		return 0, fmt.Errorf("unknown operator %v", op)
	}
}

이런 함수를 테스트하려면 각각의 경우에 대해 입력을 집어넣어서 유효한 결과를 성공적으로 반환하는지, 또는 에러를 잘 반환하는지 등 확인해볼 필요가 있다. 테스트 코드를 아래와 같이 작성할 수도 있지만, 그야말로 쌩노가다이다..!

func TestDoMath(t *testing.T) {
    result, err := DoMath(2, 2, "+")
    if result != 4 {
        t.Error("Should have been 4, got", result)
    }
    if err != nil {
        t.Error("Should have been nil error, got", err)
    }
    result2, err2 := DoMath(2, 2, "-")
    if result2 != 0 {
        t.Error("Should have been 0, got", result2)
    }
    if err2 != nil {
        t.Error("Should have been nil error, got", err2)
    }
    // and so on...
}

이러한 반복되는 코드를 table test로 교체해보자. 먼저, 익명 구조체의 slice를 선언한다. 이 구조체는 테스트명, 입력 파라미터, 반환값을 포함한다. slice의 각 항목은 각각의 테스트 케이스를 의미한다.

data := []struct {
		name     string
		num1     int
		num2     int
		op       string
		expected int
		errMsg   string
	}{
		{"addition", 2, 2, "+", 4, ""},
		{"subtraction", 2, 2, "-", 0, ""},
		{"multiplication", 2, 2, "*", 4, ""},
		{"division", 2, 2, "/", 1, ""},
		{"bad_division", 2, 0, "/", 0, `division by zero`},
	}

이제 data에서 반복문을 돌며, 각 테스트 케이스별로 *testing.TRun() 메소드를 수행할 것이다. Run()은 테스트 케이스의 이름과 *testing.T 단일 파라미터를 가진 함수를 파라미터로 받는다. 이 함수 안에서는 DoMath()를 호출하여 data의 각 필드값을 사용하여 반복적으로 테스트를 진행한다. 또한 테스트 명령어 입력시 -v 플래그를 주면 각 테스트케이스가 테스트명과 함께 출력된다.

$ go test -v
=== RUN   TestDoMath
=== RUN   TestDoMath/addition
=== RUN   TestDoMath/subtraction
=== RUN   TestDoMath/multiplication
=== RUN   TestDoMath/division
=== RUN   TestDoMath/bad_division
--- PASS: TestDoMath (0.00s)
    --- PASS: TestDoMath/addition (0.00s)
    --- PASS: TestDoMath/subtraction (0.00s)
    --- PASS: TestDoMath/multiplication (0.00s)
    --- PASS: TestDoMath/division (0.00s)
    --- PASS: TestDoMath/bad_division (0.00s)
PASS
ok      github.com/jhseoeo/learning-go/13-tests/table       0.001s

위 예제에서는 error의 에러 메시지를 비교하여 예측 에러와 실제 에러를 비교하지만, 실제로는 errors.Is() 또는 errors.As()를 사용하여 에러를 검증하는 편이 더 이상적이다.



Code Coverage

Code Coverage는 놓친 테스트 케이스를 찾아낼 수 있는 유용한 도구이다. 다만 100%의 Code Coverage라고 해서 코드가 어떠한 입력에 대해 버그를 일으키지 않으리라는 것을 의미하지는 않는다.

go test -v -cover -coverprofile=c.out

위와 같이 go test 명령어에 -cover플래그를 달면 Code Coverage를 계산할 수 있다. 또한 -coverprofile 플래그로 출력 파일명을 명시하면 coverage에 대한 정보를 파일로 저장할 수 있다.

$ go test -v -cover -coverprofile=c.out

...
coverage: 87.5% of statements

이처럼 coverage의 달성률을 보여주며, 파일은 아래와 같이 저장된다.

mode: set
github.com/jhseoeo/learning-go/13-tests/table/table.go:5.53,6.12 1 1
github.com/jhseoeo/learning-go/13-tests/table/table.go:7.11,8.26 1 1
github.com/jhseoeo/learning-go/13-tests/table/table.go:9.11,10.26 1 1
github.com/jhseoeo/learning-go/13-tests/table/table.go:11.11,12.26 1 1
github.com/jhseoeo/learning-go/13-tests/table/table.go:13.11,14.16 1 1
github.com/jhseoeo/learning-go/13-tests/table/table.go:17.3,17.26 1 1
github.com/jhseoeo/learning-go/13-tests/table/table.go:18.10,19.50 1 0
github.com/jhseoeo/learning-go/13-tests/table/table.go:14.16,16.4 1 1

마지막이 0으로 끝나는 라인이, 테스트 케이스에 의해 cover되지 않은 코드의 위치를 의미한다.


이 방식도 충분히 좋지만, 조금 더 직관적인 도구가 cover라는 도구이다. HTML로 coverage 정보를 소스코드와 함께 나타내주기 때문에 매우 직관적이다. 아래 명령어를 통해 실행해보자.

go tool cover -html=c.out

명령어를 입력하면 브라우저가 열리고, 아래 사진처럼 보다 직관적으로 붉게 색칠된 영역의 코드를 통해 테스트 케이스에 의해 cover되지 않은 코드를 보여준다!

go cover screenshot

단순한 인터페이스이다. 좌측 상단 콤보박스에 테스트된 파일의 목록이 나타나며, 회색/빨상색/초록색으로 코드가 cover되는지 여부를 나타낸다.

우리는 잘못된 연산자 기호를 입력 파라미터로 넘기는 테스트케이스를 작성하지 않았기 때문에, default case가 cover되지 않았다. 만약 테스트 케이스 slice에 다음과 같은 항목을 추가한다면 문제가 해결될 것이다.

{"bad_op", 2, 2, "?", 0, `unknown operator ?`},

추가하였다면 아래 명령어를 입력하여 재실행해보자.

go test -v -cover -coverprofile=c.out && go tool cover -html=c.out

이전에 cover되지 않았던 default case가 통과한 것을 알 수 있을 것이다. 귀찮으니 사진은 생략해야지~

다시 한번 강조하지만, 100%의 Code Coverage라고 해서 코드에 버그가 없는 것이 아니니 Code Coverage만 철썩같이 믿는 일은 없도록 해야 한다..!



Benchmarks

작성된 코드가 얼마나 빠른지/느린지, 즉 성능을 판단하는 것은 매우 어려운 일이다. 이를 우리가 스스로 판단하기 보단 Go에서 제공하는 도구를 사용하는 것이 훨씬 좋을 것이다.

아래의 예제 함수를 보자.

func FileLen(f string, bufsize int) (int, error) {
	file, err := os.Open(f)
	if err != nil {
		return 0, err
	}
	defer file.Close()

	count := 0
	buf := make([]byte, bufsize)
	for {
		num, err := file.Read(buf)
		count += num
		if err != nil {
			break
		}
	}
	return count, nil
}

파일 내 문자의 수를 세는 간단한 함수로, 파일명과 파일로부터 읽어올 버퍼의 크기를 파라미터로 받는다. 당연히 버퍼의 크기에 따라 이터레이션을 도는 횟수가 달라질 테니, 버퍼 크기에 따라 성능 차이가 많이 날 것이다.

먼저, 함수가 문제 없이 잘 동작하는지 확인해보도록 하자

package bench_test

import (
	"math/rand"
	"os"
	"testing"

	"github.com/jhseoeo/learning-go/13-tests/bench"
)

func TestMain(m *testing.M) {
	makeData()
	exitVal := m.Run()
	os.Remove("testdata/data.txt")
	os.Exit(exitVal)
}

func makeData() {
	file, err := os.Create("testdata/data.txt")
	if err != nil {
		panic(err)
	}
	defer file.Close()

	rand.Seed(1)
	for i := 0; i < 10000; i++ {
		data := makeWord(rand.Intn(10) + 1)
		file.Write(data)
	}
}

func makeWord(l int) []byte {
	out := make([]byte, l+1)
	for i := 0; i < l; i++ {
		out[i] = 'a' + byte(rand.Intn(26))
	}
	out[l] = '\n'
	return out
}

func TestFileLen(t *testing.T) {
	result, err := bench.FileLen("testdata/data.txt", 1)
	if err != nil {
		t.Fatal(err)
	}
	if result != 65204 {
		t.Error("Expected 65204, got", result)
	}
}

랜덤 시드가 고정값이라, 아마 테스트에 통과하긴 할 것이다. 그리고 우리의 목표는, 최적의 버퍼 크기를 찾아내는 것이다.

최적화를 하기 위해 긴 여정을 떠나기에 앞서, 진짜 최적화를 할 필요가 있는 것인지에 대해 검토하는 게 좋다. 가령, 프로그램이 이미 응답 속도나 메모리 요구사항을 충족하고 있다면 기능 추가나 버그 수정에 시간을 쓰는 게 낫다. 비즈니스 요구사항에 따라 응답 속도 및 메모리 요구사항이 정의된다.


Go에서 벤치마크 함수를 작성하려면, 테스트 코드에서 Benchmark로 시작하는 함수 이름을 지어주면 된다. 또한 *testing.B 타입의 단일 파라미터를 받으며, *testing.B는 벤치마킹 관련 기능과 함께 *testing.T의 모든 기능을 포함한다.

먼저, 버퍼 크기가 1 바이트인 경우의 벤치마크를 측정해보자.

var blackhole int

func BenchmarkFileLen1(b *testing.B) {
	for i := 0; i < b.N; i++ {
		result, err := bench.FileLen("testdata/data.txt", 1)
		if err != nil {
			b.Fatal(err)
		}
		blackhole = result
	}
}

위 코드에서 패키지 레벨 변수인 blackhole을 사용하는 것이 다소 의아해 보인다. 책에서는 FileLen()을 반복 호출하는 과정에서 컴파일러가 알아서 최적화하는 것을 막기 위함이라고 하는 모양이다.

아무튼, Go의 벤치마크는 반드시 0부터 b.N만큼의 이터레이션을 돌아야 한다. 테스트 프레임워크는 타이밍 결과가 정확할 때까지 N 값을 늘려가며 벤치마크 함수를 돌릴 것이다. 출력된 결과에 대해서는 조금 있다 확인할 것이다.

벤치마크를 돌리기 위해서는 go test 명령어에 -bench 플래그를 추가해주면 된다. 이 플래그는 실행하고자 하는 벤치마크의 이름을 표현하는 정규식을 받는다. -bench=.와 같이 주면 모든 벤치마크를 실행한다. 한편 -benchmem 플래그를 추가하면 벤치마크 출력에 메모리 할당 관련 정보를 포함한다. 또한 벤치마크 이전에 테스트가 실행되므로, 테스트가 통과될 때만 코드를 벤치마크할 수 있다.

다음은 -benchmem 플래그를 활성화하여 명령어를 입력한 뒤, 출력된 결과를 확인해보자.

BenchmarkFileLen1-12	66	17917232 ns/op	129 B/op 	4 allocs/op

각 정보는 다음과 같은 의미를 갖는다.

  • BenchmarkFileLen1-12: 벤치마크명-벤치마크의 GOMAXPROCS 값
  • 66: 안정적인 결과를 얻기 위해 실행한 테스트의 수
  • 17917232 ns/op: 하나의 벤치마크를 통과하는 데 걸린 시간(나노초)
  • 129 B/op: 하나의 벤치마크를 통과하는데 할당된 바이트 수
  • 4 allocs/op: 하나의 벤치마크를 통과하는 동안 힙에서 바이트를 할당한 횟수 (할당된 바이트 수보다 작거나 같음)

이제 1 바이트 말고, 버퍼 크기를 키워서 벤치마크를 돌려보자.

var blackhole int

func BenchmarkFileLen2(b *testing.B) {
	for _, v := range []int{1, 10, 100, 1000, 10000, 1000000} {
		b.Run(fmt.Sprintf("FileLen-%d", v), func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				result, err := bench.FileLen("testdata/data.txt", v)
				if err != nil {
					b.Fatal(err)
				}
				blackhole = result
			}
		})
	}
}

t.Run()을 통해 table test를 하던 것처럼, b.Run()으로 여러 입력의 벤치마크를 실행할 수 있다. 다음과 같은 결과를 얻었다.

BenchmarkFileLen2/FileLen-1-12			66		17921247 ns/op	129 B/op		4 allocs/op
BenchmarkFileLen2/FileLen-10-12			667		1791381 ns/op	144 B/op		4 allocs/op
BenchmarkFileLen2/FileLen-100-12		6368	187415 ns/op	240 B/op		4 allocs/op
BenchmarkFileLen2/FileLen-1000-12		52539	22864 ns/op		1152 B/op		4 allocs/op
BenchmarkFileLen2/FileLen-10000-12		183391	6271 ns/op		10368 B/op		4 allocs/op
BenchmarkFileLen2/FileLen-1000000-12	24865	47952 ns/op		1007753 B/op	4 allocs/op

어느 정도 충분히 예상할 수 있는 결과였다. 버퍼의 크기를 늘릴 수록 메모리 할당 회수가 적어지니 코드가 빠르게 돌아간다. 한편 버퍼의 크기가 파일의 크기에 비해 커지면 불필요한 할당이 생겨 결과가 느려진다. 위 예제에서는 10000 바이트의 버퍼가 최적의 크기임을 알 수 있다. 또한, 성능을 높이려면 메모리를 많이 써야 하고, 메모리를 적게 쓰려면 성능을 포기해야 하는 trade-off에 대해서도 확인할 수 있다.

벤치마킹을 통해 성능 및 메모리 문제를 발견했다면, 다음 단계는 문제가 정확히 무엇인지 파악하는 것이다. Go에는 프로그램의 CPU 및 메모리 사용량 데이터를 수집하는 프로파일링 및 시각화 도구가 포함되어 있다. 이에 대한 내용은 여기를 참고하면 좋을 것 같다.



Stubs

지금까지 우리가 작성한 테스트는 다른 코드에 의존하지 않는 함수에 대한 테스트였다. 하지만 대부분의 코드는 의존성으로 가득하다!

Go에서는 함수 호출을 추상화하는 두 가지 방법이 있는데, 바로 함수 타입을 정의하는 것과 인터페이스를 정의하는 것이다. 이러한 추상화는 프로덕션 코드를 모듈화함으로써 유닛 테스트를 수월하게 할 수 있게끔 해준다.

아래의 예제를 살펴보자.

type Processor struct {
	Solver MathSolver
}

type MathSolver interface {
	Resolve(ctx context.Context, expression string) (float64, error)
}

Processor라는 구조체 타입과 MathSolver라는 인터페이스 타입을 작성하였다. MathSolver의 구현체는 추후 작성할 것이다.

Processorio.Reader로부터 표현식을 읽어 계산된 값을 반환하는 메소드 ProcessExpression()를 가지고 있다.

func (p Processor) ProecessExpression(ctx context.Context, r io.Reader) (float64, error) {
	curExpression, err := readToNewLine(r)
	if err != nil {
		return 0, err
	}
	if len(curExpression) == 0 {
		return 0, errors.New("no expression to read")
	}
	answer, err := p.Solver.Resolve(ctx, curExpression)
	return answer, err
}

func readToNewLine(r io.Reader) (string, error) {
	var out []byte
	b := make([]byte, 1)
	for {
		_, err := r.Read(b)
		if err != nil {
			if err == io.EOF {
				return string(out), nil
			} else {
				return "", err
			}
		}
		if b[0] == '\n' {
			break
		}
		out = append(out, b[0])
	}
	return string(out), nil
}

ProcessExpression()을 테스트하기 위한 코드를 작성해보자. 먼저, 필요한 것은 Reslove() 메소드를 충족시키는 간단한 Stub을 정의하는 것이다.

type MathSolverStub struct{}

func (ms MathSolverStub) Resolve(ctx context.Context, expr string) (float64, error) {
	switch expr {
	case "2 + 2 * 10":
		return 22, nil
	case "( 2 + 2 ) * 10":
		return 40, nil
	case "( 2 + 2 * 10":
		return 0, errors.New("invalid expression: ( 2 + 2 * 10")
	}
	return 0, nil
}

다음으로, 이 Stub을 사용하는 유닛 테스트를 작성하는 것이다. 실제로는 에러 메시지에 대한 검증까지도 해야 하지만, 테스트의 간소화를 위해 생략하였다.

func TestProcessorProcessExpression(t *testing.T) {
	p := solver.Processor{MathSolverStub{}}
	in := strings.NewReader(`2 + 2 * 10
( 2 + 2 ) * 10
( 2 + 2 * 10`)
	data := []float64{22, 40, 0}
	hasErr := []bool{false, false, true}
	for i, d := range data {
		result, err := p.ProecessExpression(context.Background(), in)
		if err != nil && !hasErr[i] {
			t.Error((err))
		}
		if result != d {
			t.Errorf("Expected result %f, got %f", d, result)
		}
	}
}

이렇게 작성한 코드에 대해 테스트를 돌려 보면, 잘 동작함을 확인할 수 있다.


대부분의 Go의 인터페이스는 1개 내지는 2개 정도의 메소드만을 명시하지만, 가끔은 그 이상의 많은 메소드를 가진 인터페이스를 작성하게 될 떄도 있을 것이다. 가령, 아래와 같은 인터페이스가 있다고 해보자.

type Entities interface {
	GetUser(id string) (User, error)
	GetPets(userId string) ([]Pet, error)
	GetChildren(userId string) ([]Person, error)
	GetFriends(userId string) ([]Person, error)
	SaveUser(user User) error
}

이렇게 거대한 인터페이스의 테스트 코드를 작성하는 방법은 크게 두 가지 패턴이 있다.

하나는 구조체에 인터페이스를 임베드하는 것이다. 인터페이스를 구조체에 임베딩하면 자동으로 인터페이스의 모든 메소드가 구조체에서 정의된다. 다만 메소드에 대한 구현이 되는 것까진 아니기 때문에, 현재 테스트에서 사용할 메소드는 구현해주어야 한다.

type Logic struct {
	Entities Entities
}

func (l Logic) GetPetNames(userId string) ([]string, error) {
	pets, err := l.Entities.GetPets(userId)
	if err != nil {
		return nil, err
	}

	out := make([]string, len(pets))
	for _, p := range pets {
		out = append(out, p.Name)
	}

	return out, nil
}

위와 같이, Entities를 필드로 갖는 구조체 Logic과 테스트하고자 하는 메소드인 GetPetNames()를 작성하였다. 이 메소드에서는 EntitiesGetPets() 메소드 하나밖에 사용하지 않는다. 따라서 Entities의 모든 메소드에 대한 Stub을 만들어주기 보단, GetPets()의 Stub만 만들어주는 게 편할 것이다.

아래처럼, Entities를 임베딩하는 구조체를 만들고, GetPets()의 Stub을 만들어보자.

type GetPetNamesStub struct {
	Entities
}

func (ps GetPetNamesStub) GetPets(userId string) ([]Pet, error) {
	switch userId {
	case "1":
		return []Pet{{Name: "Bubbles"}}, nil
	case "2":
		return []Pet{{Name: "Stampy"}, {Name: "Snowball"}}, nil
	default:
		return nil, fmt.Errorf("invalid id: %s", userId)
	}
}

GetPetNamesStub에서 GetPets()를 제외한 다른 메소드들은 구현되지 않긴 하지만 정의는 되어 있으므로 Entities를 충족시킨다. 따라서 위처럼 GetPets()만 작성해도 된다.

이제 GetPetNames()의 유닛 테스트를 작성할 수 있다. 테스트 코드를 작성해보자!

func TestLogicGetPetNames(t *testing.T) {
	data := []struct {
		name     string
		userID   string
		petNames []string
	}{
		{"case1", "1", []string{"Bubbles"}},
		{"case2", "2", []string{"Stampy", "Snowball"}},
		{"case3", "3", nil},
	}

	l := Logic{GetPetNamesStub{}}
	for _, d := range data {
		t.Run(d.name, func(t *testing.T) {
			petNames, err := l.GetPetNames(d.userID)
			if err != nil {
				t.Error(err)
			}
			if diff := cmp.Diff(d.petNames, petNames); diff != "" {
				t.Error(diff)
			}
		})
	}
}

이대로 go test를 실행하면 아마 case1, case2에서 테스트를 통과하지 못할 텐데, GetPetNames()에 작은 버그가 있어서 그렇다. 에러를 해결하고 나면 case1, case2를 통과하는 것을 볼 수 있을 것이다.

이처럼 인터페이스를 구조체에 임베드하여 사용하는 경우, 구현하지 않은 메소드를 호출해선 안된다. 아마 테스트 중 panic이 발생할 것이다! 따라서 인터페이스의 메소드 중, 테스트하는 함수에서 사용되는 것은 반드시 구현해줘야 한다.


인터페이스에서 1~2개 정도의 메소드를 구현하는 것은 단일 테스트에서는 별 문제가 없다. 하지만 입/출력이 다른 여러 테스트에서 동일한 메소드를 호출해야 할 때 문제가 발생할 수 있다. 이런 경우, 모든 테스트에 대해 가능한 모든 결과를 구현체 내에 포함하거나, 각 테스트에 대해 구현체를 다시 작성해야 한다. 이러한 구현체는 이해하거나 유지보수하기 매우 어려울 것이다.

더 좋은 방식은 함수 필드를 통해 우회하여 메소를 호출하는 Stub 구조체를 만드는 것이다. Entities에 정의된 각 메소드와 일치하게끔, Stub 구조체의 함수 필드를 정의해준다.

type EntitiesStub struct {
	getUser     func(id string) (User, error)
	getPets     func(userId string) ([]Pet, error)
	getChildren func(userId string) ([]Person, error)
	getFriends  func(userId string) ([]Person, error)
	saveUser    func(user User) error
}

func (es EntitiesStub) GetUser(id string) (User, error) {
	return es.getUser(id)
}

func (es EntitiesStub) GetPets(userId string) ([]Pet, error) {
	return es.getPets(userId)
}

func (es EntitiesStub) GetChildren(userId string) ([]Person, error) {
	return es.getChildren(userId)
}

func (es EntitiesStub) GetFriends(userId string) ([]Person, error) {
	return es.getFriends(userId)
}

func (es EntitiesStub) SaveUser(user User) error {
	return es.saveUser(user)
}

EntitiesStub에 함수 필드를 정의하고, Entities 인터페이스를 충족시키게끔 메소드를 정의해준다. 각각의 메소드에서는 대응되는 함수 필드를 호출한다.

func TestLogicGetPetNames2(t *testing.T) {
	data := []struct {
		name     string
		getPets  func(string) ([]Pet, error)
		userId   string
		petNames []string
		errMsg   string
	}{
		{"case1", func(userId string) ([]Pet, error) { return []Pet{{Name: "Bubbles"}}, nil }, "1", []string{"Bubbles"}, ""},
		{"case2", func(userId string) ([]Pet, error) { return nil, errors.New("invalid id: 3") }, "3", nil, "invalid id: 3"},
	}

	l := Logic{}

	for _, d := range data {
		t.Run(d.name, func(t *testing.T) {
			l.Entities = EntitiesStub{getPets: d.getPets}
			petNames, err := l.GetPetNames(d.userId)
			if diff := cmp.Diff(petNames, d.petNames); diff != "" {
				t.Error(diff)
			}

			var errMsg string
			if err != nil {
				errMsg = err.Error()
			}
			if errMsg != d.errMsg {
				t.Errorf("Expected error `%s`, got `%s`", d.errMsg, errMsg)
			}
		})
	}
}

위 예제의 Table test에서는 ‘data’ 구조체에 함수 필드를 추가해주었으며, getPets를 호출했을 때 반환할 데이터를 이 함수 필드에 명시할 수 있다. 이와 같이 Stub을 작성하는 경우, 각 테스트 케이스에서 Stub이 반환해야 하는 값이 확실하다는 장점이 있다. 각 테스트마다 새로운 EntitiesStub 인스턴스가 생성되고, EntitiesStub의 함수 필드인 getPets()에는 data에 명시된 함수 필드가 할당되므로, 직접 작성한 테스트 데이터로 GetPetNames()를 테스트할 수 있다.

Mock과 Stub은 자주 혼용되는 단어이지만 실제로는 서로 다른 개념이다. 요약하자면 Stub은 주어진 입력에 대한 출력값을 검사하는 것이라면, Mock은 주어진 입력에 대해 일련의 호출이 예상된 순서로 발생하는지 검사하는 것이다.

본 장에서 Stub 코드를 작성하였듯, Mock 관련 코드도 작성할 수 있다. 가장 유명한 Mock 괸련 라이브러리는 구글의 gomock과 Stretchr사의 testify이다. 후자가 github star 수는 더 높다!



httptest

HTTP 서비스를 호출하는 함수의 테스트 코드를 작성하는 것은 쉽지 않다. 보통 이런 경우 Integration test가 되며, 함수가 호출하는 서비스의 테스트 인스턴스를 준비해야 했다. 하지만 Go는 표준 라이브러리에 net/http/httptest 패키지를 포함하고 있으며, 덕분에 HTTP 서비스의 stub을 작성하기가 굉장히 쉽다.

solver 패키지로 되돌아가서, 표현식을 평가하기 위해 HTTP 서비스를 호출하는 MathSolver의 구현체를 작성해보자.

type RemoteSolver struct {
	MathServerURL string
	Client        *http.Client
}

func (rs RemoteSolver) RemoteSolver(ctx context.Context, expression string) (float64, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rs.MathServerURL+"?expression="+url.QueryEscape(expression), nil)
	if err != nil {
		return 0, err
	}
	res, err := rs.Client.Do(req)
	if err != nil {
		return 0, err
	}
	defer res.Body.Close()
	contents, err := ioutil.ReadAll(res.Body)
	if err != nil {
		return 0, err
	}
	if res.StatusCode != http.StatusOK {
		return 0, errors.New(string(contents))
	}
	result, err := strconv.ParseFloat(string(contents), 64)
	if err != nil {
		return 0, err
	}
	return result, nil
}

이제, httptest 라이브러리를 사용하여 서버를 구축하지 않고 이 코드를 테스트할 것이다. 먼저, 함수로 전달된 데이터가 서버에 도착하는지 확인해야 한다. 따라서 테스트 함수 안에서 입력과 출력을 저장할 info라고 하는 타입을 선언하고, 현재 입출력이 할당될 io라는 변수를 정의한다.

type info struct {
	expression string
	code       int
	body       string
}
var io info

다음으로, 가짜 원격 서버를 구축하고 이를 사용하여 RemoteSolver 인스턴스를 구성한다.

server := httptest.NewServer(
	http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		expression := r.URL.Query().Get("expression")
		if expression != io.expression {
			w.WriteHeader(http.StatusBadRequest)
			fmt.Fprintf(w, "expected expression '%s', got '%s'", io.expression, expression)
			return
		}
		w.WriteHeader(io.code)
		w.Write([]byte(io.body))
	}))
defer server.Close()

rs := RemoteSolver{
	MathServerURL: server.URL,
	Client:        server.Client(),
}

httptest.NewServer() 함수는 사용되지 않는 무작위 포트에 http 서버를 열며, 요청을 처리하기 위한 http.Handler 구현체를 설정해주어야 한다. 이것은 서버이기 때문에 테스트가 끝나면 닫아줘야 한다. 위 예제에서는 defer를 통해 close한다. 그리고 URLhttp.ClientRemoteSolver에 전달한다.

이렇게 셋업하고 나면, 테스트 함수의 나머지 부분은 다른 Table test와 크게 다를 바 없다.

data := []struct {
	name   string
	io     info
	result float64
	errMsg string
}{
	{"case1", info{"2 + 2 * 10", http.StatusOK, "22"}, 22, ""},
	{"case2", info{"( 2 + 2 ) * 10", http.StatusOK, "40"}, 40, ""},
	{"case3", info{"( 2 + 2 * 10", http.StatusBadRequest,
		"invalid expression: ( 2 + 2 * 10"},
		0, "invalid expression: ( 2 + 2 * 10"},
}

for _, d := range data {
	t.Run(d.name, func(t *testing.T) {
		io = d.io
		result, err := rs.Resolve(context.Background(), d.io.expression)
		if result != d.result {
			t.Errorf("io `%f`, got `%f`", d.result, result)
		}
		var errMsg string
		if err != nil {
			errMsg = err.Error()
		}
		if errMsg != d.errMsg {
			t.Errorf("io error `%s`, got `%s`", d.errMsg, errMsg)
		}
	})
}

위 코드에서 변수 io는 Stub 서버용과 테스트 함수 양쪽에서 Closure에 의해 캡처된다. 테스트 코드 쪽에서 write, Stub 쪽에서 read가 일어나는데, 이와 같은 방식은 이러한 테스트 코드라면 몰라도 프로덕션 코드에서는 지양해야 한다.



Integration Test

httptest 등을 쓰면 외부 서비스에 대한 테스트를 생략할 수 있지만, Integration Test를 작성해야 할 때가 올 것이며, 이를 통해 서비스 API에 대한 이해가 올바른지 확인할 수 있을 것이다.

문제는 자동화된 테스트를 그룹화하는 방법을 찾는 것이다. 적랍한 환경이 있는 경우에만 Integration Test를 실행해야 할 것이며, 일반적으로 Integration Test는 Unit Test보다 느리기 때문에 자주 실행하진 않을 것이다.

Go의 컴파일러는 코드를 컴파일할 때 build tag를 사용할 수 있게 해준다. 빌드 태그는 파일의 첫 번째 줄에 // +build로 시작하는 주석을 달아서 지정할 수 있다. 빌드 태그의 원래 의도는 서로 다른 코드가 다른 플랫폼에서 컴파일될 수 있게끔 하기 위한 것이지만, 테스트 그룹을 나눌 때도 유용하다. 빌드 태그가 없는 파일의 테스트는 항상 실행되며, 이들은 외부 리소스에 의존하지 않는 Unit test이다. 반면 빌드 태그가 있는 테스트는 필요한 리소스가 있을 때만 실행된다.

solver 패키지로 돌아와서, Integration Test를 작성할 것이다. 그 이전에 다음과 같은 Docker 명령어를 쳐서 서버 이미지를 다운받고, 8080 포트로 로컬 서버를 열어보자.

docker pull jonbodner/math-server
docker run -p 8080:8080 jonbodner/math-server

이제 Resolve 메소드가 서버와 잘 통신하는지 테스트하는 Integration Test를 작성해볼 것이다.

// +build integration

package solver

import (
	"context"
	"net/http"
	"testing"
)

func TestRemoteSolver_ResolveIntegration(t *testing.T) {
	rs := RemoteSolver{
		MathServerURL: "http://localhost:8080",
		Client:        http.DefaultClient,
	}

	data := []struct {
		name       string
		expression string
		result     float64
		errMsg     string
	}{
		{"case1", "2 + 2 * 10", 22, ""},
		{"case2", "( 2 + 2 ) * 10", 40, ""},
		{"case3", "( 2 + 2 * 10", 0, "invalid expression: ( 2 + 2 * 10"},
	}

	for _, d := range data {
		t.Run(d.name, func(t *testing.T) {
			result, err := rs.Resolve(context.Background(), d.expression)
			if result != d.result {
				t.Errorf("expected `%f`, got `%f`", d.result, result)
			}

			var errMsg string
			if err != nil {
				errMsg = err.Error()
			}
			if errMsg != d.errMsg {
				t.Errorf("expected error `%s`, got `%s`", d.errMsg, errMsg)
			}
		})
	}
}

파일의 맨 처음 부분은 // +build integration로 시작하며, 다른 부분은 이전에 작성하였던 table test와 크게 다르지 않다!

이렇게, 빌드 태그를 지정한 테스트를 실행하려면 다음과 같이 명령어를 실행하면 된다.

go test -tags integration

-v 플래그로 인해 어떤 테스트가 실행되었나 확인할 수 있을 것이다. 앞서 설명하였듯, 테스트 태그를 지정하지 않은 테스트들도 integration 태그의 테스트와 함께 모두 실행된 것을 확인할 수 있다.

go test에는 -short 플래그를 통해 테스트 시간이 오래 소요되는 항목을 생략할 수 있는 기능이 있다. 다만 short flag를 사용하면 안된다는 목소리도 있고, 좀 의견이 분분한 모양이다.

short flag에 대해 알고 싶다면 여기를 참고하자.



Race Checker

동시성 문제를 해결하기 위해 Go에서는 여러 가지 지원을 해주지만, 버그는 여전히 발생할 수 있다. 이를테면 서로 다른 고루틴이 lock 없이 하나의 변수에 접근하는 일은 충분히 발생할 수 있는 일이다. 이러한 상황을 data race라고 한다.

Go에서는 Race Checker를 통해 이러한 버그를 찾아내는 데 도움을 받을 수 있다. 코드에서 모든 data race를 찾아낸다고 보장하지는 않지만, data race를 찾을 경우 찾은 데이터에 적절한 lock을 설정해줘야 한다.

다음의 예제를 살펴보자.

func getCounter() int {
	var counter int
	var wg sync.WaitGroup
	wg.Add(5)

    for i := 0; i < 5; i++ {
        go func() {
            for i := 0; i := 1000; i++ {
                counter++
            }
            wg.Done()
        }()
    }

    wg.Wait()
    return counter
}

만약 race condition을 배제한다면, getCounter()의 반환값은 5000일 것이다. 하지만 race condition때문에 현실은 그렇지가 않다. 테스트 함수를 작성해보자.

func TestGetCounter(t *testing.T) {
	counter := getCounter()
	if counter != 5000 {
		t.Error("unexpected counter:", counter)
	}
}

간단한 테스트 함수이다. 출력된 결과가 예상값인 5000인지 아닌지만 테스트해주고 있다. go test를 입력하여 테스트를 수행하면,

--- FAIL: TestGetCounter (0.00s)
    race_test.go:8: unexpected counter: 4116
FAIL
exit status 1
FAIL    github.com/jhseoeo/learning-go/13-tests/race        0.001s

테스트를 통과할 때도 있지만, 가끔 이렇게 결과가 5000이 아닐 때가 있다!

공유된 변수 counter에 접근하여 값을 증가시키는 과정에서, data race로 인해 변수의 값 갱신이 누락되는 현상이 생기는 것이다. 아주 간단한 예제를 소개했지만, 실제로 코드에서는 data race를 유발하는 원인을 훨씬 찾기 힘들 것이다. 그만큼 Race Checker가 상당히 유용하게 사용될 수 있다!

Race Checker를 사용하려면 go test 명령어에 -race 플래그를 붙이면 된다.

$ go test -race
==================
WARNING: DATA RACE
Read at 0x00c000018248 by goroutine 10:
  github.com/jhseoeo/learning-go/13-tests/race.getCounter.func1()
      /home/junhyuk/Programming/Golang/13-tests/race/race.go:13 +0x46

Previous write at 0x00c000018248 by goroutine 8:
  github.com/jhseoeo/learning-go/13-tests/race.getCounter.func1()
      /home/junhyuk/Programming/Golang/13-tests/race/race.go:13 +0x58

Goroutine 10 (running) created at:
  github.com/jhseoeo/learning-go/13-tests/race.getCounter()
      /home/junhyuk/Programming/Golang/13-tests/race/race.go:11 +0x8d
  github.com/jhseoeo/learning-go/13-tests/race.TestGetCounter()
      /home/junhyuk/Programming/Golang/13-tests/race/race_test.go:6 +0x2b
  testing.tRunner()
      /usr/local/go/src/testing/testing.go:1439 +0x213
  testing.(*T).Run.func1()
      /usr/local/go/src/testing/testing.go:1486 +0x47

Goroutine 8 (finished) created at:
  github.com/jhseoeo/learning-go/13-tests/race.getCounter()
      /home/junhyuk/Programming/Golang/13-tests/race/race.go:11 +0x8d
  github.com/jhseoeo/learning-go/13-tests/race.TestGetCounter()
      /home/junhyuk/Programming/Golang/13-tests/race/race_test.go:6 +0x2b
  testing.tRunner()
      /usr/local/go/src/testing/testing.go:1439 +0x213
  testing.(*T).Run.func1()
      /usr/local/go/src/testing/testing.go:1486 +0x47
==================
--- FAIL: TestGetCounter (0.00s)
    race_test.go:8: unexpected counter: 30131
    testing.go:1312: race detected during execution of test
FAIL
exit status 1
FAIL    github.com/jhseoeo/learning-go/13-tests/race        0.007s

이처럼 race condition을 유발하는 코드 라인, 고루틴이 생성된 라인 등을 추적해준다.


프로그램을 빌드할 때도 -race 플래그를 붙여서 race checker를 실행할 수 있다. 이렇게 하면 빌드 이전에 테스트를 거치지 않고도 data race를 추적해볼 수 있다. 다만 -race 플래그가 붙은 채로 빌드된 바이너리는 속도가 매우 느리기 때문에, 프로덕션 코드에서는 절대 권장되지 않는다.



References


[

Learning Go Book Cover ](https://learning.oreilly.com/library/view/learning-go/9781492077206/)
[Jon Bodner, 『Learning Go』, O'Reilly Media, Inc.](https://learning.oreilly.com/library/view/learning-go/9781492077206/)


© 2024 JHSeo. All right reserved.