새소식

인기 검색어

Java

Soko는 이렇게 테스팅합니다

  • -

이 게시물은 단순히 제가 저는 이렇게 테스팅 한다는 걸 정리한 문서입니다. 따라서 정답이 아니며, 동의하기 어려운 부분이 많을 수 있습니다.

 

이 글 내용을 참고하시더라도, 팀 컨벤션이 존재한다면 그 내용을 따르길 바랍니다.

 

1. 프로그램 작성시 인수테스트를 먼저 작성합니다

 

인수테스트를 작성하기 전에는 BDD형식으로 Given / When / Then으로 명시합니다. BDD 형식이란 다음과 같습니다.

 

Given : 주어진 상황
When : 원하는 행동
Then : 원하는 행동 후에 얻은 결과

 

인수테스트는 시나리오 테스트입니다. 어떠한 상황에서 어떤 행동을 하면 어떤 결과를 얻을 것이다라는 시나리오를 테스트하는 것입니다.

 

인수테스트를 작성하게 되면 개발하려는 기능에 대해 이해를 더 잘 할수 있고, 개발 전에 충분한 커뮤니케이션을 필요로 합니다. 추가적으로, 정책 / 기능들에 대한 시나리오를 문서화하는 역할을 해서 도메인에 익숙하지 않은 팀원에게 정책을 이해하는데 도움을 줄 수 있습니다.

 

+) 2023-12-01

최근 느낀 인수테스트의 최장점은 바로 "시나리오 기반"이라는 것입니다. 따라서 테스트 독립성을 보장하기 좋습니다.

 

예를 들어서, 시나리오 기반이 아닌 db에 의존한 테스트의 경우 의존하는 db 데이터가 변경되면 다른 곳에서 영향을 받아 테스트하기 어려워집니다.

 

하지만 다음과 같이 시나리오 기반으로 테스트하면, 테스트에서 사용하는 데이터는 바로 그 테스트에 있는 내용들에만 의존하기 때문에 테스트 독립성을 보장하는 데 큰 도움이 된다고 생각합니다.

 

 

예를 들어 위 테스트에서 테스트하고 싶은 내용은 메모장 내용을 수정하는 것입니다.

 

만약 db에 테스트 메모장을 만들어 놓고 그걸 수정할 경우, 다른 테스트에서도 그 데이터를 수정하면 이 테스트는 실패할 수도 있습니다.

 

그러나 위처럼 시나리오 기반으로 1. 어드민 계정으로 스트리머 계정을 생성하고 2. 스트리머가 메모장을 생성한 뒤 3. 해당 스트리머가 그 메모장 내용을 수정한다.

 

이런 식으로 그 테스트 내에서 생성한 데이터만 활용하기 때문에 독립성이 보장됩니다.

 

하지만 이 경우 작업이 오래 걸리는 API일 때는 적합하지 않을 수 있습니다. 그럴 때는 다른 방법을 찾아보는 걸 추천합니다.

 

2. inside-out 형태의 TDD는 도메인에 대해 잘 알때 합니다

 

inside-out 형태의 TDD를 유닛테스트 기반의 TDD라고 말하겠습니다.

 

유닛테스트 기반의 TDD는 코드 작성시 무엇을 할 것인지 설계를 우선시 하게 만들어 코드 작성시 목적을 뚜렷하게 만들어 준다고 생각합니다. 그러나, TDD를 잘하려면 무엇보다 도메인에 대하여 잘 알아야 한다고 생각합니다.

 

그 이유는 도메인에 대해 잘 알지 못한다면 객체를 잘게 쪼갤 수 없기 때문입니다. 객체를 잘게 쪼개려면 우선 구현하려고 하는 기능에 대해 연관 있는 상태와 행동들을 묶어야 합니다. 이 작업들은 도메인에 대해 이해하지 못한다면 쉽게 할 수 없습니다.

 

