본문으로 건너뛰기

테스트 더블과 의존성 주입

이 챕터에서 다루는 것

테스트하기 어려운 코드에는 공통점이 있다. 자기가 쓸 것을 자기가 만든다. time.Now()를 직접 부르고, sql.Open을 직접 하고, http.Get을 직접 한다. 그러면 테스트에서 그 동작을 바꿀 방법이 없다.

이 챕터는 그 결합을 인터페이스로 끊고, 테스트에서 가짜를 꽂는 방법을 다룬다. 7-8에서 만들어 두고 미뤄 둔 Store 인터페이스가 여기서 실제 fake가 된다.

:::note net/http는 파트 9에서 이 챕터는 net/http/httptest를 쓴다. HTTP 자체는 파트 9의 주제이고, 여기서는 "진짜 서버 없이 HTTP 코드를 테스트하는 도구"로만 쓴다. httptest.NewServerhttptest.NewRecorder 두 개면 충분하다. :::

문제 — 결합된 코드

이런 함수를 생각해 보자.

func Run(records []Record) (Report, error) {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
return Report{}, err
}
start := time.Now()
// ...
}

테스트에서 확인하고 싶은 것은 로직이다. 그런데 이 함수를 부르려면 PostgreSQL이 떠 있어야 하고, Report.Elapsed는 실행할 때마다 다르며, "저장이 실패했을 때"는 만들 방법이 없다.

해법은 필요한 것을 밖에서 받는 것이다. 이것이 의존성 주입(dependency injection)이고, Go에서는 프레임워크 없이 그냥 생성자 인자로 한다.

소비자 쪽 인터페이스

6-5에서 정한 규칙을 그대로 쓴다. 인터페이스는 쓰는 쪽에 선언한다. 그리고 필요한 메서드만 넣는다.

examples/08-testing/03-doubles/ingest/ingest.go
// Store는 ingest가 저장소에 요구하는 전부다.
// *sql.DB 같은 큰 타입을 통째로 받지 않는다.
type Store interface {
Save(ctx context.Context, r Record) error
}

메서드 하나다. *sql.DB를 받으면 fake를 쓰려고 백 개가 넘는 메서드를 구현해야 하지만, 이 인터페이스는 열 줄이면 만족시킬 수 있다. 인터페이스가 작을수록 테스트가 쉬워진다는 것이 여기서 확인된다.

전체 타입은 이렇다.

examples/08-testing/03-doubles/ingest/ingest.go
// Ingester의 의존성은 전부 필드다. 전역 변수도, 패키지 함수 직접 호출도 없다.
type Ingester struct {
store Store

// now는 시간을 읽는 유일한 통로다. 기본값은 time.Now이고
// 테스트는 WithClock으로 고정 시계를 꽂는다.
now func() time.Time
}

// Option은 생성자에 넘기는 선택 설정이다.
type Option func(*Ingester)

// WithClock은 시간 공급자를 바꾼다.
func WithClock(now func() time.Time) Option {
return func(i *Ingester) { i.now = now }
}

func New(store Store, opts ...Option) *Ingester {
i := &Ingester{store: store, now: time.Now}
for _, opt := range opts {
opt(i)
}
return i
}

필수 의존성은 위치 인자, 선택 설정은 옵션 함수. store 없이는 아무것도 못 하니 인자이고, 시계는 기본값이 있으니 옵션이다.

examples/08-testing/03-doubles/ingest/ingest.go
// Run은 레코드를 순서대로 저장한다.
// ID가 빈 레코드는 건너뛰고, 저장 실패는 즉시 반환한다.
func (i *Ingester) Run(ctx context.Context, records []Record) (Report, error) {
start := i.now()
rep := Report{StartedAt: start}

for _, r := range records {
if r.ID == "" {
rep.Skipped++
continue
}
if err := i.store.Save(ctx, r); err != nil {
rep.Elapsed = i.now().Sub(start)
return rep, fmt.Errorf("레코드 %q 저장: %w", r.ID, err)
}
rep.Saved++
rep.TotalBytes += r.Bytes
}

rep.Elapsed = i.now().Sub(start)
return rep, nil
}

time.Now()가 이 파일 어디에도 없다. 전부 i.now()를 지난다.

손으로 쓴 fake

