본문 바로가기
Golang

Golang과 함께하는 테스트 여정 - 통합 테스트

by Limm_jk 2023. 5. 21.

위 글에서 단위 테스트에 대하여 이야기 해보았습니다.

하지만, 테스트는 단위 테스트만으로 완벽하게 보장되지 않습니다. 예를 들면 어떠한 모듈을 모킹해둔 상태에서 그 모듈을 변경한다면 잘못되었지만 테스트는 잘 돌아가는 위양성 문제가 발생할 수 있으며, 회귀 테스트가 보장되지 않을 수 있습니다.

이를 보장하기 위해서는 다양한 모듈이 참여하는 통합 테스트가 필요한데요. Golang으로 통합 테스트를 작성하는 과정을 설명해보겠습니다.

Pre-Condition

팀에서는 DI를 편하게 사용하기 위하여 fx라는 의존성 주입 프레임워크를 사용합니다. 여기서 만들어주는 것을 기반으로 글을 진행합니다.

생성의 책임을 위임한 서버 띄우기

최초에 테스트 환경을 어떻게 구축할지 고민을 많이 했는데요. 결론은 서버를 띄워서 api를 쏠 수 있는 환경을 만들고, 거기서 추가적으로 붙여보자 정도의 결론을 냈습니다. 이 과정에서 fx의 도움을 많이 받아서 세팅했는데요. 이 과정에 대하여 이야기해보겠습니다.

fx Populate

fx는 테스트를 위하여 많은 기능을 추가적으로 제공해주는데요. 그 중, 세팅하면서 Populate를 특히 유용하게 사용하였습니다. Populate는 다음과 같이 설명이 적혀있습니다.

Populate sets targets with values from the dependency injection container during application initialization. All targets must be pointers to the values that must be populated. Pointers to structs that embed In are supported, which can be used to populate multiple values in a struct. This is most helpful in unit tests: it lets tests leverage Fx's automatic constructor wiring to build a few structs, but then extract those structs for further testing.

간단하게 설명하면 Populate 함수에 객체의 포인터를 넘기면, 의존성 주입 과정에서 생성한 객체를 포인터에 넣어주는 기능이라고 해석할 수 있습니다. db객체 혹은 repo 객체를 직접 접근해야하는 상황에서 상당히 유용하게 사용할 수 있을 것 같았고, 실제로 큰 도움을 주었습니다.

그래서 해당 기능을 이용하여 아래와 같이 최초에 TestApp 코드를 작성했습니다.

func NewTestApp(toBeWired ...any) *TestApp {
	var db *sql.DB

	fxApp := fx.New(
		http.NewModule(),
		fx.Populate(
			append(
				toBeWired,
				db,
			)...,
		),
		fx.Invoke(
			func(s *public.Server) {
				err := s.Run()
				if err != nil {
					return
				}
			},
		),
	)

	return &TestApp{
		app: fxApp,
		db:  db,
	}
}

위에서 db를 따로 받아준 이유는 db 사용 이후에 테이블을 truncate하는 과정을 TestApp이 어느정도 책임지면 좋을 것 같다는 생각에서 다음과 같이 작업해주었습니다.

그리고 TestApp을 사용하는 통합테스트 블럭에서 toBeWired에 받고 싶은 객체의 포인터를 넘기면 해당 포인터에 생성된 객체가 넘어와서 테스트 환경에서 용이하게 사용할 수 있도록 기획하였습니다.

Server.Run()

하지만, 이 코드에는 큰 문제가 있었습니다. Gin Server가 돌아가는 구조를 이해하지 못하고 되겠지.. 하고 넘어갔던 부분 중 server.Run()에서 문제가 발생했는데요. 이 글에서 설명하였듯, Gin Server는 Run을 하면 Listener에서 Accept할 때까지 대기합니다. 이로 인하여 해당 라인에서 블록되어서 다음으로 넘어가지 않는 문제가 있었는데요.

이를 해결하기 위하여 서버를 독립적인 고루틴으로 돌리면 안될까?와 같은 생각을 해보았습니다. 하지만, 결론적으로는 어렵다고 결론을 내렸는데, 서버가 뜰 때 핑을 받거나 그럴 수 있는 기능이 없어서 생각보다 많은 시간을 써야할 것으로 예상되었기 때문입니다. 안정적인 테스트 환경을 구축하려면 스핀돌면서 서버가 떴는지 확인하거나, listen signal을 받아서 하는 등의 방법이 필요했는데, 현재 이정도까지의 공수를 들일 필요가 없다고 판단했습니다.

