테스트 픽스처를 사용한 테스트 코드 짜기
데이터베이스 테이블에 CRUDCreateReadUpdateDelete를 제공하는 간단한 REST API를 구현한다고 생각해 보자. 테스트 코드는 어떻게 작성할 수 있을까?
먼저 목 객체MockObject이나 스텁Stub 같은 테스트 대역Test Double을 사용하여 단위 테스트를 작성할 수 있을 것이다. 그리고 더 많은 동작에 대한 확신을 얻기 위해서 테스트 대역을 사용하지 않고 테스트하기를 원할 수 있다. 흔히 통합 테스트라고 말하는데 이런 테스트를 작성하다 보면 테스트 데이터베이스와 테스트 데이터가 필요하다.
이 글은 테스트 픽스처Test fixture 개념과 Go 언어로 데이터베이스 픽스처 사용한 테스트 코드 작성하는 방법을 소개한다.
테스트 픽스처
xUnit 테스트 패턴에서는 테스트 픽스처를 아래처럼 정의한다.
xUnit에서는 SUTSystem Under Test를 실행하기 위해 필요한 모든 것을 테스트 픽스처라 부르고, 픽스처를 설치하기 위해 호출하는 테스트 로직 부분을 테스트의 픽스처 설치 단계라고 한다.
- xUnit 테스트 패턴 142 쪽
앞서 언급했던 통합 테스트 코드를 실행하기 위해 필요한 테스트 데이터베이스와 데이터는 테스트 픽스처(데이터베이스 픽스처)라고 부를 수 있다.
데이터베이스 픽스처는 공유해서 사용하기 쉬운데 공유 데이터 픽스처는 변덕스러운 테스트Erratic Test 문제가 발생하기 쉽다.
변덕스러운 테스트
하나 이상의 테스트가 변덕을 부린다. 즉, 어떨 때는 통과하고 어떨 때는 실패한다.
- xUnit 테스트 패턴 329 쪽
예를 들면 A 테스트 실행 전제 조건은 B이라는 테스트 데이터가 있어야 하는데 누군가(사람일 수도 있고 테스트 코드일 수도 있다) 데이터를 B'로 변경하거나 데이터를 삭제해 버릴 수 있다.
로컬 데이터베이스를 설치해서 사용할 수 있지만 여전히 변덕스러운 테스트 문제에서 자유로울 수 없다. 왜냐하면 테스트 코드끼리 테스트 데이터를 오염시킬 수 있기 때문이다. 1회용 신선한 픽스처 사용으로 이 문제를 해결할 수 있다.
1회용 신선한 픽스처
이 경우에는 각 테스트가 실행될 때 임시 신선한 픽스처를 생성한다. 테스트가 스스로 자신이 필요로 하는 객체나 데이터를(꼭 테스트 메소드 안에서가 아니라도) 생성한다. 이런 테스트 픽스처는 현재 테스트에서만 보이므로 같은 픽스처를 사용하는 다른 테스트의 출력에 실수로나 일부러 의존할 수가 없어서 완벽한 독립을 보장할 수 있다.
이런 접근법은 각 테스트가 깨끗한 상태에서 시작하므로 신선한 픽스처Fresh Fixture라 부른다. 신선한 픽스처는 다른 테스트의 픽스처나 미리 만든 픽스처로부터 어떤 것도 '상속'받거나 '재사용'하지 않는다. SUT에 의해 사용되는 모든 객체와 데이터는 '신선'하고, '새롭고', '이전에 사용된 적'이 없다.
- xUnit 테스트 패턴 145 쪽
1회용 신선한 픽스처로 설치형 로컬 데이터베이스를 사용할 수 있겠지만 별도 관리가 필요 없는 메모리 데이터베이스를 사용하는 것이 더 편하다.
예시 코드
코드는 https://github.com/pangpanglabs/echosample 가져왔다. echosample 프로젝트는 Go로 작성한 REST API 샘플 프로젝트이다. 경량형 웹 프레임워크인 Echo와 ORMObject-Relational Mapping 프레임워크로 XORM을 사용한다.
조회 코드 일부를 살펴보자. REST 진입점인 DiscountApiController의 GetOne은 HTTP Path에서 id를 추출하여 Discount에 조회(GetById)를 위임하고 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
//discount_api.go package controllers type DiscountApiController struct { } func (c DiscountApiController) Init(g echoswagger.ApiGroup) { //... g.GET("/:id", c.GetOne).AddParamPath("", "id", "") } func (DiscountApiController) GetOne(c echo.Context) error { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { return ReturnApiFail(c, http.StatusBadRequest, ApiErrorParameter, err) } v, err := models.Discount{}.GetById(c.Request().Context(), id) if err != nil { return ReturnApiFail(c, http.StatusInternalServerError, ApiErrorDB, err) } if v == nil { return ReturnApiFail(c, http.StatusNotFound, ApiErrorNotFound, nil) } return ReturnApiSucc(c, http.StatusOK, v) }
Discount GetById는 XORM을 사용하여 데이터베이스에서 id에 일치하는 데이터를 조회한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
// discount.go package models type Discount struct { Id int64 `json:"id"` Name string `json:"name"` Desc string `json:"desc"` StartAt time.Time `json:"startAt"` EndAt time.Time `json:"endAt"` ActionType string `json:"actionType"` DiscountAmount float64 `json:"discountAmount"` Enable bool `json:"enable"` CreatedAt time.Time `json:"createdAt" xorm:"created"` UpdatedAt time.Time `json:"updatedAt" xorm:"updated"` } // ... func (Discount) GetById(ctx context.Context, id int64) (*Discount, error) { var v Discount if has, err := factory.DB(ctx).ID(id).Get(&v); err != nil { return nil, err } else if !has { return nil, nil } return &v, nil }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// factory.go package factory // ... func DB(ctx context.Context) xorm.Interface { v := ctx.Value(echomiddleware.ContextDBName) if v == nil { panic("DB is not exist") } if db, ok := v.(*xorm.Session); ok { return db } if db, ok := v.(*xorm.Engine); ok { return db } panic("DB is not exist") }
테스트 코드
테스트 코드는 Go test 패키지를 사용하였다. REST를 호출하고 결과 값을 검증한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
// discount_api_test.go package controllers func Test_DiscountApiController_GetOne(t *testing.T) { // given req := httptest.NewRequest(echo.GET, "/api/discounts/1", nil) rec := httptest.NewRecorder() c := echoApp.NewContext(req, rec) c.SetPath("/api/discounts/:id") c.SetParamNames("id") c.SetParamValues("1") // when test.Ok(t, handleWithFilter(DiscountApiController{}.GetOne, c)) test.Equals(t, http.StatusOK, rec.Code) // then var v struct { Result map[string]interface{} `json:"result"` Success bool `json:"success"` } test.Ok(t, json.Unmarshal(rec.Body.Bytes(), &v)) test.Equals(t, v.Result["name"], "discount name2") test.Equals(t, strings.HasPrefix(v.Result["startAt"].(string), "2017-01-02"), true) test.Equals(t, strings.HasPrefix(v.Result["endAt"].(string), "2017-02-02"), true) }
테스트 코드를 실행해 보면 기대와 달리 실패한다. HTTP 상태 코드가 200 OK를 기대했지만 실제로는 404 Not Found 다. 왜 404일까?
위의 테스트 코드가 실행되기 전에 데이터베이스 discount 테이블에 id 칼럼의 값이 '1'인 테스트 데이터가 필요하기 때문이다.
테스트 코드 리펙토링
이 글에서 테스트 픽스처는 크게 두 가지이다.
- 테스트 데이터베이스
- 테스트 데이터
echosample 프로젝트는 테스트 데이터베이스로 이미 SQLite 메모리 데이터베이스를 사용하고 있으며, XORM 스키마 자동 생성 기능을 사용하여 스키마를 생성하고 있다.
1 2 3 4 5 6 7 8 9 10 11 12
// init_test.go package controllers func init() { // 메모리 데이터베이스 xormEngine, err := xorm.NewEngine("sqlite3", ":memory:") if err != nil { panic(err) } // 데이터베이스 스키마 생성 xormEngine.Sync(new(models.Discount)) //... }
테스트 데이터는 어떻게 만들 수 있을까?
XORM과 Discount 직접 사용하여 테스트 데이터를 만들 수 있을 것이다.
1 2 3 4 5 6 7 8 9 10 11
func setUpDiscountFixture() { xormEngine.Transaction(func(session *xorm.Session) (interface{}, error) { discount := models.Discount{ Id: 1, Name: "discount name2", // ... } session.Insert(&discount) return nil, nil }) }
하지만 이렇게 만드는 경우 테스트가 Discount에 의존성을 가지게 된다. 이 말은 Discount 가 변경되면 테스트가 깨질 수 있다는 것을 의미한다.
필자가 선택한 방법은 Go Test Fixtures 라이브러리를 사용해서 테스트 데이터를 만드는 것이다. Go Test Fixtures는 <table_name>.yml 파일에 테이블에 들어갈 데이터를 기술해 주면 데이터베이스 테이블에 자동으로 INSERT해 준다.
먼저 Go Test Fixtures를 아래 명령어로 설치한다.
1
go get -u -v gopkg.in/testfixtures.v2
그리고 testdata 디렉토리[1]를 만들고 안에 discount.yml 파일을 만들고 테스트 데이터를 기술해 준다.
마지막으로 init_test.go 파일에 아래처럼 추가해 준다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// init_test.go package controllers //... func init() { // ... fixtures, err := testfixtures.NewFolder(xormEngine.DB().DB, &testfixtures.SQLite{}, "../testdata/db_fixtures") if err != nil { panic(err) } testfixtures.SkipDatabaseNameCheck(true) if err := fixtures.Load(); err != nil { panic(err) } //... }
일전에 실패했던 아래 테스트 코드는 리펙토링 후 다시 실행해 보면 통과한다.
주석
[1] Go에서는 testdata 디렉토리와 _test 로 끝나는 파일은 빌드에서 제외된다