이 글은 Saturday night study의 발표를 위해 작성한 내용입니다.
go 1.16버전을 기준으로 작성했습니다.
Test?
- 소스 코드의 특정 모듈이 의도된 대로 정확히 작동하는지 검증하는 절차.
- 함수를 검증하는 함수를 작성하는 것.
- 테스트 종류
- 단위 테스트
- Unit(단위) + Test(테스트)
- 인수 테스트 (acceptance test)
- 사용자의 요구사항을 만족하는지 테스트
- 통합 테스트 (integration test)
- 기능들이 통합적으로 잘 작동하는지 테스트
- 단위/인수/통합테스트를 범위로 나누는게 아니라 목적에 따른 분류라고 생각함.
- 단위 테스트
어떻게 작성해야 하는가?
Go 테스트 코드 작성하기
테스트에 사용하는 프레임워크는 xUnit이 대표적이나 golang은 테스트 프레임워크를 내장하고 있다.
작성 규칙은 다음과 같다
- 파일명은
_test.go
로 끝난다. - 함수명은
Test
로 시작한다. - 매개변수는
t *testing.T
를 받는다. - 실패지점에서
t.Fail()
을 호출한다.
package study
import "testing"
func TestMain(t *testing.T) {
result := something()
if result == false {
t.Fail()
}
}
실제 사용시에는 t.Error()
, t.Fatal()
을 호출하면 에러 내용을 출력할 수 있다.
- 내부적으로 t.Log()와 t.Fail()을 호출한다
assert 패키지를 사용하면 조금 더 직관적으로 사용 가능하다.
package study
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMain(t *testing.T) {
result := something()
assert.Equal(t, true, result)
// same: assert.True(t, result)
}
테스트 코드 실행하기
go test [build/test flags] [packages] [build/test flags & test binary flags]
두 가지 실행 방법
- Local directory mode:
go test
orgo test -v
- Package list mode:
go test ./...
,go test main
- 캐시 가능하다.
테스트 플래그
- -run regexp
- 정규식으로 실행할 테스트를 지정한다.
- -bench regexp
- 정규식으로 실행할 벤치마크를 지정한다.
- default로 벤치마크는 실행되지 않으며 모두 실행하려면
-bench=.
또는-bench .
을 사용한다. - 실패하는 테스트가 있으면 벤치마크가 실행되지 않는다.
- -benchtime t
- 각 벤치마크를 t 시간동안 충분히 실행한다.
- t는 time.Duration 형태로 작성하거나 횟수제한을 할 수 있다. ex) 1h30s, 200x
- -v
- verbose output. log 출력
- 기타
- -failfast, -timeout, -cover, -count, -cpu, -parallel, -cpuprofile, -coverprofile
- 테스트 데이터는
testdata
폴더에 보관한다.
테스트 예)
package main
import (
"fmt"
"math/rand"
"testing"
)
func TestOne(t *testing.T) {
fmt.Println("test!")
t.Error("error!")
}
func TestTwo(t *testing.T) {
t.Log("로그를 보려면 -v 플래그가 필요해요")
}
벤치마크 예)
package main
import (
"strings"
"testing"
)
const VALUE = "test"
func BenchmarkStringConcatPlus(b *testing.B) {
str := ""
for i := 0; i < b.N; i++ {
str += VALUE
}
}
func BenchmarkStringConcatBuilder(b *testing.B) {
var builder strings.Builder
for i := 0; i < b.N; i++ {
builder.WriteString(VALUE)
}
}
왜 작성해야 하는가?
코드 검증하고 보호하기
- 책을 빌리면 대여권을 차감해야 한다.
- 모두 사용하면 캐시 충전 로직이 호출된다. → 10개 남으면 캐시 충전 로직을 호출해야 한다.
- 이걸 사람이 테스트한다면?
- 로그인 → 대여권 충전 → 대여권을 10개까지 깎음 → 충전로직 호출되는지 확인.....
- 코드로 테스트한다면 수 초 이내로 확인할 수 있다.
- 테스트 코드를 작성함으로써 논리적 오류를 예방
- 휴먼 에러를 방지
- 개발 완료 후 체크리스트의 역할을 하기도 한다.
- 빠른 피드백을 통해 리팩토링을 돕는다.
- 실수를 빠르게 발견할 수 있다.
생각해볼 주제
- 어디까지 테스트를 작성해야 하는가?
- 단순한 getter에도 테스트를 작성하는게 좋은가?
- 100%의 테스트 커버리지가 옳은가?
- 테스트 강제하기
- .git/hooks/pre-commit
#!/bin/sh FONT_YELLOW="\033[33m" BG_RED="\033[41m" NO_COLOR="\033[0m" # set test flag here GOTEST="go test -v ./..." echo "${FONT_YELLOW}>> Run [ `echo ${GOTEST}` ] before commit.${NO_COLOR}" ${GOTEST} if [ $? -ne 0 ]; then echo "${BG_RED}>> Commit fail! Check your code.${NO_COLOR}" exit 1 fi
더 나아가기
Panic 테스트
without assert
package main
import (
"errors"
"fmt"
"github.com/stretchr/testify/assert"
"testing"
)
func divide(a, b int) {
if b == 0 {
panic("0으로 나눌 수 없습니다")
}
fmt.Printf("%d / %d = %d\n", a, b, a/b)
}
func TestPanicWithoutAssert(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Errorf("The code did not panic")
}
}()
divide(1, 0)
}
- panic 테스트에선 defer recover가 panic보다 위에 있어야 한다.
with assert
func TestPanic(t *testing.T) {
assert.Panics(t, func() { divide(1, 0) })
assert.PanicsWithValue(t, "0으로 나눌 수 없습니다", func() { divide(1, 0) })
assert.PanicsWithError(t, "error!", func() { panic(errors.New("error!")) })
}
학습 테스트
- slice 학습테스트
func TestAppendCopy(t *testing.T) {
slice1 := []int{1, 2, 3, 4, 5}
slice1 = append(slice1, 6)
assert.Contains(t, slice1, 6)
}
계층형 테스트 코드
- Golang은 Subtest로 표현 (자바의 경우 @Nested)
func TestTeardownParallel(t *testing.T) {
// <setup code>
// This Run will not return until its parallel subtests complete.
t.Run("group", func(t *testing.T) {
t.Run("Test1", func(t *testing.T) {
assert.True(t, false)
})
t.Run("Test2", func(t *testing.T) {
assert.True(t, false)
})
})
// <tear-down code>
}
HTTP API Test
package server
import (
"fmt"
"net/http"
)
func MakeWebHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
fmt.Fprintf(writer, "HELLO WORLD")
})
return mux
}
func main() {
http.ListenAndServe(":8080", MakeWebHandler())
}
package server
import (
"github.com/stretchr/testify/assert"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestWenHandler(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
res := httptest.NewRecorder()
mux := MakeWebHandler()
mux.ServeHTTP(res, req)
assert.Equal(t, http.StatusOK, res.Code)
body, _ := io.ReadAll(res.Body)
assert.Equal(t, "HELLO WORLD", string(body))
}
TDD
- 테스트 주도 개발
- 순서
- 실패하는 테스트 작성
- 테스트를 만족하는 코드 작성
- 리팩토링
BDD
- BDD는 시나리오를 기반으로 테스트 케이스를 작성한다.
- 시나리오는 개발자가 아닌 사람이 봐도 이해할 정도로 작성한다.
- 비즈니스 요구사항에 집중해서 TestCase를 만들어야 한다.
- BDD는 TDD보다 자연어에 가까운 테스트 ?
GWT pattern
- Given: 사전 값 세팅. 상태를 정의한다.
- When: 테스트 진행시 필요한 조건, 행동. (~ 했을 때)
- Then: 예상 결과. 보장해야 하는 결과. (~ 해야한다)
func TestAppendCopy(t *testing.T) {
// given
slice1 := []int{1, 2, 3, 4, 5}
slice2 := append([]int{}, slice1...)
// when
slice2 = append(slice2, 6)
// then
assert.NotContains(t, slice1, 6)
}
DCI 패턴
- Describe: 테스트 대상이 되는 클래스, 메소드 이름 명시
- Context: 테스트할 메소드에 입력할 파라미터를 설명
- It: 테스트 대상 메소드가 무엇을 리턴하는지 설명
golang - Ginkgo
package bdd
func Add(left, right int) int {
return left + right
}
func Sub(left, right int) int {
return left - right
}
package bdd
import (
"testing"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func TestGinkgo(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "test functions suite")
}
var _ = Describe("Add method는",
func() {
Context("1과 2를 받으면", func() {
It("3을 반환한다.", func() {
Expect(3).To(Equal(Add(1, 2)))
})
})
Context("-1과 1을 받으면", func() {
It("0을 반환한다.", func() {
Expect(0).To(Equal(Add(-1, 1)))
})
})
},
)
var _ = Describe("Sub method는",
func() {
Context("1과 2를 받으면", func() {
It("-1을 반환한다.", func() {
Expect(-1).To(Equal(Sub(1, 2)))
})
})
Context("-1과 1을 받으면", func() {
It("-2를 반환한다.", func() {
Expect(-2).To(Equal(Sub(-1, 1)))
})
})
},
)