그럼 서버는 띄우지 말자..

그래서 결국 서버는 띄우지 말자. 하지만, fx를 이용하여 Injection은 이루어졌으니 이를 이용하여 e2e는 아닌 통합테스트를 만들어보자! 로 방향성을 변경했습니다.

그래서 다음과 같이 코드를 변경했습니다.

func NewTestApp(testOpts ...fx.Option) *TestApp {
	var db *sql.DB

	fxApp := fx.New(
		append(
			testOpts,
			http.NewModule(),
			Populate(&db),
		)...,
	)

	return &TestApp{
		app: fxApp,
		DB:  db,
	}
}

var Populate = fx.Populate

func Mock[T any](mock any) fx.Option {
	return fx.Decorate(func() T { return mock.(T) })
}

func (t *TestApp) Stop() {
	err := t.app.Stop(context.Background())
	if err != nil {
		return
	}
}

크게 바뀐 부분은 Invoke를 통하여 server.Run()을 하지 않는 점입니다.

사용하는 부분에서 test option을 만들어서 넘길 수 있게 하였습니다. 이 과정에서 mock의 필요성을 느껴서 Mock 함수를 이용하여 mocking 할 수 있도록 만들었고, Populate와 함께 option으로 넘길 수 있도록 하였습니다.

그리고 Stop을 만들어서 자원을 회수할 수 있도록 해주었습니다.

테스트 작성하기

이 테스트는 테스트 이전, 이후에 해줘야 할 일이 많아서 testify의 test suite을 쓰는 것을 권장드립니다.

db를 사용하여 실제 쿼리를 날리는 예제를 들어보겠습니다.

최초에는 TestSuite과 Suite Runner를 만들어주어야 합니다. 그리고 TestApp을 세팅해주어야 하는데요.

func TestTransactorTestSuite(t *testing.T) {
	suite.Run(t, new(TransactorTestSuite))
}

type TransactorTestSuite struct {
	suite.Suite
	app        *integration.TestApp
	transactor *trx.Transactor
}

func (s *TransactorTestSuite) SetupSuite() {
	s.app = integration.NewTestApp()
	s.transactor = trx.NewTransactor(s.app.DB)
}

func (s *TransactorTestSuite) TearDownSuite() {
	s.app.Stop()
}

TestSuite에서 TestApp을 받아서 사용할 수 있도록 해주었습니다. 그리고 TestSuite의 테스트를 시작하기 전에 동작하는 SetupSuite을 통하여 TestApp을 받고, 모든 테스트가 끝난 이후 동작하는 TearDownSuite을 통하여 TestApp의 자원을 회수해주었습니다.

다음으로는 테스트 환경 세팅을 위하여 db를 세팅해주었습니다.

func (s *TransactorTestSuite) SetupTest() {
	_, err := s.app.DB.Exec(fmt.Sprintf(
		"CREATE TABLE IF NOT EXISTS %s (id SERIAL PRIMARY KEY, name TEXT NOT NULL)",
		testTableName,
	))
	s.NoError(err)
}

func (s *TransactorTestSuite) TearDownTest() {
	_, err := s.app.DB.Exec(fmt.Sprintf("DROP TABLE %s", testTableName))
	s.NoError(err)
}

각 테스트 시점에 필요한 테이블을 만들고 제거해주는 로직을 넣어주었는데요. 여기서는 쿼리를 직접 썼으나, repo를 주입받아서 필요한 데이터를 세팅해 주는 방향으로 개선이 가능합니다.

이 과정까지 했다면 테스트 코드는 이전 글에서 언급했던 테스트 방법과 비슷합니다. 로직을 잘 정의하고, assert를 통하여 원하는 값이 나오는지 잘 테스트합니다.

func (s *TransactorTestSuite) TestTransactor_WithoutError() {
   ctx := context.Background()

   err := s.transactor.Run(ctx, func(ctx context.Context) error {
      tx := trx.Tx(ctx)
      s.NotNil(tx)

      s.NoError(executeInsertQuery(ctx, tx))

      return nil
   })

   s.NoError(err)

   count, err := countTestRow(s.app.DB)
   s.NoError(err)
   s.Equal(1, count)
}

makefile도 잘 만들어야 한다.