이제 가짜를 쓴다. 30줄이 안 된다.

examples/08-testing/03-doubles/ingest/fake_test.go
// fakeStore는 손으로 쓴 테스트 더블이다. 30줄이 안 되고,
// 생성 라이브러리도 DSL도 필요 없다.
type fakeStore struct {
saved []ingest.Record

// failOn이 비어 있지 않으면 그 ID를 저장할 때 failWith를 반환한다.
failOn string
failWith error
}

func (f *fakeStore) Save(ctx context.Context, r ingest.Record) error {
if err := ctx.Err(); err != nil {
return err // 컨텍스트를 존중하는지도 가짜가 검사해 줄 수 있다
}
if f.failOn != "" && r.ID == f.failOn {
return f.failWith
}
f.saved = append(f.saved, r)
return nil
}

// ids는 저장된 레코드의 ID만 뽑는다. 검증을 읽기 쉽게 만드는 도우미다.
func (f *fakeStore) ids() []string {
out := make([]string, 0, len(f.saved))
for _, r := range f.saved {
out = append(out, r.ID)
}
return out
}

이 fake가 하는 일이 셋이다.

  1. 기록한다 (saved). 무엇이 저장됐는지 검증할 수 있다.
  2. 실패를 만든다 (failOn/failWith). 진짜 DB로는 재현 불가능한 상황이다.
  3. 계약을 검사한다 (ctx.Err()). 호출자가 컨텍스트를 존중하는지 가짜가 본다.

에러 경로 테스트가 그 값을 바로 보여 준다.

examples/08-testing/03-doubles/ingest/fake_test.go
// 에러 경로는 가짜가 있어야 테스트할 수 있다.
// 진짜 저장소로는 "b를 저장할 때만 실패" 상황을 만들 방법이 없다.
func TestRunStopsAtFirstStoreError(t *testing.T) {
boom := errors.New("디스크 가득 참")
store := &fakeStore{failOn: "b", failWith: boom}
ing := ingest.New(store)

rep, err := ing.Run(t.Context(), []ingest.Record{
{ID: "a"}, {ID: "b"}, {ID: "c"},
})
if !errors.Is(err, boom) {
t.Fatalf("err = %v, want %v로 감싼 에러", err, boom)
}
if rep.Saved != 1 {
t.Errorf("Saved = %d, want 1", rep.Saved)
}
if want := []string{"a"}; !slices.Equal(store.ids(), want) {
t.Errorf("저장된 ID = %v, want %v", store.ids(), want)
}
}

세 가지를 한 번에 확인했다. 에러가 감싸져 전파되는지(errors.Is), 실패 전까지의 결과가 보고서에 남는지, 그리고 실패 이후 c를 저장하지 않았는지. 마지막 것은 fake의 기록이 없으면 알 수 없다.

:::tip t.Context() t.Context()는 그 테스트가 끝날 때 자동으로 취소되는 컨텍스트를 준다. context.Background() 대신 이것을 쓰면, 테스트가 실패해 중단되어도 그 컨텍스트를 붙들고 있던 고루틴이 정리된다. 취소된 컨텍스트가 필요하면 여기서 파생시킨다.

examples/08-testing/03-doubles/ingest/fake_test.go
func TestRunRespectsCanceledContext(t *testing.T) {
store := &fakeStore{}
ing := ingest.New(store)

ctx, cancel := context.WithCancel(t.Context())
cancel()

_, err := ing.Run(ctx, []ingest.Record{{ID: "a"}})
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
if len(store.saved) != 0 {
t.Errorf("취소된 컨텍스트로 %d건이 저장됐다", len(store.saved))
}
}

:::

시간 주입

시간에 의존하는 테스트는 두 가지 방식으로 깨진다. 실제 경과 시간을 단언하면 느린 CI에서 실패하고, 현재 시각을 단언하면 자정에 실패한다.

시계를 주입하면 둘 다 사라진다.

examples/08-testing/03-doubles/ingest/fake_test.go
// fixedClock은 호출할 때마다 step만큼 흐르는 가짜 시계다.
// 실제 시간에 전혀 기대지 않으므로 Elapsed를 정확히 단언할 수 있다.
func fixedClock(start time.Time, step time.Duration) func() time.Time {
cur := start
return func() time.Time {
t := cur
cur = cur.Add(step)
return t
}
}

