Go: Mockc로 인터페이스 모킹하기

Introduction

이미 Go 생태계에는 꽤 많은 모킹 라이브러리가 있습니다. 하지만 그중 대부분이 쉽게 배우고 사용하기 힘듭니다. 저는 그 이유가 테스트 코드에서 라이브러리에서 제공하는 특정 함수의 사용을 강제하기 때문이라 생각합니다. 이러한 함수들은 강력한 기능을 갖고있을 지라도, 보통은 비직관적이고 타입 세이프하지 않은 인터페이스를 제공하는 경우가 많습니다.

그래서 직관적이고 아주 간단한 방법으로 모킹하고 테스트 할 수 있도록 도와주는, 'Mockc' 라는 라이브러리를 만들게 되었습니다.

그럼 살펴보도록 하겠습니다!

Overview

Mockc는 목을 생성하기 위한 두 가지 방법을 제공합니다. 이 글에서는 Mock Generator를 사용한 방법을 살펴보도록 하겠습니다.

일단, 커맨드라인 툴인 mockc를 먼저 설치해보겠습니다.

go get github.com/KimMachineGun/mockc/cmd/mockc

위 커맨드를 실행하셨다면, $GOPATH/bin 하위에 mockc가 설치됐을 것입니다.

아래는 이 글에서 Mockc를 통해 모킹할 인터페이스와 그의 간단한 구현체입니다.

// cache.go
package main

type Cache interface {
	Get(key string) (val interface{}, err error)
	Set(key string, val interface{}) (err error)
	Del(key string) (err error)
}

type MapCache struct {
	m map[string]interface{}
}

func (c MapCache) Get(key string) (interface{}, error) {
	if c.m != nil {
		c.m = map[string]interface{}{}
	}
	return c.m[key], nil
}

func (c MapCache) Set(key string, val interface{}) error {
	if c.m != nil {
		c.m = map[string]interface{}{}
	}
	c.m[key] = val
	return nil
}

func (c MapCache) Del(key string) error {
	if c.m != nil {
		c.m = map[string]interface{}{}
	}
	delete(c.m, key)
	return nil
}

func HasKey(c Cache, key string) (bool, error) {
	val, err := c.Get(key)
	if err != nil {
		return false, err
	}

	return val != nil, nil
}

목을 생성하기에 앞서 Mock Generator가 무엇인지 먼저 알아보겠습니다.

Mock Generator: Go 문법으로 쓰여진 목 생성 옵션을 명시하기 위한 코드로, 이를 통해 목의 이름, 새성될 파일 이름, 생성자 생성 옵션 등을 수정할 수 있습니다.

그럼 Mock Generator를 실제로 작성해보도록 하겠습니다.

// mockc.go
//+build mockc

package main

import (
	"github.com/KimMachineGun/mockc"
)

func MockcCache() {
	mockc.Implement(Cache(nil))
	mockc.WithConstructor()
}

Note: Mock Generaotr를 일반적인 컴파일에서 제외하기 위해, 파일의 최상단에 //+build mockc 빌드 태그를 넣어줘야 합니다.

func MockcCache()가 바로 Mock Genrator입니다. Mock Generator는 Go의 함수 형태로 작성해야 하며, 함수 내부에서 mockc.Implement를 모킹하고자 하는 인터페이스와 함께 호출해야 합니다. 그리고 Mock Generator 함수의 이름(MockcCache)이 추후 생성될 목의 이름이 됩니다.

이 예시에서는 MockCachemockc.WithConstructor를 함께 호출했습니다. mockc.WithConstructor는 목의 생성 옵션을 설정하는 함수로, 목이 생성될 때 목 생성자 함수도 함께 생성되도록 합니다.

mockc.WithConstructor와 같은 목 생성 옵션 설정 함수는 필요에 따라 선택적으로 사용할 수 있으며, 여기에서 모든 설정 함수를 확인할 수 있습니다.

위 예시와 같이 Mock Generator를 작성했다면, 전에 설치한 커맨드라인 툴을 사용하여 바로 목을 생성해보겠습니다.

mockc

위 커맨드가 정상적으로 실행됐다면, 아래와 같은 mockc_gen.go 파일이 생성됐을 것입니다.

