이 문서는 Go 테스트 방법과 가이드라인을 제공한다. 이는 Go 테스트 추천 라이브러리와 활용 방법에 대한 내용을 포함한다. 단 아래 내용은 추천 사항일 뿐이며, 각 프로젝트에 따라서 언제든 자유롭게 다른 방법을 적용하거나 제안할 수 있다. 다만 이 경우에도 프로젝트 내에서는 통일성을 갖는 것을 장려한다.
테스트 라이브러리
Testify
- TestSuite 을 사용하기 위해
- 모킹을 활용하기 위해
Faker
테스트에 사용하는 객체를 정의할 때 모든 값을 일정하게 대입하거나 값을 넣지 않으면 다양한 케이스를 처리할 수 없으므로 일부 객체의 멤버값들이 임의의 값을 가질수 있도록 할 수 있다.
Go SQLMock
gopkg.in/DATA-DOG/go-sqlmock.v2
- 데이터베이스에 대한 의존성 없이 실제 쿼리를 돌리지 않고 쿼리 그 자체에 대해 검증 할 수 있다.
가이드라인
테스트 파일 및 패키지 이름
- 파일 이름은 테스트 대상이 되는 go 파일의 이름과 맞춘다.
- 테스트 대상 패키지에 _test 를 붙여서 테스트를 작성한다. (Blackbox 테스트)
- 패키지 내부 구현이 복잡할 경우 대상 패키지와 같은 이름을 사용할 수 있다. (Whitebox 테스트)
대상 | 테스트 | |
---|---|---|
파일 | controller.go | controller_test.go |
패키지 | package articlesvc | package articlsvc_test (package articlesvc) |
함수 | GetArticles() | Test_GetArticles() |
모킹
- 참조된 객체는 모킹하여 테스트한다.
유닛 테스트 작성
테스트 스위트(TestSuite) 정의
테스트 스위트(TestSuite)를 활용해서 각 유닛 테스트 초기화 및 테스트 정리를 수행한다. SetupTest
함수를 통해 테스트 대상과 기타 테스트마다 초기화 되어야 하는 변수들의 초기화를 수행할 수 있고, TearDownTest
함수를 통해 테스트 이후 테스트로 변경된 사항을 정리할 수 있다. 자세한 내용은 Suite Package 문서를 참고한다.
type ControllerTestSuite struct {
suite.Suite
controller articlesvc.Controller
engine *core.Engine
usecase *mocks.Usecase
}
func (ts *ControllerTestSuite) SetupTest() {
ts.engine = echo.New()
ts.usecase = new(mocks.Usecase)
ts.controller = articlesvc.NewController(ts.engine, ts.usecase)
}
// Write test definition with TestSuite.
func TestControllerSuite(t *testing.T) {
suite.Run(t, new(ControllerTestSuite))
}
모킹 객체를 준비하기
모킹 객체는 mock.Mock 을 활용해서 작성한다. 아래의 예시는 유스케이스 목을 작성하는 예제이다. 구현은 해당 함수를 부르는 쪽에서 응답을 정하도록 작성한다.
var _ article.Usecase = &Usecase{}
type Usecase struct {
mock.Mock
}
func (u *Usecase) GetBy(lang string) ([]*article.Article, error) {
ret := u.Called(lang)
return ret.Get(0).([]*article.Article), ret.Error(1)
}
func (u *Usecase) Save(a *article.Article) error {
ret := u.Called(a)
return ret.Error(0)
}
func (u *Usecase) Delete(a *article.Article) error {
ret := u.Called(a)
return ret.Error(0)
}
테스트 케이스를 작성하기
테스트 대상(Class)의 모든 공개(Public) 함수들을 테스트 한다. 아래는 위에서 준비한 모킹 객체를 활용해서 컨트롤러 테스트케이스를 작성한 예제이다. 유스케이스의 함수들을 모킹할 때 어떤 함수(GetBy)의 파라미터로 어떤 값(en) 을 전달되어야 하는지를 작성하고 이에 대한 응답도 지정해준다.
func (ts *ControllerTestSuite) TestController_GetArticles() {
// Given
var mockArticles []*article.Article
err := faker.FakeData(&mockArticles)
ts.NoError(err)
ts.usecase.On("GetBy", "en").Return(mockArticles, nil).Once() // 유스케이스 함수 모킹
req := (&network.Request{
Method: http.MethodGet,
Url: "/articles",
Params: &url.Values{
"lang": []string{"en"},
},
}).Build()
ts.NoError(err)
ctx, rec := ts.buildContextAndRecorder(req.GetHttpRequest())
// When
err = ts.controller.GetArticles(ctx)
// Then
ts.NoError(err)
ts.Equal(http.StatusOK, rec.Code)
ts.usecase.AssertExpectations(ts.T())
}
테스트 정의하기
테스트 스위트를 활용해서 테스트할 대상의 함수들에 대해 테스트 작성을 완료 했다면 해당 테스트 스위트를 실행할 테스트 코드를 작성한다. 테스트 코드는 Test 로 시작해야 하며 파라미터로 *testing.T 를 전달한다.
func TestControllerSuite(t *testing.T) {
suite.Run(t, new(ControllerTestSuite))
}
테스트 코드 예제
프로젝트 레이아웃
의존성 흐름: 컨트롤러 -> Article 도메인의 유스케이스 -> 엔티티 / 리포지토리 -> 데이터소스의 DbArticleGormSrouce
테스트 대상 & 예제
컨트롤러
- api/articlesvc/controller.go
- 의존성: Article 도메인의 유스케이스 (Get / Save / Delete)
[go-rest-sample] controller_test.go
package articlesvc_test
// Step 3. Write test cases for each public method for the testing target.
// In this case, articles controller is the testing target and it has 3 functions. (Get / Post / Delete)
func (ts *ControllerTestSuite) TestController_GetArticles() {
// Given
var mockArticles []*article.Article
err := faker.FakeData(&mockArticles)
ts.NoError(err)
ts.usecase.On("GetBy", "en").Return(mockArticles, nil).Once()
req := (&network.Request{
Method: http.MethodGet,
Url: "/articles",
Params: &url.Values{
"lang": []string{"en"},
},
}).Build()
ts.NoError(err)
ctx, rec := ts.buildContextAndRecorder(req.GetHttpRequest())
// When
err = ts.controller.GetArticles(ctx)
// Then
ts.NoError(err)
ts.Equal(http.StatusOK, rec.Code)
ts.usecase.AssertExpectations(ts.T())
}
func (ts *ControllerTestSuite) TestController_PostArticles() {
var mockArticle article.Article
err := faker.FakeData(&mockArticle)
mockArticle.ID = 0 // 새로운 Article
ts.NoError(err)
ts.usecase.On("Save", mock.Anything).Return(nil).Once()
req := (&network.Request{
Method: http.MethodPost,
Url: "/articles",
Params: &url.Values{
"title": []string{mockArticle.Title},
"content": []string{mockArticle.Content},
"lang": []string{mockArticle.Lang},
},
}).Build()
ctx, rec := ts.buildContextAndRecorder(req.GetHttpRequest())
err = ts.controller.PostArticles(ctx)
ts.NoError(err)
ts.Equal(http.StatusOK, rec.Code)
ts.usecase.AssertExpectations(ts.T())
}
func (ts *ControllerTestSuite) TestController_DeleteArticles() {
var mockArticle article.Article
err := faker.FakeData(&mockArticle)
ts.NoError(err)
ts.usecase.On("Delete", mock.Anything).Return(nil).Once()
req := (&network.Request{
Method: http.MethodDelete,
Url: "/articles",
Params: &url.Values{
"id": []string{strconv.FormatInt(mockArticle.ID, 10)},
},
}).Build()
ctx, rec := ts.buildContextAndRecorder(req.GetHttpRequest())
err = ts.controller.DeleteArticles(ctx)
ts.NoError(err)
ts.Equal(http.StatusOK, rec.Code)
ts.usecase.AssertExpectations(ts.T())
}
func (ts *ControllerTestSuite) buildContextAndRecorder(httpRequest *http.Request) (ctx core.Context, rec *httptest.ResponseRecorder) {
rec = httptest.NewRecorder()
ctx = ts.engine.NewContext(httpRequest, rec)
return
}
// Step 2. Write test definition with TestSuite.
func TestControllerSuite(t *testing.T) {
suite.Run(t, new(ControllerTestSuite))
}
// Step 1. Define a test suite.
// Testing target and some pre-defined members should be initialized.
// In this case, controller is testing target and usecase is defined for mocking.
type ControllerTestSuite struct {
suite.Suite
controller articlesvc.Controller
engine *core.Engine
usecase *mocks.Usecase
}
func (ts *ControllerTestSuite) SetupTest() {
ts.engine = echo.New()
ts.usecase = new(mocks.Usecase)
ts.controller = articlesvc.NewController(ts.engine, ts.usecase)
}
유스케이스
- article/usecase.go
- 의존성: 도메인 내의 entity.go, repository.go
[go-rest-sample] usecase_test.go
package article_test
func (ts *UsecaseTestSuite) TestArticleUsecase_GetBy() {
var mockArticles []*article.Article
lang := "en"
err := faker.FakeData(&mockArticles)
ts.NoError(err)
for _, mn := range mockArticles {
mn.Lang = lang
}
ts.repo.On("Find", `lang = ?`, []interface{}{lang}).Return(mockArticles, nil).Once()
results, err := ts.usecase.GetBy("en")
ts.NoError(err)
ts.Equal(mockArticles, results)
ts.repo.AssertExpectations(ts.T())
}
func (ts *UsecaseTestSuite) TestArticleUsecase_Save() {
var mockArticle article.Article
err := faker.FakeData(&mockArticle)
ts.NoError(err)
mockArticle.ID = 0
ts.repo.On("Save", &mockArticle).Return(nil).Once()
err = ts.usecase.Save(&mockArticle)
ts.NoError(err)
ts.repo.AssertExpectations(ts.T())
}
func (ts *UsecaseTestSuite) TestArticleUsecase_Delete() {
var mockArticle article.Article
err := faker.FakeData(&mockArticle)
ts.NoError(err)
ts.repo.On("Delete", &mockArticle).Return(nil).Once()
err = ts.usecase.Delete(&mockArticle)
ts.NoError(err)
ts.repo.AssertExpectations(ts.T())
}
func TestControllerSuite(t *testing.T) {
suite.Run(t, new(UsecaseTestSuite))
}
var (
_ suite.SetupTestSuite = &UsecaseTestSuite{}
)
type UsecaseTestSuite struct {
suite.Suite
repo *mocks.Repository
usecase article.Usecase
}
func (ts *UsecaseTestSuite) SetupTest() {
ts.repo = new(mocks.Repository)
ts.usecase = article.NewUsecase(ts.repo)
}
[installedappsvc] usecase_test.go
package installedapp
func TestGetInstalledApps(t *testing.T) {
uid := int64(10001)
apps := []InstalledApp{InstalledApp{UserID: uid, PkgName: "com.test.package.1"}}
mockRepo := new(mockRepo)
mockRepo.On("GetByUserID", uid).Return(apps, nil).Once()
u := NewUsecase(mockRepo)
pkgs, err := u.GetInstalledApps(uid)
mockRepo.AssertExpectations(t)
assert.Nil(t, err)
assert.Equal(t, 1, len(pkgs))
assert.Equal(t, "com.test.package.1", pkgs[0].PkgName)
}
func TestSaveInstalledApps(t *testing.T) {
uid := int64(10001)
pkgName := "com.test.package.1"
pkgNames := []string{pkgName}
apps := []InstalledApp{InstalledApp{UserID: uid, PkgName: pkgName}}
mockRepo := new(mockRepo)
mockRepo.On("Create", apps).Return(nil).Once()
mockRepo.On("DeleteByUserIDExcept", uid, pkgNames).Return(nil).Once()
u := NewUsecase(mockRepo)
u.SaveInstalledApps(uid, pkgNames)
mockRepo.AssertExpectations(t)
}
리포지토리
- article/repo/repo.go
- 의존성: datasource/dbarticle/source.go
[go-rest-sample] repo_test.go
//https://github.com/jirfag/go-queryset/blob/master/queryset/queryset_test.go
func (ts *RepoTestSuite) TestArticleRepository_Find() {
var mockArticles []*dbarticle.Article
lang := "en"
err := faker.FakeData(&mockArticles)
ts.NoError(err)
for _, mn := range mockArticles {
mn.Lang = lang
}
ts.dbSource.On("Find", `lang = ?`, []interface{}{[]interface{}{"en"}}).Return(mockArticles, nil).Once()
var results []*article.Article
results, err = ts.repo.Find("lang = ?", "en")
ts.NoError(err)
ts.Equal(len(mockArticles), len(results))
ts.dbSource.AssertExpectations(ts.T())
}
func (ts *RepoTestSuite) TestArticleRepository_Save() {
var mockDBArticle dbarticle.Article
err := faker.FakeData(&mockDBArticle)
ts.NoError(err)
mockDBArticle.ID = 0
mockDBArticle.DeletedAt = nil
mockDBArticle.CreatedAt = time.Time{}
mockDBArticle.UpdatedAt = time.Time{}
ts.dbSource.On("Save", &mockDBArticle).Return(nil).Once()
mockArticle := repo.DBArticleToArticle(mockDBArticle)
err = ts.repo.Save(&mockArticle)
ts.NoError(err)
ts.dbSource.AssertExpectations(ts.T())
}
func (ts *RepoTestSuite) TestArticleRepository_Delete() {
mockDBArticle := dbarticle.Article{
ID: 1,
}
mockDBArticle.DeletedAt = nil
ts.dbSource.On("Delete", &mockDBArticle).Return(nil).Once()
mockArticle := repo.DBArticleToArticle(mockDBArticle)
err := ts.repo.Delete(&mockArticle)
ts.NoError(err)
ts.dbSource.AssertExpectations(ts.T())
}
func TestRepoSuite(t *testing.T) {
suite.Run(t, new(RepoTestSuite))
}
type RepoTestSuite struct {
suite.Suite
dbSource *dbarticle.MockDBSource
repo article.Repository
}
func (ts *RepoTestSuite) SetupTest() {
ts.dbSource = &dbarticle.MockDBSource{}
ts.repo = repo.New(ts.dbSource)
}
[installedappsvc] repo_test.go
package iarepo
type repoTestSuite struct {
suite.Suite
db *sql.DB
}
func TestRepoTestSuite(t *testing.T) {
suite.Run(t, new(repoTestSuite))
}
func (s *repoTestSuite) SetupSuite() {
db, err := sql.Open("postgres", "")
require.Nil(s.T(), err)
s.db = db
}
func (s *repoTestSuite) SetupTest() {
_, err := s.db.Exec("DELETE FROM installed_apps")
require.Nil(s.T(), err)
}
func (s *repoTestSuite) TearDownSuite() {
s.db.Close()
}
func (s *repoTestSuite) TestGetByUserID() {
require := require.New(s.T())
assert := assert.New(s.T())
uid := int64(10001)
pkgName := "com.test.package.1"
_, err := s.db.Exec("INSERT INTO installed_apps (user_id, package_name) VALUES ($1, $2)", uid, pkgName)
require.Nil(err)
r := New(s.db)
as, err := r.GetByUserID(uid)
require.Nil(err)
require.Equal(1, len(as))
assert.Equal(pkgName, as[0].PkgName)
}
func (s *repoTestSuite) TestCreate() {
require := require.New(s.T())
assert := assert.New(s.T())
uid := int64(10001)
as := []ia.InstalledApp{
ia.InstalledApp{UserID: uid, PkgName: "com.test.package.1"},
ia.InstalledApp{UserID: uid, PkgName: "com.test.package.2"},
}
r := New(s.db)
err := r.Create(as)
require.Nil(err)
rows, err := s.db.Query("SELECT user_id, package_name, uninstalled_at FROM installed_apps")
require.Nil(err)
require.True(rows.Next())
var ra ia.InstalledApp
var ts *time.Time
rows.Scan(&ra.UserID, &ra.PkgName, &ts)
a := as[0]
assert.Equal(a.UserID, ra.UserID)
assert.Equal(a.PkgName, ra.PkgName)
assert.Nil(ts)
}
func (s *repoTestSuite) TestCreateWhenUninstalledPackageExists() {
require := require.New(s.T())
assert := assert.New(s.T())
uid := int64(10001)
pkgName := "com.test.package.1"
_, err := s.db.Exec("INSERT INTO installed_apps (user_id, package_name, uninstalled_at) VALUES ($1, $2, $3)", uid, pkgName, time.Now())
require.Nil(err)
as := []ia.InstalledApp{ia.InstalledApp{UserID: uid, PkgName: pkgName}}
r := New(s.db)
err = r.Create(as)
require.Nil(err)
rows, err := s.db.Query("SELECT uninstalled_at FROM installed_apps")
require.Nil(err)
require.True(rows.Next())
var ts *time.Time
rows.Scan(&ts)
assert.Nil(ts)
}
func (s *repoTestSuite) TestDeleteByUserIDExcept() {
require := require.New(s.T())
assert := assert.New(s.T())
uid := int64(10001)
pkgName := "com.test.package.1"
_, err := s.db.Exec("INSERT INTO installed_apps (user_id, package_name) VALUES ($1, $2)", uid, pkgName)
require.Nil(err)
r := New(s.db)
err = r.DeleteByUserIDExcept(uid, []string{})
require.Nil(err)
rows, err := s.db.Query("SELECT uninstalled_at FROM installed_apps")
require.Nil(err)
require.True(rows.Next())
var ts *time.Time
rows.Scan(&ts)
assert.NotNil(ts)
}
func (s *repoTestSuite) TestDeleteByUserIDExceptWhenFilterMatches() {
require := require.New(s.T())
assert := assert.New(s.T())
uid := int64(10001)
pkgName := "com.test.package.1"
_, err := s.db.Exec("INSERT INTO installed_apps (user_id, package_name) VALUES ($1, $2)", uid, pkgName)
require.Nil(err)
r := New(s.db)
err = r.DeleteByUserIDExcept(uid, []string{pkgName})
require.Nil(err)
rows, err := s.db.Query("SELECT uninstalled_at FROM installed_apps")
require.Nil(err)
require.True(rows.Next())
var ts *time.Time
rows.Scan(&ts)
assert.Nil(ts)
}
데이터소스
- datasource/dbarticle/gorm_source.go
- 의존성: gorm
[go-rest-sample] gorm_source_test.go
package dbarticle_test
//https://github.com/jirfag/go-queryset/blob/master/queryset/queryset_test.go
func (ts *RepoTestSuite) TestGormArticleRepository_Find() {
articles := getTestArticles(2)
req := "SELECT * FROM `articles` WHERE `articles`.`deleted_at` IS NULL"
ts.mock.ExpectQuery(fixedFullRe(req)).
WillReturnRows(getRowsForArticles(articles))
var results []*dbarticle.Article
results, err := ts.dbSource.Find("")
ts.NoError(err)
ts.Equal(articles, results)
}
func (ts *RepoTestSuite) TestGormArticleRepository_Save() {
n := getTestArticles(1)[0]
query := "INSERT INTO `articles` (`title`,`content`,`lang`,`author_id`,`created_at`,`updated_at`,`deleted_at`) VALUES (?,?,?,?,?,?,?)"
args := []driver.Value{n.Title, sqlmock.AnyArg(), n.Lang, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), nil}
ts.mock.ExpectExec(fixedFullRe(query)).
WithArgs(args...).
WillReturnResult(sqlmock.NewResult(1, 1))
err := ts.dbSource.Save(n)
ts.NoError(err)
}
func (ts *RepoTestSuite) TestGormArticleRepository_Delete() {
n := getTestArticles(1)[0]
n.ID = 1
query := "UPDATE `articles` SET `deleted_at`=? WHERE `articles`.`deleted_at` IS NULL AND `articles`.`id` = ?"
args := []driver.Value{sqlmock.AnyArg(), n.ID}
ts.mock.ExpectExec(fixedFullRe(query)).
WithArgs(args...).
WillReturnResult(sqlmock.NewResult(1, 1))
err := ts.dbSource.Delete(n)
ts.NoError(err)
}
func getTestArticles(num int) []*dbarticle.Article {
articles := make([]*dbarticle.Article, 0)
for i := 0; i < num; i++ {
n := &dbarticle.Article{
Title: fmt.Sprintf("Title - %d", i),
Lang: "en",
}
articles = append(articles, n)
}
return articles
}
func getRowsForArticles(articles []*dbarticle.Article) *sqlmock.Rows {
var fieldNames = []string{"id", "title", "content", "lang"}
rows := sqlmock.NewRows(fieldNames)
for _, n := range articles {
rows = rows.AddRow(n.ID, n.Title, n.Content, n.Lang)
}
return rows
}
func fixedFullRe(s string) string {
return fmt.Sprintf("^%s$", regexp.QuoteMeta(s))
}
func TestRepoSuite(t *testing.T) {
suite.Run(t, new(RepoTestSuite))
}
type RepoTestSuite struct {
suite.Suite
mock sqlmock.Sqlmock
db *gorm.DB
dbSource dbarticle.DBSource
}
func (ts *RepoTestSuite) SetupTest() {
db, mock, err := sqlmock.New()
ts.NoError(err)
ts.mock = mock
ts.db, err = gorm.Open("mysql", db)
ts.NoError(err)
ts.db.LogMode(true)
ts.db = ts.db.Set("gorm:update_column", true)
ts.dbSource = dbarticle.NewSource(ts.db)
}
func (ts *RepoTestSuite) AfterTest(suiteName, testName string) {
_ = ts.db.Close()
}