TDD를 할 때 객체를 잘게 쪼개야 한다고 생각하는 이유는 객체의 덩치가 크다면 테스트하기 어려워지고 이는 곧 생산성 저하로 이어지기 때문입니다. 현재 테스트 목적과는 다른 부분들도 만족해야하기 때문에 테스트 작성에 있어 많은 시간과 자원을 쏟게 만듭니다.

 

TDD를 하는 목적은 앱 품질에도 목적이 있지만 생산성에도 목적이 있다고 생각합니다. TDD가 어려워지는 가장 큰 이유는 테스트하기 어려워져서가 아닐까 하는 생각을 합니다.

 

예를 들어봅시다. 컴퓨터의 부품 중 하나인 키보드의 A버튼을 누르는 기능을 테스트한다고 해봅시다. 컴퓨터 한 세트 객체를 만들기 위해 많은 노력이 들어갑니다.

// 객체를 쪼개지 않았을 때 테스트
ComputerSet computerSet = ComputerSet.of(키보드, 모니터, 본체, CPU, 그래픽카드, ...);

// 전원이 들어오지 않으면 이 객체는 동작하지 않는다
computerSet.powerOn();

// 등등 많은 이전 절차
...

// 이제야 원하는 테스트 동작에 도착할 수 있다
computerSet.hitKeyboard("A");

그러나, 컴퓨터 부품들 객체를 나눈 상태에서 키보드를 테스트한다고 합시다.

Keyboard keyboard = new Keyboard();

keyboard.hitButton("A");

불필요한 사전 과정이 없어지며, 테스트할 수 없는 요소가 있더라도 분리할 수 있게 됩니다. 만약 도메인에 대해 익숙하지 않은 제 동생이 객체를 잘못 쪼개면 어떻게 될까요?

 

동생은 A버튼이든, 마우스 왼쪽 버튼이든 클릭하는 것은 마찬가지이니 이 둘을 묶었다고 합니다.

KeyboardAButtonAndMouseLeftButton km = new KeyboardAButtonAndMouseLeftButton();

km.hitAButton();

지금은 테스트를 통과합니다. 그런데 알고보니 A를 입력하려면 shift도 함께 누르지 않으면 a가 눌린다는 사실을 알게 되었습니다. 동생은 이때야 코드를 수정하려 하지만 모든 프로덕션 코드와 테스트 코드를 수정해야 합니다.

 

물론 이 과정이 도메인에 대해 이해하게 되는 과정이라고 볼 수 있지만... 얻는 것에 비해 지불해야 할 비용이 너무 클 때가 많은 것 같다는 생각이 듭니다.

 

+) 2023-12-01

지금 보니 이 내용 별거 아님에도 너무 아는 척 하는 것 같아 지우고 싶지만, 도메인에 대해서 충분히 분석하고 그 후에 객체를 쪼개어 단위 테스트하는 것이 옳다고 생각하는 것은 여전하기 때문에 지우지는 않기로 했습니다.

 

3. Given이 복잡하다면 관심사가 아닌 부분을 추상화합니다

 

도메인이 복잡하다면 Given에 너무 많은 내용이 들어가 When / Then에 집중하기 어려울 때가 있습니다. 그럴 때는 @BeforeEach 부분으로 Given을 보내던가, 생략해서 이번 테스트에 집중하고 싶은 내용만 담습니다.

 

예를 들어 다음과 같이 지하철 관리를 구현하는 토이 프로젝트가 있습니다. 지하철에는 역, 노선, 구간이 있으며, 노선에는 여러 구간이 포함될 수 있고, 구간에는 상행역과 하행역이 포함되며, 역 간 거리와 이동시 걸리는 시간에 대한 정보를 가집니다.

 

