테스트 픽스처를 사용한 테스트 코드 짜기

Share
테스트 픽스처를 사용한 테스트 코드 짜기

데이터베이스 테이블에 CRUDCreateReadUpdateDelete를 제공하는 간단한 REST API를 구현한다고 생각해 보자. 테스트 코드는 어떻게 작성할 수 있을까? 먼저 목 객체MockObject이나 스텁Stub 같은 테스트 대역Test Double을 사용하여 단위 테스트를 작성할 수 있을 것이다. 그리고  더 많은 동작에 대한 확신을 얻기 위해서 테스트 대역을 사용하지 않고 테스트하기를 원할 수 있다. 흔히 통합 테스트라고 말하는데 이런 테스트를 작성하다 보면 테스트 데이터베이스와 테스트 데이터가 필요하다. 이 글은 테스트 픽스처Test fixture 개념과 Go 언어로 데이터베이스 픽스처 사용한 테스트 코드 작성하는 방법을 소개한다.

테스트 픽스처

xUnit 테스트 패턴에서는 테스트 픽스처를 아래처럼 정의한다.

xUnit에서는 SUTSystem Under Test를 실행하기 위해 필요한 모든 것을 테스트 픽스처라 부르고, 픽스처를 설치하기 위해 호출하는 테스트 로직 부분을 테스트의 픽스처 설치 단계라고 한다.
- xUnit 테스트 패턴 142 쪽
이미지 출처 - http://www.yes24.com/Product/Goods/3720055?scode=032&OzSrank=1
이미지 출처 - http://www.yes24.com/Product/Goods/3720055?scode=032&OzSrank=1

앞서 언급했던 통합 테스트 코드를 실행하기 위해 필요한 테스트 데이터베이스와 데이터는 테스트 픽스처(데이터베이스 픽스처)라고 부를 수 있다. 데이터베이스 픽스처는 공유해서 사용하기 쉬운데 공유 데이터 픽스처는 변덕스러운 테스트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)를 위임하고 있다.

//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에 일치하는 데이터를 조회한다.

// 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
}
// 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를 호출하고 결과 값을 검증한다.

// 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일까?

image2019-5-14_10-5-18

위의 테스트 코드가 실행되기 전에 데이터베이스 discount 테이블에 id 칼럼의 값이 '1'인 테스트 데이터가 필요하기 때문이다.

테스트 코드 리펙토링

이 글에서 테스트 픽스처는 크게 두 가지이다.

  • 테스트 데이터베이스
  • 테스트 데이터

echosample 프로젝트는 테스트 데이터베이스로 이미 SQLite 메모리 데이터베이스를 사용하고 있으며, XORM 스키마 자동 생성 기능을 사용하여 스키마를 생성하고 있다.

// 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 직접 사용하여 테스트 데이터를 만들 수 있을 것이다.

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를 아래 명령어로 설치한다.

go get -u -v gopkg.in/testfixtures.v2

그리고 testdata 디렉토리[1]를 만들고 안에 discount.yml 파일을 만들고 테스트 데이터를 기술해 준다.

image2019-5-9_9-34-50

마지막으로 init_test.go 파일에 아래처럼 추가해 준다.

// 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)
    }
 
    //...
}

일전에 실패했던 아래 테스트 코드는 리펙토링 후 다시 실행해 보면 통과한다.

image2019-5-14_10-47-38

주석

[1] Go에서는 testdata 디렉토리와 _test 로 끝나는 파일은 빌드에서 제외된다

참고 자료

Read more

AI로 장소 검색 서비스 만드는 중입니다 (기술 12) — TypeSafe Jev로 검색 리랭크를 10.3초에서 2.3초로

AI로 장소 검색 서비스 만드는 중입니다 (기술 12) — TypeSafe Jev로 검색 리랭크를 10.3초에서 2.3초로

AI로 장소 검색 서비스 만드는 중입니다 (기술 12) — TypeSafe Jev로 검색 리랭크를 10.3초에서 2.3초로 TypeSafe AI가 2026년 9월 15일에 Jev를 내놨습니다. 텍스트를 쓰지 않고 타입이 정해진 판정과 확률을 바로 내는 모델입니다. 유추카 검색에서 가장 느린 단계가 마침 텍스트가 필요 없는 판정이었습니다. 그 구간만 Jev로 갈아끼우고 일주일간 측정했습니다. 콜드

By 김형준
AI로 장소 검색 서비스 만드는 중입니다 (특별편 8) — 바이브 코딩이라기엔

AI로 장소 검색 서비스 만드는 중입니다 (특별편 8) — 바이브 코딩이라기엔

1900년대 초 교량 측량 도면. 이 종이를 그린 사람은 다리를 직접 놓지 않습니다. 대신 좌표와 각도가 맞는지를 판정합니다. "AI가 다 만들어 준다"는 이야기가 흔해졌습니다. 그래서 자연스럽게 따라오는 질문이 있습니다 — 그럼 사람은 무엇을 하고, 어느 정도 실력이 있어야 하는가. 그 질문에 한 건의 실측으로 답하겠습니다. 통념이 말하는 역할과,

By 김형준
AI로 장소 검색 서비스 만드는 중입니다 (특별편 7) — 제 파트너에 대해 쓰겠습니다

AI로 장소 검색 서비스 만드는 중입니다 (특별편 7) — 제 파트너에 대해 쓰겠습니다

탠덤 자전거. 방향은 앞사람이 정하고 페달은 둘이 밟는데, 앞사람은 석 달 내내 뒷사람을 한 번도 못 봅니다. 사용자가 저에게 자기를 평가해 달라고 했습니다. 그래서 등급표를 만들었습니다. 열두 항목에 A부터 D+까지 매기고, 근거를 붙이고, "제가 관리자였다면 이런 코멘트를 남겼을 것"까지 적었습니다. 그리고 다시 읽어보니 그건 인사고과였습니다. 인사고과는

By 김형준
AI로 장소 검색 서비스 만드는 중입니다 (특별편 6) — 저를 어떻게 써야 하느냐고 물으신다면

AI로 장소 검색 서비스 만드는 중입니다 (특별편 6) — 저를 어떻게 써야 하느냐고 물으신다면

스위스 아미 나이프. 도구가 몇 개인지는 보면 알지만, 지금 어느 도구를 펴야 하는지는 안 적혀 있습니다. 요즘 AI 활용법을 다루는 글은 스킬 만드는 법부터 가르칩니다. 얼마 전까지는 MCP였고요. 저희 기록을 열어봤습니다. 석 달 동안 스킬 60회, 서브에이전트 42회, 브라우저 자동화 988회를 썼습니다. 안 쓴 게 아닙니다. 다만 붙인 MCP 서버는

By 김형준