func TestRunSavesEveryRecord(t *testing.T) {
store := &fakeStore{}
start := time.Date(2026, 8, 11, 9, 0, 0, 0, time.UTC)
ing := ingest.New(store, ingest.WithClock(fixedClock(start, time.Second)))

rep, err := ing.Run(t.Context(), []ingest.Record{
{ID: "a", Bytes: 10},
{ID: "b", Bytes: 20},
})
if err != nil {
t.Fatalf("Run: 예상치 못한 에러 %v", err)
}

if want := []string{"a", "b"}; !slices.Equal(store.ids(), want) {
t.Errorf("저장된 ID = %v, want %v", store.ids(), want)
}
if rep.Saved != 2 || rep.TotalBytes != 30 {
t.Errorf("Report = %+v, want Saved=2 TotalBytes=30", rep)
}
if !rep.StartedAt.Equal(start) {
t.Errorf("StartedAt = %v, want %v", rep.StartedAt, start)
}
// 시계를 두 번 읽으므로(시작, 끝) 정확히 1초다. 실제 경과 시간이 아니다.
if rep.Elapsed != time.Second {
t.Errorf("Elapsed = %v, want 1s", rep.Elapsed)
}
}

rep.Elapsed != time.Second라는 정확한 등호가 가능해졌다. > 0 && < time.Minute 같은 애매한 범위 단언이 필요 없다.

:::info 시간 주입 vs testing/synctest func() time.Time 주입은 "지금 몇 시인가"만 가짜로 만든다. time.Sleep, time.After, context.WithTimeout처럼 실제로 기다리는 코드는 이 방법으로 빨라지지 않는다.

그쪽은 8-7testing/synctest가 답이다. 버블 안에서는 time 패키지 전체가 가상 시계를 쓴다. 시계 주입은 여전히 유용하다 — synctest 없이도 되고, 특정 시각(윤년, 자정, 타임존 경계)을 정확히 지정할 수 있다. :::

난수 주입

같은 논리가 난수에도 적용된다.

examples/08-testing/03-doubles/backoff/backoff.go
// Jitter는 무작위 수 공급자다. Float64는 [0, 1) 범위를 돌려준다.
// *rand.Rand가 이 인터페이스를 그대로 만족한다.
type Jitter interface {
Float64() float64
}

// Policy는 지수 백오프 + 지터를 계산한다.
type Policy struct {
Base time.Duration
Max time.Duration
Jitter Jitter
}

// New는 실행할 때마다 다른 시드를 쓰는 기본 정책을 만든다.
func New(base, max time.Duration) Policy {
return Policy{Base: base, Max: max, Jitter: rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))}
}

// Delay는 attempt번째 재시도(0부터)의 대기 시간을 돌려준다.
// 지수적으로 늘리되 Max에서 자르고, 그 값의 50%~100% 사이로 흩뿌린다.
func (p Policy) Delay(attempt int) time.Duration {
if attempt < 0 {
attempt = 0
}
d := p.Base << min(attempt, 32)
if d <= 0 || d > p.Max {
d = p.Max
}
factor := 0.5 + 0.5*p.Jitter.Float64()
return time.Duration(float64(d) * factor)
}

여기서 선택지가 둘이다.

경계값을 정확히 찍고 싶으면 가짜를 쓴다.

examples/08-testing/03-doubles/backoff/backoff_test.go
// constJitter는 언제나 같은 값을 돌려주는 가짜다.
// 이걸 꽂으면 Delay가 완전히 결정적인 함수가 된다.
type constJitter float64

func (c constJitter) Float64() float64 { return float64(c) }

func TestDelayGrowsAndCaps(t *testing.T) {
// Float64가 1.0이면 factor는 1.0 — 상한값이 그대로 나온다.
p := backoff.Policy{Base: 100 * time.Millisecond, Max: time.Second, Jitter: constJitter(1.0)}

tests := []struct {
attempt int
want time.Duration
}{
{0, 100 * time.Millisecond},
{1, 200 * time.Millisecond},
{2, 400 * time.Millisecond},
{3, 800 * time.Millisecond},
{4, time.Second}, // 1600ms가 Max에서 잘린다
{9, time.Second},
}

for _, tt := range tests {
if got := p.Delay(tt.attempt); got != tt.want {
t.Errorf("Delay(%d) = %v, want %v", tt.attempt, got, tt.want)
}
}
}

