Go 테스트

이 문서는 Go 테스트 방법과 가이드라인을 제공한다. 이는 Go 테스트 추천 라이브러리와 활용 방법에 대한 내용을 포함한다. 단 아래 내용은 추천 사항일 뿐이며, 각 프로젝트에 따라서 언제든 자유롭게 다른 방법을 적용하거나 제안할 수 있다. 다만 이 경우에도 프로젝트 내에서는 통일성을 갖는 것을 장려한다.

테스트 라이브러리

Testify

github.com/stretchr/testify

  • TestSuite 을 사용하기 위해
  • 모킹을 활용하기 위해

Faker

github.com/bxcodec/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()
}