본문 바로가기
💪 Practice

테스트 코드 작성 시 주의해야 하는 것들

by iirin 2024. 2. 17.

들어가며

이 글은 최근 테스트 작성을 많이 하기 시작하며 염두하기 시작했던 부분들을 잊지 않기 위해 작성해보았습니다.

 

사실 작년까지는 테스트 코드 작성을 정말 최소한으로만 해왔었는데요. 짜고나서 동작을 확인할 때, 테스트하고 싶은 것만 테스트하고 (정말 로직만 확인하는 테스트) 그나마도 구현이 계속되어 테스트를 고쳐야할 때면 가끔 코드를 들어내기도 했습니다.

TDD나 테스트가 좋다고는 하지만 오히려 구현에 발목을 잡히는 것이 아닌가 하는 생각도 들었고요.

 

최근 테스트를 열심히 짜보며 이런 생각을 고치게 되었습니다. "그동안 내가 테스트가 유효할 만큼의 테스트를 짜보지 않아서 잘모르고 있었다, 변경에 취약한 테스트를 짜고 있었다"는 생각이 들어 중간 소결차 글을 써봐야겠다는 생각이 들었습니다. 특히 회사에서 온보딩 교육을 거치며 테스트 관련 조언과 배움을 얻었던 것에서 많이 기반하였습니다. `BAD EXAMPLE`의 경우 실제로 제가 잘못사용한 예시입니다. (그대로 복사하기 좀 그런 경우는 적당히 수정하였습니다 ㅎㅎ)

 

제 나름대로 내용을 소화했기 때문에 옳지 않은 이야기가 있을 수 있습니다. 그럴 경우 부디 댓글로 알려주세요 🙋

 

문득 작년 말에 했던 간단한 외주 프로젝트가 떠오르는데요.

좀 간단하다고 생각되어 부끄럽지만 테스트 코드에 매우 소홀했었는데, 기능 하나가 추가되면서 연쇄적으로 다른 예외들이 터져서 프론트엔드 개발자님이 고생을 많이 하셨어요. 아마 테스트를 차곡차곡 쌓아가기 시작했으면 그렇게까지 연쇄적인 예외는 사전에 방지할 수 있었을 것 같더라고요.

그 때를 반성하며... 😂 간략하게 작성해보겠습니다.

 

 


🤔 테스트 작성시 주의할 점

테스트도 관리 대상입니다

  • 테스트의 수정이 잦은 것은 자연스럽습니다.

테스트가 촘촘히 있으면 가장 힘들었던 부분이 이 주제입니다.

요구사항이 바뀌거나, 뭐하나를 바꿀 때마다 모든 테스트를 수정해주어야 한다는 점이 스트레스였는데요.

그렇기 때문에 지금 내가 테스트를 짜는 방법이 잘못되었나 우려가 많이 되었습니다.

 

하지만, 기능이 바뀌면 연관된 테스트가 바뀐다는 것은 너무나 자연스러운 구현의 과정입니다.

그리고 테스트를 바꾸는 과정에서 구현의 문제점을 발견하는 경험을 하기도 했습니다.

 

코드가 변화에 유연할 수 있도록 구현하는 것이 서비스 유지보수에 유리합니다.

변경해야할 테스트가 과도하게 많다는 것은 유지보수에 불리한 코드라는 뜻이고, 그러므로 연관된 테스트를 관리하는 과정에서 설계상의 장단점이 잘 드러날 수 있습니다.

 

 

이름을 잘 지어야 합니다

  • 테스트를 대표하는 이름(ex. `DisplayName`)은 비즈니스 내용을 포함하도록 작성합니다.
    • 테스트 자체로 문서 역할을 하는 데 큰 도움을 줍니다.
  • 객체의 역할도 잘 표현할 수 있도록 변수 이름을 짓습니다.
    • 예를 들어 `Mock` 과 `Dummy`, `Fake`와 같은 PostFix 도 한눈에 테스트를 읽고 해당 로직을 파악하는 데에 도움을 많이 줍니다.

 

테스트에는 성공 케이스도 같이 작성합니다

  • 목표 동작을 파악하기 좋습니다.
  • 예외를 방지하기 좋습니다.

단위테스트에서 간혹 기능 수정에 의해 가장 기본적인 기능이 깨지거나, 예상치 못한 데이터에서 실패가 날 수 있습니다. 성공 케이스도 엣지 케이스를 생각하여 여러개 작성하면 좋은 이유가 여기서 `예상치 못한`의 범주를 많이 줄여주기 때문입니다.

 