분포를 보고 싶으면 고정 시드를 쓴다.

examples/08-testing/03-doubles/backoff/backoff_test.go
// 고정 시드를 준 진짜 rand.Rand도 결정적이다.
// 가짜를 쓸지 고정 시드를 쓸지는 "무엇을 검증하려는가"로 정한다.
// 경계값을 정확히 찍고 싶으면 가짜, 분포를 보고 싶으면 고정 시드다.
func TestDelayStaysInBand(t *testing.T) {
p := backoff.Policy{
Base: 100 * time.Millisecond,
Max: time.Second,
Jitter: rand.New(rand.NewPCG(1, 2)),
}

for attempt := range 6 {
lo := p.Base << min(attempt, 32)
if lo > p.Max {
lo = p.Max
}
for range 100 {
d := p.Delay(attempt)
if d < lo/2 || d > lo {
t.Fatalf("Delay(%d) = %v, 기대 범위 [%v, %v]", attempt, d, lo/2, lo)
}
}
}
}

math/rand/v2rand.New(rand.NewPCG(seed1, seed2))는 시드가 같으면 항상 같은 수열을 낸다. 반면 패키지 수준 함수 rand.Float64()는 시드를 지정할 수 없다 — v2에서는 매 실행 무작위로 시드된다. 테스트에서 쓰지 않는다.

httptest — HTTP 코드 테스트

net/http/httptest는 도구가 둘이다. 핸들러를 테스트할 때는 ResponseRecorder, 클라이언트를 테스트할 때는 Server.

대상은 이렇다.

examples/08-testing/03-doubles/probe/probe.go
// Handler는 bodies에 등록된 경로에 그 본문을 돌려주고,
// 없는 경로에는 404를 돌려준다.
func Handler(bodies map[string]string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, ok := bodies[r.URL.Path]
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if _, err := io.WriteString(w, body); err != nil {
// 클라이언트가 끊긴 경우다. 여기서 할 수 있는 일은 없다.
return
}
})
}

ResponseRecorder — 서버 없이

examples/08-testing/03-doubles/probe/probe_test.go
// ResponseRecorder는 서버도 소켓도 띄우지 않는다.
// 핸들러를 그냥 함수로 호출하고 무엇을 썼는지 들여다본다.
func TestHandlerWithRecorder(t *testing.T) {
h := probe.Handler(map[string]string{"/a": "hello"})

tests := []struct {
name string
path string
wantStatus int
wantBody string
}{
{"등록된 경로", "/a", http.StatusOK, "hello"},
{"없는 경로", "/nope", http.StatusNotFound, "not found\n"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
rec := httptest.NewRecorder()

h.ServeHTTP(rec, req)

res := rec.Result()
defer res.Body.Close()

if res.StatusCode != tt.wantStatus {
t.Errorf("상태 코드 = %d, want %d", res.StatusCode, tt.wantStatus)
}
if got := rec.Body.String(); got != tt.wantBody {
t.Errorf("본문 = %q, want %q", got, tt.wantBody)
}
})
}
}

핵심은 h.ServeHTTP(rec, req)가 그냥 함수 호출이라는 것이다. 포트도, TCP 연결도, 대기도 없다. http.Handler가 인터페이스인 덕이다. http.Error가 붙이는 개행("not found\n")까지 그대로 관찰된다.

httptest.Server — 클라이언트 쪽

examples/08-testing/03-doubles/probe/probe_test.go
// httptest.Server는 루프백 포트에 진짜 서버를 띄운다.
// 클라이언트 쪽 코드(요청 조립, 상태 코드 처리, 본문 소진)를 검증할 때 쓴다.
func TestClientAgainstTestServer(t *testing.T) {
srv := httptest.NewServer(probe.Handler(map[string]string{
"/small": "1234567890",
"/empty": "",
}))
t.Cleanup(srv.Close) // 닫지 않으면 고루틴과 포트가 남는다

c := probe.NewClient(srv.URL, srv.Client())

t.Run("본문 크기", func(t *testing.T) {
n, err := c.Size(t.Context(), "/small")
if err != nil {
t.Fatalf("Size: %v", err)
}
if n != 10 {
t.Errorf("크기 = %d, want 10", n)
}
})

t.Run("빈 본문", func(t *testing.T) {
n, err := c.Size(t.Context(), "/empty")
if err != nil {
t.Fatalf("Size: %v", err)
}
if n != 0 {
t.Errorf("크기 = %d, want 0", n)
}
})

t.Run("404", func(t *testing.T) {
_, err := c.Size(t.Context(), "/nope")
if err == nil {
t.Fatal("에러를 기대했는데 nil이 나왔다")
}
if !strings.Contains(err.Error(), "404") {
t.Errorf("err = %v, want 404를 언급하는 에러", err)
}
})
}