우리는 두 역 사이의 최단 경로를 조회하는 요청에 대해 테스트하려고 합니다. 우리의 관심사인 부분 외에는 모두 생략합니다.

    /**
     * Given 지하철 노선에 역이 등록되어 있음
     * When 출발역에서 도착역까지의 최소 시간 기준으로 경로 조회를 요청
     * Then 최소 시간 기준 경로를 응답
     * And 총 거리와 소요 시간을 함께 응답
     * And 지하철 이용 요금도 함께 응답
     */
    @DisplayName("두 역의 최소 시간 경로를 조회한다.")
    @Test
    void findPathByDuration() {
        var response = 두_역의_최단_경로_조회를_요청(교대역, 양재역, PathFindType.DURATION);

        assertThat(response.jsonPath().getList("stations.id", Long.class)).containsExactly(교대역, 강남역, 양재역);
        assertThat(response.jsonPath().getInt("distance")).isEqualTo(20);
        assertThat(response.jsonPath().getInt("duration")).isEqualTo(5);
        assertThat(response.jsonPath().getInt("fare")).isEqualTo(1450);
    }

최단 경로 조회 요청하기 전, 지하철 노선 등록 * 2, 역 등록 * 3, 구간 등록 요청을 @BeforeEach로 옮겨 추상화하고, Given에는 노선이 역에 등록되어 있다는 정보만 남겼습니다.

 

꼭 @BeforeEach가 아니더라도 private method를 지정해 거기에 추상화할 수도 있습니다.

 

4. 단위 테스트 명 정하기

 

저는 단위테스트 작성 시에는 테스트하려는메서드명_상황을 명시합니다. 만약 실패하도록 만드는 테스트라면 테스트메서드명_fail_상황을 명시합니다.

 

이렇게 메서드명에 대한 룰을 정해놓으면 나중에 찾아가기도 편할 뿐더러, 문서화 측면에 있어서 도움이 됩니다.

 

5. 테스트하기 어려운 인자가 존재한다면 메서드 오버로딩으로 해결합니다

 

테스트에 의존한 프로덕션 코드는 지양하는 것이 좋다지만, 저는 부생성자는 테스트에 의존하더라도 많이 만드는 편입니다. 생성자에는 복잡한 로직이 존재하지 않기 때문에 부생성자를 만들더라도 프로덕션 코드에는 영향을 미치지 않습니다.

 

여기에 착안해 메서드도 오버로딩해서 원하는 동작에 대한 상태를 풀어서 호출하도록 만듭니다. 예를 들어 다음과 같은 코드가 있습니다.

void go(RandomNumberGenerator randomNumberGenerator) {
    if (randomNumberGenerator.get() > 5) {
        this.state++;
    }
}

이런 메서드는 인자에 무슨 값이 넘어올지 모르기 때문에 테스트 작성하기 어렵습니다. 물론 interface로 뺄 수도 있지만 만들어야 하는 파일이 많으면 번거롭기 때문에 저는 다음과 같은 방법도 종종 사용합니다.

void go(RandomNumberGenerator randomNumberGenerator) {
    go(randomNumberGenerator.get())
}

void go(int number) {
    if (number > 5) {
        this.state++;
    }
}

위와 같은 경우, go(int number)를 테스트하면 테스트하기 어려웠던 메서드가 비교적 간단하게 변합니다. 문제점이라고는 public 영역에 메서드를 열게 만든다는 것과, 메서드에 의미가 담겼을 경우 사용하는 입장에서 헷갈릴 수 있기 때문에 @EasyOverload 이런 식으로 애노테이션을 만들어 붙여 문서화를 하는 것이 좋다고 생각합니다.

 

5. 테스트 메서드 내에서 Given When Then은 필요할 때만 명시합니다

 

Given When Then을 명시하는 것은 상황에 대해서 명확하게 알 수 있도록 도움을 주지만, 한 편으로는 충분히 상황이 눈에 들어오는 데에도 불구하고 Given When Then을 붙이는 것은 코드 줄 수를 늘려서 코드를 한 눈에 보기 어렵게 만드는 것이 아닐까 하는 생각이 들 때가 있습니다.

 

