본문 바로가기
개발/Golang

Golang의 test 이야기

by 상5c 2021. 7. 18.

이 글은 Saturday night study의 발표를 위해 작성한 내용입니다.
go 1.16버전을 기준으로 작성했습니다.

Test?

  • 소스 코드의 특정 모듈이 의도된 대로 정확히 작동하는지 검증하는 절차.
  • 함수를 검증하는 함수를 작성하는 것.
  • 테스트 종류
    • 단위 테스트
      • Unit(단위) + Test(테스트)
    • 인수 테스트 (acceptance test)
      • 사용자의 요구사항을 만족하는지 테스트
    • 통합 테스트 (integration test)
      • 기능들이 통합적으로 잘 작동하는지 테스트
    • 단위/인수/통합테스트를 범위로 나누는게 아니라 목적에 따른 분류라고 생각함.

어떻게 작성해야 하는가?

Go 테스트 코드 작성하기

테스트에 사용하는 프레임워크는 xUnit이 대표적이나 golang은 테스트 프레임워크를 내장하고 있다.

작성 규칙은 다음과 같다

  1. 파일명은 _test.go로 끝난다.
  2. 함수명은 Test로 시작한다.
  3. 매개변수는 t *testing.T를 받는다.
  4. 실패지점에서 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 or go 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개까지 깎음 → 충전로직 호출되는지 확인.....
      • 코드로 테스트한다면 수 초 이내로 확인할 수 있다.
  • 테스트 코드를 작성함으로써 논리적 오류를 예방
    • 휴먼 에러를 방지
    • 개발 완료 후 체크리스트의 역할을 하기도 한다.
  • 빠른 피드백을 통해 리팩토링을 돕는다.
    • 실수를 빠르게 발견할 수 있다.

생각해볼 주제

  • 어디까지 테스트를 작성해야 하는가?
  • 테스트 강제하기
    • .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)))
            })
        })
    },
)