- 출처: https://quii.gitbook.io/learn-go-with-tests/go-fundamentals/mocking
- 개요
mocking에 대해 알아보고 mocking를 사용할 때 장점에 대해 알아본다.
- Countdown 프로그램
3부터 하나씩 줄어드는 숫자를 출력하는 프로그램을 만들어보자. 단 각 숫자를 출력하기전에 1초를 멈추고 나서 출력한다. 0에 도달할 때는 0대신 Go!를 출력한다.
3
2
1
Go!
이런 기능을 하는 Countdown() 이라는 함수를 main()에서 호출하면 아래와 같이 작성할 수 있다.
package main
func main() {
Countdown()
}
사실 프로그램이 맞나 싶을 정도로 보잘 것 없는 요구사항이지만 언제나 작은 단계의 성공적인 소프트웨어를 구축한 후, 이를 점차 반복적인 테스트 접근법을 통해 구축해야 한다. 이렇게 하려면 요구사항을 가능한 잘게 쪼갠 후 동작하는 소프트웨어를 가질 수 있는 스킬이 중요하다.
요구사항을 아래와 같이 쪼개보자.
- 3을 출력한다.
- 3,2,1,Go!를 출력한다.
- 각 라인사이에 멈추는 동작을 추가한다.
요구사항을 만족하려면 표준 출력으로 출력해야 한다. 이전의 DI에서는 interface를 이용하여 test 코드와 실제 app코드에서 동작할 수 있게 기능을 구현하는법을 배웠다.
func TestCountdown(t *testing.T) {
buffer := &bytes.Buffer{}
Countdown(buffer)
got := buffer.String()
want := "3"
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
인터페이스의 개념에 대해 알고 있다면 위의 test 코드의 Countdown 함수가 데이터를 어딘가에 넣을것이라는것, 그리고 io.Writer가 데이터를 보관할 수 있는 방법이라는것도 알고 있을것이다.
func Countdown(out *bytes.Buffer) {}
위와 같이 코드를 작성하고 test를 수행하면 정상적으로 실패할것이다. 이제 test가 통과하도록 함수내부를 구현해주자.
func Countdown(out *bytes.Buffer) {
fmt.Fprint(out, "3")
}
fmt.Fprint 함수는 io.Writer 인터페이스 형(*bytes.Buffer)을 받아 문자열을 io.Writer에 넣어준다. 이제 test를 통과할것이다. DI section 에서도 언급했듯이 기술적으로는 아무 문제가 없지만, 좀 더 일반적인 목적으로 함수를 사용하기 위해 io.Writer형으로 인자를 변경해준다.
func Countdown(out io.Writer) {
fmt.Fprint(out, "3")
}
test를 통과했으므로 main함수도 변경해주어야 한다. 실제 소프트웨어의 요구사항은 표준 출력이므로 io.Writer의 구현체로 os.Stdout을 인자로 넘겨주도록 한다.
package main
import (
"fmt"
"io"
"os"
)
func Countdown(out io.Writer) {
fmt.Fprint(out, "3")
}
func main() {
Countdown(os.Stdout)
}
여기서 살펴봐야 하는 것은 구현 방법이 아니라 사용한 접근방법이다. 요구사항을 잘게 쪼개서 작은 단위를 우선 구현하고 테스트를 통해 반복적으로 구현하면서 결국 전체 요구사항을 만족할 수 있게 된다. 이제 2,1,Go!를 출력하는 기능을 구현해보자.
func TestCountdown(t *testing.T) {
buffer := &bytes.Buffer{}
Countdown(buffer)
got := buffer.String()
want := `3
2
1
Go!`
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
Go에서는 문자열을 만들 때 `(backtick) 문법을 이용해서 만들 수도 있는데 줄바꿈 까지 고려할 수 있으므로 우리가 정의한 요구사항에서 사용하기 좋다.
func Countdown(out io.Writer) {
for i := 3; i > 0; i-- {
fmt.Fprintln(out, i)
}
fmt.Fprint(out, "Go!")
}
출력을 out에다가 해주기 위해 Fprint 함수를 사용하였다. Fprintln은 끝에 줄바꿈 문자를 추가해준다.
const finalWord = "Go!"
const countdownStart = 3
func Countdown(out io.Writer) {
for i := countdownStart; i > 0; i-- {
fmt.Fprintln(out, i)
}
fmt.Fprint(out, finalWord)
}
아주 크게 리팩토링할 사항은 별로 없고, 위와 같이 magic number에 대한 리팩토링만 진행해주면 될 것 같다. 위의 코드를 실행하면 우리가 원하던 결과가 나오긴하지만 1초간 멈춤이 되진 않는다. Go에서는 time.Sleep을 사용하면 된다.
func Countdown(out io.Writer) {
for i := countdownStart; i > 0; i-- {
time.Sleep(1 * time.Second)
fmt.Fprintln(out, i)
}
time.Sleep(1 * time.Second)
fmt.Fprint(out, finalWord)
}
드디어 우리가 원했던 요구사항을 모두 만족하면서 동작할 수 있게 되었다.
- Mocking
test도 통과하고 소프트웨어도 의도한대로 동작하므로 별 문제는 없어보이지만 고려해야할 점이 있다.
일단 test에 4초나 수행된다는 점은 문제가 있다. 느린 test는 생산성을 망친다. 지금은 한번만 test를 수행하면 되지만 요구사항이 복잡해져서 4초가 걸리는 test를 여러 번 실행해야 한다고 생각해보면 문제가 심각하다는것을 느낄수가 있을 것이다.
test에 문제가 되는 sleep 부분을 추출해서 이를 test 에서 제어할 수 있도록 변경하자. time.Sleep에 대한 mocking을 할 수 있다면 실제 time.Sleep 대신 의존성 주입을 할 수 있게 되고, 호출시 이를 지켜보면서(spy) 검사(assertion)를 할 수 있게 된다.
sleep에 대한 의존성을 위해 인터페이스를 하나 정의하도록 하자. 인터페이스를 사용하면 main 에서는 실제 Sleeper를 사용하고, test 에서는 spy sleeper를 사용할 수 있게 된다. 인터페이스를 인자로 받으면 Countdown 함수는 이를 인지하지 못하지만 호출자는 자신이 원하는 세부 구현을 Countdown에게 유연하게 전달할 수 있게 된다.
type Sleeper interface {
Sleep()
}
위에서 어려운 용어가 많아 헷갈릴 수 있지만 결국 지금 하려고 하는 일은 Countdown을 구현했을 때 time.Sleep을 이용하여 각 라인의 출력마다 멈추어야 하는 책임을 갖고 있었던것을 인터페이스를 통해서 Countdown이 멈추어야 할 책임에서 벗어나도록 해주고 있는 것이다.
type SpySleeper struct {
Calls int
}
func (s *SpySleeper) Sleep() {
s.Calls++
}
위와 같이 test에서 사용할 mock을 만들어 줄 필요가 있다. Spy는 mock의 종류로서 의존성이 어떻게 사용되고 있는지를 기록(record)하는 행위를 한다. Spy는 일반적으로 보낸 인자들을 기록하는데, 얼마나 많이 호출 되었는지등을 기록한다. 우리 코드에서 Spy를 사용하면 Sleep이 얼마나 호출 되었는지를 기록할 수 있게 된다.
이제 test에서 우리의 Spy를 의존성 주입할 수 있도록 바꿔주자. 그리고 sleep이 4번 호출 되었다는것을 단언(assert)해줌으로써 정상동작 여부를 테스트하자.
func TestCountdown(t *testing.T) {
buffer := &bytes.Buffer{}
spySleeper := &SpySleeper{}
Countdown(buffer, spySleeper)
got := buffer.String()
want := `3
2
1
Go!`
if got != want {
t.Errorf("got %q want %q", got, want)
}
if spySleeper.Calls != 4 {
t.Errorf("not enough calls to sleeper, want 4 got %d", spySleeper.Calls)
}
}
아직 Countdown 함수는 수정하지 않았기 때문에 동작은 하지 않을것이다. 해당 함수를 수정해주자.
func Countdown(out io.Writer, sleeper Sleeper) {
for i := countdownStart; i > 0; i-- {
time.Sleep(1 * time.Second)
fmt.Fprintln(out, i)
}
time.Sleep(1 * time.Second)
fmt.Fprint(out, finalWord)
}
또 main도 작성했었기 때문에 같이 변경해준다.
type DefaultSleeper struct {}
func (d *DefaultSleeper) Sleep() {
time.Sleep(1 * time.Second)
}
.............
func main() {
sleeper := &DefaultSleeper{}
Countdown(os.Stdout, sleeper)
}
이제 Compile은 성공했지만 test를 수행하면 실패할것이다. 아직 주입된 의존성을 호출하지 않고 time.Sleep함수를 사용하고 있기 때문이다. 의존성을 호출하도록 코드를 변경해주자.
func Countdown(out io.Writer, sleeper Sleeper) {
for i := countdownStart; i > 0; i-- {
sleeper.Sleep()
fmt.Fprintln(out, i)
}
sleeper.Sleep()
fmt.Fprint(out, finalWord)
}
이제 4초를 기다리지 않아도 test를 통과할 수 있게 되었다.
- Breaking test
멋있게 구현한 것 같지만 약간의 문제가 있다. 현재 코드는 순서에 상관없이 단순하게 Sleep을 몇번 호출했는지만 검사하고 있다.
func Countdown(out io.Writer, sleeper Sleeper) {
for i := countdownStart; i > 0; i-- {
sleeper.Sleep()
}
for i := countdownStart; i > 0; i-- {
fmt.Fprintln(out, i)
}
sleeper.Sleep()
fmt.Fprint(out, finalWord)
}
위의 코드처럼 sleep과 숫자의 호출 순서는 관계없이 sleep의 횟수만 맞게 호출되면 test가 통과할 수 있게 코드를 작성한것이다. 동작의 순서를 기록하기 위해 Spy를 다시 작성해야한다.
type SpyCountdownOperations struct {
Calls []string
}
func (s *SpyCountdownOperations) Sleep() {
s.Calls = append(s.Calls, sleep)
}
func (s *SpyCountdownOperations) Write(p []byte) (n int, err error) {
s.Calls = append(s.Calls, write)
return
}
const write = "write"
const sleep = "sleep"
SpyCountdownOperations 는 io.Writer와 Sleeper 인터페이스를 둘 다 구현해서 모든 사항들을 하나의 slice인 Calls에 기록하고 있다. 이번 test에서는 작업 순서에만 관심이 있으므로 동작들을 기록하는것만으로도 충분하다.
기존에 sleep에 관한 테스트와 더불어 기대한 순서대로 호출하는지를 검사할 test도 추가해준다.
t.Run("sleep before every print", func(t *testing.T) {
spySleepPrinter := &SpyCountdownOperations{}
Countdown(spySleepPrinter, spySleepPrinter)
want := []string{
sleep,
write,
sleep,
write,
sleep,
write,
sleep,
write,
}
if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
t.Errorf("wanted calls %v got %v", want, spySleepPrinter.Calls)
}
})
이제 test가 실패할것이다. 이제는 Sleeper를 감시하는 2가지 테스트를 갖고 있으므로 테스트코드를 리팩토링 해줄 수 있다. 하나는 무엇이 출력 되는지를 나머지, 하나는 출력된 사이에 sleeping 이 수행 되었는지를 테스트할 수 있게 되었다. 그리고 처음에 만든 spy(SpySleeper)는 더이상 사용하지 않으므로 삭제해준다.
func TestCountdown(t *testing.T) {
t.Run("prints 3 to Go!", func(t *testing.T) {
buffer := &bytes.Buffer{}
Countdown(buffer, &SpyCountdownOperations{})
got := buffer.String()
want := `3
2
1
Go!`
if got != want {
t.Errorf("got %q want %q", got, want)
}
})
t.Run("sleep before every print", func(t *testing.T) {
spySleepPrinter := &SpyCountdownOperations{}
Countdown(spySleepPrinter, spySleepPrinter)
want := []string{
sleep,
write,
sleep,
write,
sleep,
write,
sleep,
write,
}
if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
t.Errorf("wanted calls %v got %v", want, spySleepPrinter.Calls)
}
})
}
이제는 함수에 대해 중요한 2가지 속성에 대해 테스트할 수 있는 코드가 되었다.
- Sleeper에 대한 환경변수 구성
Sleeper에 대해서 환경 설정을 할 수 있도록 구성해보자. 기존에는 sleep 시간이 1초로 고정되어있었지만 이를 설정에 따라 변경할 수 있도록 한다. 설정과 test를 위해 새로운 ConfigurableSleeper struct를 만들어보자.
type ConfigurableSleeper struct {
duration time.Duration
sleep func(time.Duration)
}
위의 구조체는 얼만큼 멈출것인지에 대한 duration과 sleep 함수를 전달하기 위한 방법으로 sleep 멤버를 갖고 있다. sleep의 시그니처는 time.Sleep과 동일한데, 이렇게 하면 실제 구현에서는 time.Sleep을 사용할 수 있고, test에서는 아래와 같은 형태의 Spy를 사용할 수 있게된다.
type SpyTime struct {
durationSlept time.Duration
}
func (s *SpyTime) Sleep(duration time.Duration) {
s.durationSlept = duration
}
Spy가 있으므로 설정가능한 Sleeper를 위한 새로운 test를 작성할 수 있다.
func TestConfigurableSleeper(t *testing.T) {
sleepTime := 5 * time.Second
spyTime := &SpyTime{}
sleeper := ConfigurableSleeper{sleepTime, spyTime.Sleep}
sleeper.Sleep()
if spyTime.durationSlept != sleepTime {
t.Errorf("should have slept for %v but slept for %v", sleepTime, spyTime.durationSlept)
}
}
위의 코드를 실행하면 ConfigurableSleeper에 대해서 Sleep 메소드가 없다고 오류가 발생할것이다. Compile은 되면서 test가 제대로 실패하는지 확인하도록 아래처럼 코드를 작성해주자.
func (c *ConfigurableSleeper) Sleep() {
}
test의 실패를 확인했으면 다시 성공하도록 내부를 구현한다.
func (c *ConfigurableSleeper) Sleep() {
c.sleep(c.duration)
}
코드를 위와 같이 작성하면 test를 전부 통과한다. test 통과를 확인했으므로 main 함수도 수정해주도록 하자.
func main() {
sleeper := &ConfigurableSleeper{1 * time.Second, time.Sleep}
Countdown(os.Stdout, sleeper)
}
test와 main을 모두 ConfigurableSleeper로 대체했으므로 더이상 사용하지 않는 DefaultSleeper를 삭제해주도록 하자.
- mocking 에 대한 오해
mocking은 악마라는 말을 들어본적이 있을것이다. 모든 도구가 그렇듯이 잘못사용하면 이런 오해를 불러올 수 있다. 테스트에 주의를 기울이지 않거나 리팩토링 단계를 간과하면 mocking 으로 얻는 실익보다 불이익이 클 수 있다.
만약 mocking으로 인해 코드가 복잡해지거나 test에서 mocking할 부분이 너무 많다면 다음 사항에 해당되지 않는지 생각해봐야 한다.
우선 너무 많은 것을 테스트하고 있지 않은지 생각해보아야 한다. 만약 너무 많은것을 테스트하고 있다면 모듈을 따로 분리해야 한다. 이와 반대로 너무 모듈이 자잘한 경우 통합할 수 있는지를 고려해보아야 한다.
만약 test 코드가 너무 세부사항을 테스트하고 있다면 해당 코드가 무엇을 test 하고 있는지 주의깊게 볼 필요가 있다. 이런 경우 어떤 행위를 test 하고 있는것이 아니라 세부사항을 test 하고 있는 경우가 종종있다.
일반적으로 mocking 지점이 많다는것은 추상화를 잘하지 못했다는 사인이다.
만약 어떤 코드를 리팩토링할 때 이를 위해 많은 test를 변경해야 한다면 이는 대게 너무 세부사항을 test하고 있을 가능성이 크다. 물론 어떤 것을 test 해야 하는지 판단하는게 쉽지 않을때도 있다. 이럴 때에는 아래와 같은 사항들을 생각해보자.
기본적으로 리팩토링은 행위의 변화 없이 코드를 변경하는것이기 때문에 test를 변경하지 않아야 한다. 이 코드를 변경할 때 test들을 많이 변경해야 하는지를 생각해보자.
Go에서는 기술적으로 private function을 test 할 수 있지만 public 행위를 test 하는것이 좋다. 어떻게 보면 private function은 덜 안정적이기 때문에 test가 private function에 의존하는것은 좋지 않다.
만약 test가 3개 보다 많은 mocking 지점을 갖고 있다면 설계에 대해서 다시 생각해보는 것이 좋다.
Spy를 사용할때에는 주의 해야 한다. Spy가 알고리즘 내부를 들여다볼 수 있다는점은 매우 유용하지만, 바꿔서 생각해보면 test 코드와 구현 세부사항간의 결합도가 매우 높다는 의미이기도 하다.
Mocking은 마법이 아니며 비교적 간단해야 한다. framework를 사용하면 mocking을 더 복잡하게 만들 가능성이 있다. 이번 예제에서 automocking을 사용하지 않았는데 이로서 mock에 대해 더 깊은 이해를 하였으며 인터페이스 구현에 대해 연습했다.
다른 프로젝트와 협동할때에는 auto-generating mock을 사용할 가치가 있다. 팀 프로젝트에서는 mock 생성 툴이 test double에 대한 코드에대해 일관성을 보장해줄 수 있기 때문이다. 다만 인터페이스에 대한 test double을 생성할때에만 mock 생성 도구를 사용해야 한다. 만약 어떻게 test가 작성되는지를 너무 세세하게 지시하는 툴을 사용하면 나중에 감당할 수 없게 될 수 있다.
'Language > Go' 카테고리의 다른 글
Go - select (0) | 2022.01.12 |
---|---|
Go - Concurrency (0) | 2022.01.04 |
Go - dependency injection (0) | 2021.12.31 |
Go - maps (0) | 2021.12.31 |
Go - pointers & errors (0) | 2021.12.26 |
댓글