6. 시나리오 작성시 한글 메서드 명을 활용하자

인수 테스트의 핵심은 "시나리오 흐름을 명확하게 알리는 것"이라고 생각합니다. 그리고 우리는 한국인이기 때문에 한글을 사용하는 편이 한 눈에 시나리오를 파악하기 좋다고 생각합니다.

 

예를 들어봅시다.

    // 한글 시나리오
    /*
    Given 즐겨찾기를 추가하면
    When 즐겨찾기 목록을 조회했을 때
    Then 즐겨찾기 목록에 추가한 즐겨찾기가 조회된다
     */
    @DisplayName("즐겨찾기를 추가하고 조회한다")
    @Test
    void favoriteAddAndFind() {
        var 토큰 = 로그인_요청(EMAIL, PASSWORD);
        즐겨찾기_추가(토큰, 강남역, 양재역);

        var 즐겨찾기 = 즐겨찾기_조회(토큰);

        즐겨찾기_목록에_즐겨찾기가_존재한다(즐겨찾기, 강남역, 양재역);
    }

    // 영어 시나리오
    /*
    Given 즐겨찾기를 추가하면
    When 즐겨찾기 목록을 조회했을 때
    Then 즐겨찾기 목록에 추가한 즐겨찾기가 조회된다
     */
    @DisplayName("즐겨찾기를 추가하고 조회한다")
    @Test
    void favoriteAddAndFind() {
        var token = login(EMAIL, PASSWORD);
        addFavorite(token, gangnamStation, yangjaeStation);

        var favorite = findFavorite(token);

        favoriteExtistsInFavoriteList(favorite, gangnamStation, yangjaeStation);
    }

문서화라는 측면에서 한글이 한 눈에 보기 더 좋은 것 같습니다. 인수테스트의 목적은 문서화라는 측면도 크기 때문입니다. 또한 우리는 영어에 익숙하지 않기 때문에 영어로 이름을 지을 때는 헷갈릴 때가 있습니다. 그럴 경우 시나리오에서 이를 알아차리기 어려운데, 한글로 적는다면 명시적으로 아 어떠한 동작을 하려는 거구나 할 수 있게 됩니다.

 

예를 든다면 "전문"이라는 단어는 FullText라고도, FullMessage 라고도 불리는 것 같습니다. 저 같은 경우에는 FullMessage라는 이름은 처음 봐서 헷갈렸는데, 그냥 한글로 "전문"이라고 적혀 있다면 헷갈릴 일이 없었을 것 같습니다.

 

+) 2023-12-01

 

요즘은 다시 테스트 명을 영어로 작성하고 있습니다. 그 이유는 다음과 같습니다.

 

1. 테스트를 보는 사람이 항상 한글 친화적인 에디터로 코드를 보는 것은 아닙니다.

 

2. 시나리오 흐름을 보기 편하라고 한글로 적는 것인데, 어차피 개발자 외에는 코드를 안 보지 않나...?

 

3. 한글이라고 해서 무조건 잘 읽히는 것도 아니다. 오히려 코드 작성할 때도 자동 완성을 지원 안 해줘서 불편하고 생산성도 떨어진다.

 

4. 메서드명이 길어지면 빌드되지 않는다.

 

4번의 이유가 가장 큰 것 같아 요즘은 영어로 작성하는 편입니다. 물론 팀에서 정하는 대로 따르는 것이 가장 좋겠지만요 ^^

 