가장 간단한 테스트를 작성합니다

  • 향후 변화에 유연해집니다.
  • 테스트하고자 하는 동작에 집중할 수 있습니다.
  • 테스트를 읽기 편합니다.

하나의 테스트 안에 제어하는 변수는 최소로 하여 가장 작은 단위로 테스트 하는 것이 좋습니다.

예를 들어 `게시글 작성시 제목이 공백이어서는 안됩니다`, ` 게시글 작성시 내용이 공백이어서는 안됩니다`는 별개의 테스트로 작성하는 것입니다.

 

Given-when-then 코드는 서로 섞지 않습니다

  • 테스트의 가독성을 높이고, 시나리오에 필요한 요소를 빼먹지 않도록 해줍니다.

 

BAD EXAMPLE

// given
val data = "sth"

// when
val expect = // sth

// then
assert(expect).isEquals() // sth

// when
val anotherExpect = // sth

// then
assert(expect).isEquals() // sth

 

위에 이어서 가장 간단한 테스트를 작성하면 이런 경우가 나타나지 않습니다.

이는 AAA패턴(Arrange/Act/Assert)에서도 마찬가지입니다. (👉 Given/When/Then 과 AAA 비교 link)

 

 

각 테스트끼리는 관계가 없이 독립적으로 작동할 수 있게 합니다

  • 외부 효과에 의해 테스트의 성공여부가 좌우되지 않습니다.
  • 테스트를 동작 시키는 순서가 중요해서는 안됩니다.

 

BAD EXAMPLE

@Order(1)
fun "할 일을 등록합니다" () {}

@Order(2)
fun "할 일을 조회합니다"() {}

 

 

테스트 구성 요소는 테스트 안에 작성합니다

  • 중복 코드를 최소화하기 위해 전역 변수 혹은 클래스 변수로 테스트 구성요소를 정의하지 않습니다.
  • 테스트를 읽을 때 혼동이 적습니다.
  • 테스트 간의 상태 공유를 방지합니다.

저는 사실 중복코드를 제거하기 위해 `테스트를 해야할 변수`를 테스트 함수 외부에서 정의하는 방법을 여러번 사용했었는데요. 이렇게 되면 

 

BAD EXAMPLE

    val memberKey
    val authorization
    val memberId
    val todoId

    @BeforeEach
    fun setup() {
        restClient = WebTestClient
                .bindToApplicationContext(context)
                .build()

        memberKey = UUID.randomUUID().toString()
        def token = // token 생성
        authorization = "Bearer $token"

        memberId = // member 생성
        todoId = // todo 생성
    }
    
    @Test
    fun test() {
    	// test 내용
    }

 

 

테스트 환경은 테스트 결과에 영향을 미치지 않아야 합니다

  • 환경을 실제 환경과 동일하게 하지만, 그 환경이 테스트 결과에 영향을 미치도록 짜서는 안됩니다.

테스트하는 조건과 환경은 다릅니다.

테스트하는 조건이 `테스트 하고자하는 변수`라면 환경은 애플리케이션의 context, properties 등을 말합니다.

 

 

미리 주입하는 데이터에 테스트가 의존하지 않아야 합니다

여기서 미리 주입하는 데이터란, sql query 등으로 insert 해두는 더미 데이터를 뜻합니다.

그러나 비즈니스 테스트의 경우, 테이블이 많고 관계를 매번 세팅하기 어려워 미리 데이터를 만들어두기도 합니다.

 

  • 테스트 하나하나마다 독립적인 테스트 데이터를 가지고 있는 것이 좋습니다.
  • 테스트 구성요소는 랜덤하게 생성되어야 합니다.
  • 테스트 통과를 위해 매직 넘버를 사용하지 않습니다.

 

빠른 테스트를 할 수 있도록 합니다

테스트에 불필요한 context는 제거하여 테스트 실행 및 검증이 빨라지도록 합니다.

매번 구현&리팩토링 시마다 테스트를 실행시켜야 하기 때문에 특히나 단위 테스트는 빠르게 결과를 확인할 수 있으면 경제적입니다.

해당 주제 관련해서 우연찮게 괜찮았던 영상이 있어 같이 첨부합니다. 👉 토스 | 테스트 커버리지 100%

 

 

매직 넘버, 매직 문자열을 지양합니다

  • 변경에 취약합니다.
  • 코드를 통과시키기 위한 변수로 사용할 수 있습니다.

변경에 취약한 것은 예를 들자면 기능 요구사항의 기준이 달라졌을 경우 같은 것이죠. 이부분은 이 글을 읽고 있는 여러분 모두가 잘 아실 것이라 생각합니다.

 