// mockc_gen.go
// Code generated by Mockc. DO NOT EDIT.
// repo: https://github.com/KimMachineGun/mockc

//go:generate mockc
// +build !mockc

package main

import (
	"sync"
)

type MockcCache struct {
	// method: Del
	_Del struct {
		mu sync.Mutex
		// basics
		Called    bool
		CallCount int
		// call history
		History []struct {
			Params struct {
				P0 string
			}
			Results struct {
				R0 error
			}
		}
		// params
		Params struct {
			P0 string
		}
		// results
		Results struct {
			R0 error
		}
		// if it is not nil, it'll be called in the middle of the method.
		Body func(string) error
	}
	// method: Get
	_Get struct {
		mu sync.Mutex
		// basics
		Called    bool
		CallCount int
		// call history
		History []struct {
			Params struct {
				P0 string
			}
			Results struct {
				R0 interface{}
				R1 error
			}
		}
		// params
		Params struct {
			P0 string
		}
		// results
		Results struct {
			R0 interface{}
			R1 error
		}
		// if it is not nil, it'll be called in the middle of the method.
		Body func(string) (interface{}, error)
	}
	// method: Set
	_Set struct {
		mu sync.Mutex
		// basics
		Called    bool
		CallCount int
		// call history
		History []struct {
			Params struct {
				P0 string
				P1 interface{}
			}
			Results struct {
				R0 error
			}
		}
		// params
		Params struct {
			P0 string
			P1 interface{}
		}
		// results
		Results struct {
			R0 error
		}
		// if it is not nil, it'll be called in the middle of the method.
		Body func(string, interface{}) error
	}
}

func NewMockcCache(
	v ...interface {
		Del(p0 string) error
		Get(p0 string) (interface{}, error)
		Set(p0 string, p1 interface{}) error
	},
) *MockcCache {
	m := &MockcCache{}
	if len(v) > 0 {
		m._Del.Body = v[0].Del
		m._Get.Body = v[0].Get
		m._Set.Body = v[0].Set
	}
	return m
}

func (recv *MockcCache) Del(p0 string) error {
	recv._Del.mu.Lock()
	defer recv._Del.mu.Unlock()
	// basics
	recv._Del.Called = true
	recv._Del.CallCount++
	// params
	recv._Del.Params.P0 = p0
	// body
	if recv._Del.Body != nil {
		recv._Del.Results.R0 = recv._Del.Body(p0)
	}
	// call history
	recv._Del.History = append(recv._Del.History, struct {
		Params struct {
			P0 string
		}
		Results struct {
			R0 error
		}
	}{
		Params:  recv._Del.Params,
		Results: recv._Del.Results,
	})
	// results
	return recv._Del.Results.R0
}

func (recv *MockcCache) Get(p0 string) (interface{}, error) {
	recv._Get.mu.Lock()
	defer recv._Get.mu.Unlock()
	// basics
	recv._Get.Called = true
	recv._Get.CallCount++
	// params
	recv._Get.Params.P0 = p0
	// body
	if recv._Get.Body != nil {
		recv._Get.Results.R0, recv._Get.Results.R1 = recv._Get.Body(p0)
	}
	// call history
	recv._Get.History = append(recv._Get.History, struct {
		Params struct {
			P0 string
		}
		Results struct {
			R0 interface{}
			R1 error
		}
	}{
		Params:  recv._Get.Params,
		Results: recv._Get.Results,
	})
	// results
	return recv._Get.Results.R0, recv._Get.Results.R1
}

func (recv *MockcCache) Set(p0 string, p1 interface{}) error {
	recv._Set.mu.Lock()
	defer recv._Set.mu.Unlock()
	// basics
	recv._Set.Called = true
	recv._Set.CallCount++
	// params
	recv._Set.Params.P0 = p0
	recv._Set.Params.P1 = p1
	// body
	if recv._Set.Body != nil {
		recv._Set.Results.R0 = recv._Set.Body(p0, p1)
	}
	// call history
	recv._Set.History = append(recv._Set.History, struct {
		Params struct {
			P0 string
			P1 interface{}
		}
		Results struct {
			R0 error
		}
	}{
		Params:  recv._Set.Params,
		Results: recv._Set.Results,
	})
	// results
	return recv._Set.Results.R0
}