TestStage

개발환경 구분을 명확하게 하고 싶어하는 니즈가 존재하여 Test와 CI환경을 분리하였는데요.

테스트 환경에서는 test 혹은 ci의 stage만 사용하기를 원하므로 TestStage를 따로 만들어주었습니다.

# 기본적으로 test STAGE로, 단 ci환경에서만 ci STAGE로 테스트를 제공함
TEST_STAGE := test
ifeq (ci, $(STAGE))
	TEST_STAGE = $(STAGE)
endif

...

test:
	GO_ENV=$(TEST_STAGE) go test .

test makefile setting

먼저 통합테스트는 시간이 비교적으로 오래 소요됩니다. 이로 인하여 unit test만 돌리고 싶은 순간이 분명 존재하리라 생각하였고, makefile에서 unitTest와 IntegrationTest를 분리했습니다.

unitTest:
	# generated file 전반적으로 test에서 제외
	# admin & public 모두 swagger는 제외해주기 위하여 swagger는 / 없이 swagger/로 사용
	GO_ENV=$(TEST_STAGE) go test `go list ./... | grep -Ev '/generated/|/test/|swagger/'`

integrationTest:
	# 통합테스트는 병렬로 돌아갈 시 db에 문제가 있을 수 있으므로 -parallel(p) flag를 1로 설정하여 순차적으로 시행하도록 설정함
	GO_ENV=$(TEST_STAGE) go test ./test/integration/... -p 1

이 과정에서 unitTest와 integrationTest의 속성에 따른 추가 설정이 들어갔습니다.

먼저 unitTest의 경우에는 어디에나 존재할 수 있기 때문에 모든 디렉토리를 대상으로 진행하였습니다. 하지만, 생성된 파일에 대하여는 그렇게 하는 것을 원하지 않기 때문에, grep -Ev를 이용하여 해당 디렉토리를 제외하고 진행하였습니다.

그리고 integrationTest의 경우는 병렬로 돌아가게 된다면 db 정합성에 문제가 생길 수 있습니다. 이를 해결하는 다양한 방법이 있으나, 이로 인하여 충분히 느려진 상황에 도입해도 늦지 않으리라 생각해서 병렬처리가 안되는 방향으로 해결하였습니다.

위 문제를 리서치하면서 p 옵션을 주지 않았을 때 기본적으로 parallel 값이 들어가는 로직에 대하여 리서치했는데, 함께 첨부합니다.

더보기

reference 1 by go help build

The default is GOMAXPROCS, **normally the number of CPUs available**.

reference 2 by go help testflag

    -parallel n
        Allow parallel execution of test functions that call t.Parallel.
        The value of this flag is the maximum number of tests to run
        simultaneously; by default, it is set to the value of GOMAXPROCS.
        Note that -parallel only applies within a single test binary.
        The 'go test' command may run tests for different packages
        in parallel as well, according to the setting of the -p flag
        (see 'go help build').

한 줄 요약 : 괜찮은 인스턴스 사용하면 기본적으로 병렬로 돈다. (CPU 가용 가능한 수만큼)

make test

그리고 마지막으로 test를 한번에 돌릴 환경을 위하여 make test에는 unit, integration을 함께 돌릴 수 있도록 해주었습니다.

하지만, 이 때 독립적인 integration과 unit을 순차적으로 돌리면 시간이 조금 아까울 것 같아서 CI에서는 아래와 같이 병렬로 돌도록 처리했습니다.

name: Run test
    # 한번에 처리할 job의 수를 지정
    # default value = 1 / 하나의 작업만을 한번에 처리함
    # make test 내에서 unit test와 integration test를 함께 돌리기 위하여 사용함.
    command: |
      make -j 2 test

 

마무리

사내에서 최초의 golang 프로젝트를 세팅하며 고민했던 테스트에 대하여 정리해보았습니다. 혹시 첨언하실 것이 있다면 편하게 말씀해주시면 다시 한번 감사드리겠습니다.

 

마치면서 통합 테스트가 느려졌을 때 참고할만한 아티클을 남기고 정리해보겠습니다 ;)

https://medium.com/kongkow-it-medan/parallel-database-integration-test-on-go-application-8706b150ee2e

 

Parallel database integration test on Go application

Integration test usually takes more time. Running the test parallelly is a good solution and will reduce the time spend on running the test

medium.com

 

댓글