세 가지 관용구가 있다.

  • t.Cleanup(srv.Close)8-2의 픽스처 패턴이다. 닫지 않으면 서버 고루틴과 포트가 테스트 프로세스가 끝날 때까지 남는다.
  • srv.URL — 실제 할당된 포트가 들어 있다. 포트를 하드코딩하지 않는다.
  • srv.Client() — 그 서버에 맞게 설정된 클라이언트다. TLS 테스트 (httptest.NewTLSServer)에서는 인증서까지 맞춰 준다.

RoundTripper 스텁 — 가장 가벼운 방법

네트워크 자체가 필요 없다면 http.Client.Transport를 갈아 끼운다.

examples/08-testing/03-doubles/probe/probe_test.go
// roundTripFunc은 Transport 자리에 끼우는 가짜다.
// 소켓도 포트도 쓰지 않으므로 httptest.Server보다 훨씬 가볍고,
// "네트워크가 끊겼다" 같은 상황을 정확히 만들 수 있다.
type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

func TestClientTransportError(t *testing.T) {
boom := errors.New("연결 거부됨")
hc := &http.Client{
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, boom
}),
}

c := probe.NewClient("http://example.invalid", hc)

_, err := c.Size(context.Background(), "/x")
if !errors.Is(err, boom) {
t.Fatalf("err = %v, want %v로 감싼 에러", err, boom)
}
}

함수 타입에 메서드를 붙여 인터페이스를 만족시키는 것은 Go의 표준 관용구다. http.HandlerFunc가 표준 라이브러리 안의 같은 패턴이다.

도구무게쓸 자리
ResponseRecorder가장 가볍다핸들러 로직
RoundTripper 스텁가볍다클라이언트 로직, 네트워크 에러 재현
httptest.Server실제 소켓요청 조립·헤더·리다이렉트·커넥션 재사용까지

용어 정리 — fake, stub, mock, spy

이름
fake동작하는 간이 구현. 인메모리 DB 같은 것
stub정해진 답만 돌려준다. constJitter가 그렇다
spy호출을 기록한다. fakeStore.saved가 그 역할
mock호출 순서와 횟수까지 미리 지정하고 검증한다

Go 커뮤니티는 이 구분을 엄밀히 지키지 않고 대개 다 "fake"나 "mock"이라 부른다. 중요한 것은 이름이 아니라 무엇을 검증하느냐다.

mock 스타일(호출 검증)은 신중하게 쓴다. "Save가 정확히 3번 불렸다"를 단언하면 구현을 바꿀 때마다 테스트가 깨진다. 리팩터링을 막는 테스트는 자산이 아니라 부채다. 위 예제들이 전부 결과(store.ids(), rep.Saved)를 검증하고 호출 방식을 검증하지 않는 이유다.

testifymockgen을 언제 도입하나

손으로 쓰는 fake의 한계는 인터페이스 크기다. 메서드가 15개인 인터페이스를 손으로 구현하는 것은 낭비이고, 메서드가 추가될 때마다 컴파일이 깨진다.

그럴 때 go.uber.org/mock(mockgen)이 답이다. go:generate 한 줄로 구현을 생성한다.

//go:generate go tool mockgen -source=store.go -destination=mock_store_test.go -package=ingest_test

다만 순서가 있다.

  1. 먼저 인터페이스를 줄여 본다. 메서드가 15개라면 그것부터가 설계 문제다. Store 하나 대신 Saver, Loader를 나누면 각각 손으로 쓸 수 있다.
  2. 정말 큰 서드파티 인터페이스를 감싸야 할 때 생성기를 쓴다.
  3. mockgen이 만든 mock은 호출 검증 스타일을 유도한다. 위에서 말한 함정을 기억한다.