생성된 MockCacheMock Generator 내부에서 mockc.Implement에 인자로 넘겨줬던 Cache 인터페이스를 구현할 것이고, 메서드의 반환 값이나, 메서드 호출 히스토리와 같은 검증을 위한 값은 MockCache의 필드를 통해 접근할 수 있습니다. (기본적으로 필드의 이름은 '_{MethodName}'이고, mockc.SetFieldNamePrefixmockc.SetFieldNameSuffix 생성 옵션 설정 함수를 통해 prefix, suffix를 변경할 수 있습니다.)

위 절차를 통해 목이 성공적으로 생성됐다면, 바로 사용해볼 수 있습니다.

아래는 생성된 목을 사용한 테스트 코드의 예시입니다.

// cache_test.go
package main

import (
	"errors"
	"testing"
)

func TestHasKey(t *testing.T) {
	// m := &MockcCache{}
	m := NewMockcCache()

	// set return value
	m._Get.Results.R0 = struct{}{}

	// execute
	key := "test_key"
	result, err := HasKey(m, key)

	// assert
	if !result {
		t.Error("result should be true")
	}
	if err != nil {
		t.Error("err should be nil")
	}
	if m._Get.CallCount != 1 {
		t.Errorf("Cache.Get should be called once: actual(%d)", m._Get.CallCount)
	}
	if m._Get.Params.P0 != key {
		t.Errorf("Cache.Get should be called with %q: actual(%q)", key, m._Get.Params.P0)
	}
}

func TestHasKey_WithBodyInjection(t *testing.T) {
	m := NewMockcCache()

	// inject body
	key := "test_key"
	m._Get.Body = func(actualKey string) (interface{}, error) {
		if actualKey != key {
			t.Errorf("Cache.Get should be called with %q: actual(%q)", key, actualKey)
		}
		return nil, errors.New("error")
	}

	// execute
	result, err := HasKey(m, key)

	// assert
	if result {
		t.Error("result should be false")
	}
	if err == nil {
		t.Error("err should not be nil")
	}
	if m._Get.CallCount != 1 {
		t.Errorf("Cache.Get should be called once: actual(%d)", m._Get.CallCount)
	}
}

func TestHasKey_WithMapCache(t *testing.T) {
	// set the underlying implementation by passing real implementation to constructor
	m := NewMockcCache(MapCache{})

	// execute
	key := "key"
	result, err := HasKey(m, key)

	// assert
	if result {
		t.Error("result should be false")
	}
	if err != nil {
		t.Error("err should be nil")
	}
	if m._Get.CallCount != 1 {
		t.Errorf("Cache.Get should be called once: actual(%d)", m._Get.CallCount)
	}
	if m._Get.History[0].Params.P0 != key {
		t.Errorf("Cache.Get should be called with %q: actual(%q)", key, m._Get.History[0].Params.P0)
	}
}

위 테스트 코드는 하나의 예시일 뿐입니다. Mockc를 통해 생성된 목은 직관적이고, 쉽게 커스텀 가능한 인터페이스를 제공하고 있기 때문에 원하는 패턴으로 자유롭게 테스트를 구성할 수 있습니다. 더 많은 예시는 여기에서 확인할 수 있습니다.

Conclusion

이 글을 통해 Mockc가 무엇이고 어떻게 사용할 수 있는지 아주 간단하게 살펴봤습니다. Mockc가 모두를 만족시킬 만한 완벽한 라이브러리라 생각하지 않습니다. 다른 라이브러리들과 비교했을 때 분명 장단점이 있을 것입니다. 어떤 사람들은 Mockc의 직관적인 인터페이스와 간결함이 마음에 들테지만, 또 어떤 사람들은 Mockc가 모킹을 위해 사용하는 방법(빌드 태그 사용, 코드 제너레이션 등)이 마음에 들지 않을 수 있습니다.

하지만 Mockc는 여전히 빠르게 발전하는 중이고, 더 많은 사람들이 편하게 사용할 수 있는 방향으로 나아가고 싶습니다. 그렇기에 여러분의 피드백과 기여가 절실히 필요합니다. 어떤 피드백도 감사히 받겠습니다.

감사합니다!

Comments

Popular Posts

Go: Interface Mocking With Mockc