두번째 이유에 대해서 더 얘기하려고 이 소제목을 썼는데요.

제가 들었던 테스트에 대한 조언 중에 가장 인상깊었던 말이 "테스트만 통과하도록 데이터를 바꾸는 것은 쉬워요" 였습니다.

실제로 제가 그렇게 해왔거든요 😭

 

최대한 내가 테스트 하고자 하는 조건을 생각해보고, 그 조건에 대한 변수를 생성하여 매직 넘버를 지양하려고 노력해보고 있습니다.

 

 

BAD EXAMPLE

//given
def tooShortId = "a"

 

 

BETTER EXAMPLE

//given
def tooShortId = Generator.nextString(MIN_LENGTH - 1)

 

 

버그에 대한 테스트를 남겨둡니다

아무리 테스트를 잘 해서 배포해도 결국 버그가 일어나기 쉽습니다.

애써 테스트를 짜려 노력하지 않아도 이렇게 남은 테스트들은 계속 누적되어 앞으로의 어플리케이션의 안정성에 도움이 됩니다. 계속해서 관리해주는걸로!

 

그리고 왜 코드가 이렇게 작성되었는지 설명해주는 역사(..)가 되기도 하지 않을까요? ㅎㅎ 이건 한 서비스를 오래 운영해본 적이 없어서 잘 모르겠습니다.

 

 


👍 테스트를 작성해서 좋았던 점

  • 동작을 설명해주는 문서 자체의 역할을 합니다.
    • 해당 로직이 어떤 역할을 하는지, 어떤 변수가 있는지 파악하기 좋습니다.
    • 테스트를 실행시켜 해당 로직의 호출을 즉시 할 수 있어 이해가 쉽습니다.
  • 해당 도메인의 변수와 도메인간 관계를 파악하기가 좋습니다.
  • 코드 구현의 안전망이 되어줍니다.
    • 사이드 이펙트를 파악하기 좋아 리팩터링과 기능 구현에 안정감을 줍니다.
  • 버그 배포를 방지할 수 있습니다.

테스트는 프로젝트 구현 초기보다는 점점 서비스가 고도화될 때 더 빛을 발합니다.

파악해야 할 코드도 많고, 복잡하게 얽인 도메인 로직에서 사이드 이펙트가 많이 일어날 수 있기 때문입니다.

하지만 테스트를 한꺼번에 많이 작성하기 힘드므로 초반부터 꾸준히 쌓아두는 것이 좋습니다.

 


🧪 TDD를 해야할까?

그럼 테스트를 잘 짜기 위해 꼭 TDD를 해야할까? 하는 질문에는 저의 상황에서는 아직까지는 잘 모르겠습니다 🧐

 

아직 제가 개발 경험이 적기 때문인지 테스트할 로직이 매우 간단한 경우가 많았기 때문이에요.

혹은 설계의 미숙함으로 테스트를 작성하고 구현을 하다보면 반드시 테스트를 끊임없이 다시 수정하게 되더라고요.

API 테스트의 경우 문서처럼 작성하면 된다하더라도 단위 테스트의 경우 실패사례를 생각하는 데에 한계가 있어서 테스트를 계속 추가하기도 하고요.

 

이렇게 되다보니 아무것도 없는 상태에서 테스트를 짜는 것이 너무 시간이 많이 걸려서 차라리 먼저 기능을 간단하게 구현해보고 실패 사례에 대한 테스트를 계속 추가하면 되지 않을까? 라는 생각이 많이 들었어요.

 

하지만 연습과 설계도를 그리는 마음으로 미리 테스트를 짜곤합니다.

먼저 짜는 것의 좋은 점도 분명 있습니다.

중간에 어디까지 짜야할지 가늠이 좀 더 잘되고, 해당 객체가 어떤 역할을 하는지 미리 고민하게 되더라고요.

이렇게 TDD의 방식으로 테스트를 미리 짜고 구현을 한다면, 좀 더 객체지향적이고 간결한(SRP) 코드를 짜는데 도움이 되는 것 같습니다.

 

결국 테스트 작성 시간 더 견고하고 객체지향적인 초기 코드의 트레이드 오프라고 생각해요.

저의 경우 아직까지는 잃을 것이 많은 선택인 것 같습니다 😂

 

 

학습에 도움이 되었던 컨텐츠들

혹시 테스트 코드 작성이 불편하고 어렵나요? 그렇다면 이는 구현 코드의 품질과 구조에 대한 피드백일 수 있습니다. 그 피드백을 반영해 주세요.