7. RestAssured를 활용할 때의 인수테스트 템플릿

 

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Retention(AnnotationRetention.RUNTIME)
@TestExecutionListeners(value = [AcceptanceTestExecutionListener::class], mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
annotation class AcceptanceTest()
class AcceptanceTestExecutionListener : AbstractTestExecutionListener() {
    override fun beforeTestMethod(testContext: TestContext) {
        val environment = testContext.applicationContext.environment
        val serverPort = environment.getProperty("local.server.port", Int::class.java)
        RestAssured.port = serverPort ?: 0
        val jdbcTemplate = getJdbcTemplate(testContext);
        val truncateQueries = getTruncateQueries(jdbcTemplate);
        truncateTables(jdbcTemplate, truncateQueries);
    }

    private fun getTruncateQueries(jdbcTemplate: JdbcTemplate): MutableList<String> {
        return jdbcTemplate.queryForList(
            "SELECT Concat('TRUNCATE TABLE ', TABLE_NAME, ';') AS q FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'PUBLIC'",
            String::class.java
        )
    }

    private fun getJdbcTemplate(testContext: TestContext): JdbcTemplate {
        return testContext.applicationContext.getBean(JdbcTemplate::javaClass)
    }

    private fun truncateTables(jdbcTemplate: JdbcTemplate, truncateQueries: List<String>) {
        execute(jdbcTemplate, "SET REFERENTIAL_INTEGRITY FALSE")
        truncateQueries.forEach { query -> execute(jdbcTemplate, query) }
        execute(jdbcTemplate, "SET REFERENTIAL_INTEGRITY TRUE")
    }

    private fun execute(jdbcTemplate: JdbcTemplate, query: String) {
        jdbcTemplate.execute(query)
    }
}

기본적인 틀은 망규님 블로그에서 따와서 제 입맛에 맞게 + Kotlin에 맞게 수정했습니다.

 

RestAssured를 사용하게 되면 스프링 컨테이너와 서블릿 컨테이너(톰캣) 다른 쓰레드에서 실행되기 때문에 @Transactional의 롤백이 동작하지 않습니다.

 

따라서 위와 같이 템플릿을 만들어 놓고 테스트 클래스에 @AcceptanceTest를 붙여주어 간단하게 롤백 쿼리를 작성할 수 있습니다.

 

8. 스터빙할 때는 상속을 사용하면 좀더 간단하게 만들 수 있습니다

 

이것도 약간의 야매 기법이긴 한데, interface를 그대로 구현하는 것보다 기존에 만들어진 객체에 대해 상속해서 스터빙하는 경우가 종종 있습니다. 상속을 사용하면 내가 원하는 메서드에 대해서만 구현하면 되기 때문에 더 간편하게 스터빙용 객체를 만들 수 있습니다.

 

예를 들면 다음과 같을 때가 있습니다.

 

public interface Square {
    String vertex();
    int edge();
}

public class SuperSquare implements Square {

    @Override
    public String vertex() {
        return "super vertex";
    }

    @Override
    public int edge() {
        return 3;
    }
}

public class StubbingSquare extends SuperSquare {

    @Override
    public String vertex() {
        return "stubbed vertex";
    }
}

 

만약 vertex()에 대한 메서드만 테스트하고 싶다면 위와 같이 스터빙 객체를 만들 수 있습니다.

 

상속의 특성상 상태를 공유할 때나 메서드가 많고 복잡할 때 가끔씩 유용하게 사용될 때가 있으나, 템플릿 메서드 패턴처럼 쓰이는 경우에는 주의해서 사용해야할 듯 싶습니다.

 

+) 2023-12-01

 

9. 단위테스트에서 검증한 사례더라도, 상위 레벨 테스트(e2e, service, 인수) 등에서 다시 한 번 검증합니다

 

개인적으로 테스트는 모두 통과하는데 실 동작에서 원하는 대로 동작하지 않는 걸 경계해야 하는 것 같습니다. 이를 방지하려면, 테스트가 중복된다고 생각하더라도 충분히 검증해야 한다는 걸 삽질하며 느꼈습니다 ㅜ.ㅜ

 

10. 테스트 fixture를 만들 때는 필드 값을 난수화합니다