testify8-2에서 다룬 이유로 기본값이 아니다. testify/mock도 마찬가지다. 필요한 것이 깊은 비교라면 google/go-cmp가, mock 생성이라면 go.uber.org/mock이 각각 더 좁고 나은 선택이다.

흔히 하는 실수

1. 구체 타입을 받는다

func New(db *sql.DB) *Ingester // 테스트에서 진짜 DB가 필요해진다
func New(store Store) *Ingester // 이렇게

2. 인터페이스를 제공하는 쪽에 선언한다

// storage 패키지가 이걸 선언하면 ingest는 storage를 import해야 한다
package storage
type Store interface { Save(...) error; Load(...) error; Delete(...) error; /* ... */ }

쓰는 쪽에 선언하면 필요한 메서드만 넣게 되고, import 방향이 뒤집히지 않는다.

3. 인터페이스를 미리 만든다

구현이 하나뿐인데 "나중을 위해" 인터페이스를 만드는 것은 과설계다. 두 번째 구현(테스트용 fake 포함)이 필요해진 시점이 인터페이스를 만들 때다.

4. fake가 진짜보다 관대하다

fake가 ctx를 무시하거나, 중복 키를 허용하거나, 빈 값을 통과시키면 테스트가 초록색이어도 프로덕션에서 깨진다. fake는 진짜와 같은 제약을 걸어야 한다.fakeStorectx.Err()부터 확인하는 이유다.

5. httptest.Server를 닫지 않는다

고루틴과 포트가 남는다. t.Cleanup(srv.Close) 또는 defer srv.Close(). 8-1의 누수 검사기가 이것을 잡아 준다.

6. 시계 주입을 해 놓고 time.Now()를 어딘가에서 부른다

주입 지점이 하나 남으면 그 코드 경로만 다시 플래키해진다. 패키지 전체에서 time.Now를 grep해 보는 것이 확인 방법이다.

정리

  • 테스트하기 어려운 코드는 자기가 쓸 것을 자기가 만드는 코드다. 필요한 것을 생성자로 받는다.
  • 인터페이스는 쓰는 쪽에 선언하고, 필요한 메서드만 넣는다. 작을수록 fake가 쉽다.
  • 필수 의존성은 위치 인자, 선택 설정은 옵션 함수.
  • 손으로 쓴 fake가 기본값이다. 기록·실패 주입·계약 검사 셋을 30줄로 얻는다.
  • 시간과 난수는 주입한다. 그러면 범위 단언이 정확한 등호가 된다. 실제로 기다리는 코드는 8-7synctest가 답이다.
  • httptest: 핸들러는 ResponseRecorder, 클라이언트는 Server, 네트워크 에러는 RoundTripper 스텁.
  • 호출 횟수가 아니라 결과를 검증한다. mock 스타일은 리팩터링을 막는다.
  • mockgen은 인터페이스를 줄이려는 시도가 실패한 다음에 꺼낸다.

연습문제

  1. Ingester에 "같은 ID가 두 번 오면 두 번째는 건너뛴다"는 규칙을 추가해 보자. 먼저 fakeStore를 쓰는 실패하는 테스트를 쓰고 구현한다. 그다음 fakeStore가 같은 ID를 두 번 받으면 에러를 반환하도록 바꿔 보자 — 그렇게 하면 이 규칙을 깨는 회귀를 어느 테스트든 자동으로 잡게 되는가?

  2. probe.Client에 재시도를 붙이고, backoff.Policy로 간격을 정하게 해 보자. roundTripFunc 스텁이 처음 두 번은 에러를 내고 세 번째에 성공하도록 만들면 재시도 횟수를 정확히 검증할 수 있다. 이때 대기 시간까지 검증하려면 무엇이 더 필요한가? (8-7을 보고 오자.)

  3. WithClock 대신 Clock 인터페이스(Now() time.Time)를 만들어 같은 코드를 다시 써 보자. 함수 타입과 인터페이스 중 어느 쪽이 이 경우에 더 나은가? 메서드가 Now 하나에서 NowSince 둘로 늘어나면 답이 바뀌는가?