보통 TDD(Test Driven Developmen) 까진 안 해도
기본적인 틀을 갖추면 테스트를 병행해서 진행하는 편입니다
토이 프로젝트 기본적인 형태가 갖춰져서
우리의 소중한 동반자 [테스트]를 작성하면서 진행하려고 합니다
테스트는 안정성이 중요한 상용 프로젝트에서 가장 중요한 부분이라고 볼 수 있습니다
안정적이지 않은 App과 장애 발생은 서비스 가용성을 보장하지 못하게 되고
이는 단순한 비용 손해뿐만 아니라 가장 중요한 고객의 신뢰 손실까지 이어집니다
기업 입장에서 고객 신뢰성 상실은 가장 큰 손해로 이어지기 때문에
개발자들은 수많은 테스트들과 이중화 구성 등 만발의 준비를 합니다
높은 안정성 보장을 위해 테스트 케이스를 먼저 작성하는 TDD 방식의 개발도 채택되고 있습니다
위에서 말했듯 테스트는 정말 중요합니다
우리가 작성한 코드가 설계한 대로 안정적으로
동작한다고 보장할 수 있도록 최대한 많은 형태의
테스트 케이스를 작성할 수 있어야겠죠
사내에선 Go로 개발한 코드를 테스트하기 위해 GoConvey를 사용해왔는데
Go에서 어떠한 test 방식이 존재하고 선호되는지 궁금해져 정리해봤습니다
사실 테스트 방식에까지 어떠한 정답이 있다고 생각하지는 않습니다
하지만 더 편하거나 프로젝트 성향에 따라 적합한 방식은 있겠지요
프레임워크마다 지원하는 테스트의 범위도 다를 거고요
이번 포스팅에선 go test에서 추천되는 작성 guideline과
기본 go test를 포함해서 많이 사용되는
아래 프레임워크들을 간단히 예제와 정리해보겠습니다
한눈에 보고 선택하시는데 도움이 될 수 있을 것 같습니다
위 그래프는 LibHunt라는 awesome-go를 크롤링해서 만든 그래프 같습니다
확인해보니 정확한 정보를 가져오지 못했는지 상위 3개(Testify, GoConvey, ginkgo)를 제외하고는
제대로 된 go package가 아니었습니다(단순히 go로 구현된 거던가, test 용이 아니던가...)
[awesome-go]
/*
의도하진 않았는데 위의 그림은 마침 이전에 포스팅한 awesome-go와 관련이 있네요
go.libhunt라는 페이지가 긁어서 그래프를 그려주는 것 같습니다
관심 있으신 분들은 확인해보세요
*/
따라서, 상위 3가지(Testify, GoConvey, ginkgo)와
추가적으로 많이 사용되는 몇 가지 go test framework들을 정리해보겠습니다
Go Test Guide
test 작성에 정답은 없지만 일반적으로 go에서 추천하는 방법, 가이드가 있습니다
두 가지만 정리해보겠습니다
1. Table-Driven Test
먼저 table-driven test(테이블 주도 테스트)에 대해 정리하겠습니다
table-driven test는 package나 tool이 아니라 Go official Wiki에서
깨끗하고 더 나은 test를 작성하기 위해 권고하는 관점(perspective) 및 방식(way)입니다
여기서 말하는 table은 간단합니다
테스트되는 function의 input과 expected result로 이루어집니다
(테스트를 이해할 수 있는 이름 같은 정보가 추가될 수 도 있음)
하나의 테스트 코드가 작성되면 테이블의 각 entry가 해당 테스트 코드에 수행되는 방식입니다
var flagtests = []struct {
in string
out string
}{
{"%a", "[%a]"},
{"%-a", "[%-a]"},
{"%+a", "[%+a]"},
{"%#a", "[%#a]"},
{"% a", "[% a]"},
{"%0a", "[%0a]"},
{"%1.2a", "[%1.2a]"},
{"%-1.2a", "[%-1.2a]"},
{"%+1.2a", "[%+1.2a]"},
{"%-+1.2a", "[%+-1.2a]"},
{"%-+1.2abc", "[%+-1.2a]bc"},
{"%-1.2abc", "[%-1.2a]bc"},
}
func TestFlagParser(t *testing.T) {
var flagprinter flagPrinter
for _, tt := range flagtests {
t.Run(tt.in, func(t *testing.T) {
s := Sprintf(tt.in, &flagprinter)
if s != tt.out {
t.Errorf("got %q, want %q", s, tt.out)
}
})
}
}
위의 예제 코드와 같이 테이블 형태의 in, out을 만들어두고
하나의 테스트 코드 TestFlagParser에 각 in, out을 사용하는 방식이기 때문에 테이블 주도 테스트라고 합니다
장점?
기존에 새로운 테스트 케이스를 추가하기 위해서는
초기화, 세팅, 네이밍, 테스트 함수의 반복 등의 발생합니다
하지만 table-driven test 방식에서는 새로운 테스트 케이스를 추가하기 쉽습니다
오직 테이블에 새로운 entry를 추가하면 됩니다!
또한, 하나의 중앙화 된(centralized) 테스트 블럭 만으로도 제한 없이 테스트 케이스를 생성할 수 있습니다
테이블 형태로 input이 정리되어 있기 때문에 직관적이고 관리하기 쉬운 테스트 케이스를 사용할 수 있습니다
위와 같은 세 가지 장점으로 정리할 수 있습니다
Go 개발자라면 이러한 방식의 test를 작성하는 것에 이견이 없을 것 같습니다
2. Mocking
기본적으로 Go의 각 모듈은 interface를 통해 서로 상호작용하는 것을 추천합니다
이 방식의 장점 중 하나는 테스트 케이스를 작성하기 위해 mockup을 만들기 쉽다는 것입니다
위의 내용은 이전 DI 포스팅에서 DI의 장점으로 잠시 언급되었습니다
DI 방식으로 모듈을 개발하면 interface 방식을 써야 하기 때문에
테스트에서 mocking을 하는 데에도 이점을 가질 수 있는 것입니다
그럼 mock이란 무엇일까요?
mock은 '모조', '가짜'란 뜻입니다
소프트웨어에서는 특정 객체 대신에 가짜 객체를 만드는 것을 mocking이라고 합니다
가짜 객체를 왜 만들까요?
예를 들어 우리가 아래와 같이 SendMail 메소드를 가진 Notifier라는 구조체(객체)를 구현했습니다
package notifier
import "mailman"
type Notifier struct {
mailMan *MailMan
}
func (m *Notifier) SendMail(subject, body string, to ...*mail.Address) error {
// some code
m.mailMan.Send(subject, body, to)
if err != nil {
return err
}
return nil
}
func New(m *MailMan) *Notifier {
return &Notifier{m}
}
package mailman
type MailMan interface {
Send(subject, body string, to ...*mail.Address) error
}
Notifier의 SendMail 메소드는 MailMan이라고 하는 Interface의 Send 메소드를 호출합니다
그럼 Notifier의 SendMail 메소드를 테스트하려면 MailMan Interface가 구현이 되어야겠죠!
이 MailMan interface의 구현은 나 혹은 동료가 구현해야 하는 구조체(객체) 일 수도 있고,
이미 존재하는 라이브러리일 수도 있습니다
내가 직접 혹은 동료가 구현하는데 시간이 걸리거나
MailMan 라이브러리를 가져와 세팅하기에는
너무 복잡해서 배보다 배꼽이 더 큰 상황이 있을 수 있죠
이와 같이 우리가 Notifier 같은 새로운 객체를 만들어 테스트할 때마다
MailMan 같이 필요한 객체나 시스템을 구현하거나 가져와서 세팅하기에는
비용이 너무 큽니다(시간도 너무 많이 걸리고 어렵고)
또한, 실제로 테스트할 때마다 메일이 정말로 보내지면 관리하기도 어렵겠죠
때문에 실제로 메일을 보내는 MailMan 말고
테스트 용도로 구조체(객체)나 메소드를 흉내만 내는 Mock을 만듭니다
go에서는 아래처럼 interface의 메소드만 구현해주면 되죠
package mockmailman
import "fmt"
type MockMailMan struct {}
func (m *MockMailMan) Send(subject, body string, to ...*mail.Address) error {
fmt.Printf("Mail Sended : [%s] [%s] to %s\n", subject, body, to)
return nil
}
SendMail의 MailMan interface의 구현을 MockMailMan으로 주입하여 사용하면
우리는 아직 MailMan이 구현되지 않아도 Notifier를 테스트할 수 있게 되는 거죠!
mock을 위와 같이 대충 만들어서 테스트할 수도 있겠지만
interface를 보고 세련된 mock 코드를 생성해주는 tool들도 존재합니다
해당 내용은 기회가 되면 포스팅해보도록 하겠습니다
이와 같이 Mocking을 이용하면 테스트를 단순화하고
구현에 의존하지 않고 수월하게 진행할 수 있게 되는 것입니다
Go에서 테스트 유닛을 만들 때 table-driven test와 mocking을 이용하면
더 깨끗하고 효율적인 테스트 케이스를 작성할 수 있습니다
그럼 이제 testing framework들을 살펴봅시다
Go Test Framework
아래의 정말 간단한 코드를 테스트해보도록 하겠습니다
너무 간단해 table-driven test나 mocking은 적용 없이
딱 프레임워크들의 사용 스타일만 빠르게 이해하기에 좋은 예제입니다
package calc
// Add two numbers.
// Return the result.
func Add(a, b int) int {
return a + b
}
// Subtract two numbers.
// Return the result.
func Subtract(a, b int) int {
return a - b
}
// Multiply two numbers.
// Return the result.
func Multiply(a, b int) int {
return a * b
}
// Divide two numbers.
// Return the result.
func Divide(a, b int) float64 {
return float64(a / b)
}
1. Go standard testing package
기본적으로 Go testing package를 사용하는 방법이 있습니다
package calc_test
import (
. "github.com/bmuschko/go-testing-frameworks/calc"
"testing"
)
func TestAddWithTestingPackage(t *testing.T) {
result := Add(1, 2)
if result != 3 {
t.Errorf("Result was incorrect, got: %d, want: %d.", result, 3)
}
}
func TestSubtractWithTestingPackage(t *testing.T) {
result := Subtract(5, 3)
if result != 2 {
t.Errorf("Result was incorrect, got: %d, want: %d.", result, 2)
}
}
func TestMultiplyWithTestingPackage(t *testing.T) {
result := Multiply(5, 6)
if result != 30 {
t.Errorf("Result was incorrect, got: %d, want: %d.", result, 30)
}
}
func TestDivideWithTestingPackage(t *testing.T) {
result := Divide(10, 2)
if result != 5 {
t.Errorf("Result was incorrect, got: %f, want: %f.", result, float64(5))
}
}
go test를 실행하면 아래와 같이 결과를 확인할 수 있습니다
일반적으로 test에 사용하는 assertion 스타일이 아닙니다
if문으로 처리하다 보니 코드가 길어져 작성하기도 힘들고
테스트 조건을 한눈에 파악하기 쉽지 않습니다
Go standard package들은 아름다운 편이지만
이러한 방식이 선호된다고 하긴 힘들겠네요
2. Testify
package calc_test
import (
. "github.com/bmuschko/go-testing-frameworks/calc"
. "github.com/stretchr/testify/assert"
"testing"
)
func TestAddWithTestify(t *testing.T) {
result := Add(1, 2)
Equal(t, 3, result)
}
func TestSubtractWithTestify(t *testing.T) {
result := Subtract(5, 3)
Equal(t, 2, result)
}
func TestMultiplyWithTestify(t *testing.T) {
result := Multiply(5, 6)
Equal(t, 30, result)
}
func TestDivideWithTestify(t *testing.T) {
result := Divide(10, 2)
Equal(t, float64(5), result)
}
현재 Go에서 가장 널리 사용되고 있는 assertion 스타일의 라이브러리이며 mocking을 지원합니다
위의 Go standard와도 잘 동작합니다
특징
- 읽기 쉽고 깔끔하게 failure description 출력
- assertion 스타일로 코드가 간결해지며 가독성 up
- interface를 이용한 mock 코드 자동 생성 기능
- test suite 기능을 통해 관련 코드를 깔끔하게 묶어 사용 가능
3. GoConvey
import (
. "github.com/bmuschko/go-testing-frameworks/calc"
. "github.com/smartystreets/goconvey/convey"
"testing"
)
func TestAddWithGoConvey(t *testing.T) {
Convey("Adding two numbers", t, func() {
x := 1
y := 2
Convey("should produce the expected result", func() {
So(Add(x, y), ShouldEqual, 3)
})
})
}
func TestSubtractWithGoConvey(t *testing.T) {
Convey("Subtracting two numbers", t, func() {
x := 5
y := 3
Convey("should produce the expected result", func() {
So(Subtract(x, y), ShouldEqual, 2)
})
})
}
func TestMultiplyWithGoConvey(t *testing.T) {
Convey("Multiplying two numbers", t, func() {
x := 5
y := 6
Convey("should produce the expected result", func() {
So(Multiply(x, y), ShouldEqual, 30)
})
})
}
func TestDivideWithGoConvey(t *testing.T) {
Convey("Dividing two numbers", t, func() {
x := 10
y := 2
Convey("should produce the expected result", func() {
So(Divide(x, y), ShouldEqual, float64(5))
})
})
}
현재 Testify와 함께 Go에서 가장 널리 사용되고 있는 assertion 스타일 라이브러리입니다
Go standard와도 잘 동작합니다
특징
- goconvey tool을 이용해 웹 UI로 테스트 수행 및 결과 확인 가능
- convey를 이용해 각 test를 자연어 형식으로 describe 가능
- assertion 스타일
- 각 convey 별로 나뉘어 test scope가 명확하며, nested scope도 사용 가능
- test의 description을 포함한 상세 결과 표시
- 자연어를 이용한 설명식의 코드이므로 코드가 길어지며 지저분해질 수 있음
4. ginkgo
package calc_test
import (
. "github.com/bmuschko/go-testing-frameworks/calc"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func TestCalc(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Calculator Suite")
}
var _ = Describe("Calculator", func() {
Describe("Add numbers", func() {
Context("1 and 2", func() {
It("should be 3", func() {
Expect(Add(1, 2)).To(Equal(3))
})
})
})
Describe("Subtract numbers", func() {
Context("3 from 5", func() {
It("should be 2", func() {
Expect(Subtract(5, 3)).To(Equal(2))
})
})
})
Describe("Multiply numbers", func() {
Context("5 with 6", func() {
It("should be 30", func() {
Expect(Multiply(5, 6)).To(Equal(30))
})
})
})
Describe("Divide numbers", func() {
Context("10 by 2", func() {
It("should be 30", func() {
Expect(Divide(10, 2)).To(Equal(float64(5)))
})
})
})
})
가독성과 표현력이 굉장히 좋은 BDD 스타일의 testing 라이브러리입니다
주로 자매 프로젝트인 Gomega(assertion 라이브러리)와 함께 사용됩니다
Go standard와도 잘 동작합니다
특징
- 자연어와 유사한 스타일의 테스트 코드 작성 가능
- BDD 스타일로 구현하기에 적합
- GoConvey 만큼은 아니지만 자연어와 유사한 설명식의 코드이므로 코드가 길어지며 지저분해질 수 있음
- 테스트를 위한 기타 다양한 기능 제공
5. goblin
package calc_test
import (
"go-test-framework-example/calc"
"testing"
. "github.com/franela/goblin"
)
func TestCalculator(t *testing.T) {
g := Goblin(t)
g.Describe("Calculator", func() {
g.It("should add two numbers ", func() {
g.Assert(calc.Add(1, 2)).Equal(3)
})
g.It("should subtract two numbers", func() {
g.Assert(calc.Subtract(5, 3)).Equal(2)
})
g.It("should multiply two numbers", func() {
g.Assert(calc.Multiply(5, 6)).Equal(30)
})
g.It("should divide two numbers", func() {
g.Assert(calc.Divide(10, 2)).Equal(float64(5))
})
})
}
ginkgo와 유사한 스타일의 BDD 스타일 testing framework 입니다
간결한 API들을 사용하며 이를 이용해 간결하고도 표현력 있는 테스트 케이스들을 작성 가능합니다
Go standard와도 잘 동작합니다
특징
- 자연어를 사용하여 테스트 코드 작성 가능
- BDD 스타일로 구현하기에 적합
- ginkgo 처럼 gomega의 assertion을 주로 사용
- colorful 하고 세련된 스타일의 report
- 테스트를 위한 기타 다양한 기능 제공
간단하게 테스트 스타일을 살펴보는 게 목표였기 때문에 상세한 기능까지 정리하진 않았습니다
테스트 케이스를 작성하는 데에 표현력과 간결성은 보통 반비례하지만
이 두 가지를 적당히 만족시키는 스타일을 사용하는 것이 바람직하다고 생각합니다
아래와 같이 정리할 수 있겠네요
간결성 <<----------------------------------------------->>표현력
Testify ---------- Goblin --------- Ginkgo -------- Goconvey
테스트되는 프로젝트의 크기가 클수록
테스트 케이스의 표현력과 결과의 분석을 위한
report가 중요해진다고 생각합니다
하지만, 간결성이 떨어지면 코드의 생산성이
떨어질 수 있음을 함께 고려하여 선택해야 합니다
(대부분 테스트의 수준은 남은 기한에 따라 크게 좌지우지되기 때문입니다)
그러므로 아래와 같이 정리할 수 있겠습니다
- 간단한 App일수록 간결성 위주의 testing 방식이 효과적
- 복잡하고 큰 App일수록 표현력 위주의 testing 방식이 효과적
GoConvey를 사용해 왔기 때문에 프로젝트에는
Testify로 깔끔한 테스트 코드를 작성해보려고 합니다
위의 예제 코드들은 아래에서 확인할 수 있습니다
[Reference]
'Programming > Go' 카테고리의 다른 글
Go/Golang Dependency Injection (12) | 2021.09.10 |
---|---|
Go/Golang Scheduling (8) | 2021.09.02 |
Go/Golang Memory Management (16) | 2021.08.22 |
Go/Golang Protobuf Compile Guide (0) | 2021.08.16 |
Go/Golang Configuration (0) | 2021.08.16 |