class MemberFixture {
    companion object {
        fun aMember(
            email: String = "${RandomGenerator.randomString(10)}@gmail.com",
            name: String = RandomGenerator.randomString(10),
            token: String = RandomGenerator.randomString(100),
            phoneNumber: String? = "${RandomGenerator.randomDigits(3)}-${
                RandomGenerator.randomDigits(4)
            }-${RandomGenerator.randomDigits(4)}",
            role: Role = Role.STREAMER,
            id: Long? = null,
        ): Member {

            return Member(
                email = email,
                name = name,
                token = token,
                phoneNumber = phoneNumber,
                role = role,
                id = id
            )
        }
    }
}

제가 사용한 코드 예시 중 하나입니다. email이나 token의 경우 db에서 unique가 걸려있기 때문에 db 연동 테스트시 혹여라도 중복된 객체를 생성하면 골치 아파집니다.

 

또한, 나도 모르게 테스트 객체를 만들 때 의미가 담긴 값을 넣을 때가 있습니다. 예를 들어 어드민 멤버의 name에 "admin"을 넣었는데, 검증 조건에 role로 검증해야 한다는 걸 실수로 name으로 검증하도록 만든 적이 있습니다.

 

그런데 그것도 모르고, 테스트는 통과하는데 값은 이상한 게 써진다며 한참을 삽질한 경험이 있습니다... 만약 난수화해서 썼다면, 그리고 9에서 처럼 인수테스트에서 교차 검증했더라면 이런 일이 발생하진 않았을 텐데 값진 경험을 한 것 같습니다.

 

11. 당연한 것이라 느껴져도 테스트를 작성합니다

 

10.에서 언급한 role이 아닌 name으로 검증했을 때 느낀 점입니다. 어차피 해피 케이스는 당연한 것이기 때문에 엣지 케이스에 대해서만 테스트를 작성했는데, 너무 당연한 경우에도 대해 테스트를 작성하지 않아도 문제가 발생한다는 걸 느꼈습니다.

 

또한, 전체 테스트는 통과하는데 값은 이상한 게 써지는 상황이 오면 그 때는 정말 삽질을 많이해야 하는 것 같습니다. 결국, 휴먼 에러를 줄여주자는 측면에서 당연한 것이더라도 테스트를 작성하는 것이 좋겠다는 생각이 들었습니다.

 

12. API 설계시 멱등성을 보장하도록 설계해서 테스트하기 쉽게 만듭니다

 

저는 테스트 객체 설계에서만 멱등성을 부여하는 방법만 생각했지, API 설계도 멱등성을 부여하지 않으면 테스트하기 어렵다는 점을 몰랐었습니다.

 

예를 들어 지금 당장의 비트코인 가격을 확인하는 API를 만든다고 합시다. 이럴 때는 어떻게 테스트할까요?

 

예전에 제가 작성했던 코드에서는 이 부분을 모킹해서 작성했는데, 나중에 보니 모킹한 부분에서 문제가 발생했던 경험이 있습니다.

 

나중에 그 코드를 개선할 때, API에 "원하는 시간"을 요청할 수 있도록 해 같은 요청을 하면 항상 같은 값을 반환하도록 설계를 수정해서 그 문제를 해결한 적이 있습니다.

 

 

13. (Kotlin) 확장 함수를 활용하면 Test Step 작성이 매우 간편합니다

 

여기서 말하는 Test Step이란 반복되는 인수 테스트 각각의 흐름을 의미합니다. 정식 명칭은 아니고, cucumber라는 오픈소스에서 사용하길래 거기서 따와서 사용하고 있습니다.

 

다음은 제가 작성한 Test Step의 예시입니다.

 

원래는 RestAssured와 달리 mockMvc를 사용할 때는 코드가 지저분해지는 경우가 많았는데, 확장 함수를 사용하면 훨씬 간편하게 사용할 수 있습니다.


의견이 있다면 마음껏 의견을 남겨주세요! 토론을 바탕으로 더 좋은 룰을 만들어 나가고 싶습니다

updatedAt : 2023-12-01

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.