Tech Note

테스트 코드는 어떻게 만들까

Samgim 2021. 3. 18. 15:15

신입으로 입사하신 분들이 테스트 코드를 어떻게 만들어야 하는지를 종종 물어보셔서,

공유도 하고 내 머리 속에 정리도 할 겸 이 글을 써본다.

 

 

이 글은 유닛 테스트 코드를 많이 만들어보신 분들이 읽을 만한 글은 아니다.

신입으로 입사하신 분들이 테스트 코드를 처음 접할 때 당황스러움을 느낄 수 있는데, 그 때 도움이 될 수 있을 것이다.

나 역시 TDD를 처음 접했을 때는 그랬고,

어떻게 해야 테스트 코드를 잘 짜는 건지는 다른 사람의 코드를 보지 않으면 모를 수도 있다고 생각한다.

테스트 코드가 필요하다는 것만 잘 알고 있으면, 테스트 코드를 잘 짜는 방법은 배워나가면 된다.

다른 사람이 짜 놓은 테스트 코드를 보면 금방 깨달을 수 있는 문제이기도 하니 너무 겁먹지 않았으면 한다.

 

다만, 어떻게 해야 '잘' 만드는 것인지는 시니어라도 보는 관점에 따라 다를 뿐만 아니라 완벽한 방법이라는 것은 없다.

이 짧고 모자란 글에만 의존하지 말고, 테스트 코드에 관한 책을 참조하거나 주변에 있는 능력자들에게 조언을 얻는 등 능숙해지려고 노력하면 점점 좋아질 것이다.

마찬가지로 능력자분들이 이 글을 보고 틀린 점이 있거나 부족하다고 생각하시면 피드백 주실 것이라고 믿어본다.

 

 

1. 기본적인 테스트 코드 작성

먼저, 유닛 테스트 코드란 함수 단위로 테스트하는 것을 기본으로 한다.

어떤 함수가 정상적으로 동작하는지, 그렇지 않을 때는 어떻게 오류(Exception 이나 에러 코드, 에러 메시지 등)을 내는지를 확인한다.

JUnit 이나 Enzyme 등이 이러한 테스트를 지원한다.

각 툴들의 사용법은 해당 문서나 사용법을 올린 글들이 많이 있으니 이 글에서 다루지 않을 예정이다. 

아래와 같이 단순한 함수가 있다고 하자.

public List<Post> getPosts(int userId) {
    return repository.getPosts(userId);
}

이 함수는 사용자가 작성한 포스트들을 리스트로 가져온다.

 

이 함수가 성공할 때의 테스트 코드는 다음과 같다.

먼저, 임의의 사용자와 임의의 포스트들을 만들어주고, 함수가 정상 동작 했을 때 앞에서 생성한 포스트들을 가져오는지를 확인한다.

public void testGetPosts(){                                                        
    // java 를 오랜만에 짜서 돌아가는 코드인지 자신이 없다...
    // sudo 코드 정도로 봐주었으면 한다.
    User u = new User();
    
    IntStream stream = IntStream.range(0, 3);
    
    posts = stream.map((i) -> {
        Post p = new Post();
        p1.user = u;
        repository.savePost(p1);
    });
    
    result = postService.getPosts(u.id);
    assertArrayEquals(posts, result);
}

 

하지만, 이렇게 성공하는 케이스만 테스트 코드를 만들면 될까? 

실패했을 때 어떻게 동작할지도 만들어둬야 한다.

 

public void testGetPostsFail(){                                                        
    // 유저가 없을 때
    User u = null;
    try {
        result = postService.getPosts(u.id);
        return result;
    } catch (Exception e) {
    	assertEquals(e.message, "Not Exist User");
    }
}

 

자, getPosts 가 이렇게 실패했을 때 Exception 처리도 해줘야 한다. 

위의 예시 코드에는 해당 Exception을 다루는 코드가 빠져있으니 위의 테스트가 실패할 것이다.

이렇게 테스트가 실패 할 때는 원하는 동작을 하도록 원본 코드를 수정해주면 된다.

 

그 외에 상상력을 동원해서 테스트 코드가 실패나는 경우를 찾아보자.

그리고 그 경우들을 추가해주면 훨씬 나은 테스트 코드가 된다.

 

 

2. 잘못된 코드의 냄새

그런데 여기서 전제 조건이 한 가지 있다.

테스트할 함수가 무척 "간단"했다는 것이다.

만약에 함수가 매우 복잡하다고 생각해보자. 흔히 말하는 복잡한 함수는 함수가 하는 역할이 아주 많은 경우이다.

사용자의 이름을 받아서, 이를 validation 하고 저장까지 하는 함수가 있다고 생각해보자.

 

이 함수는 몇 가지 테스트가 필요할까?

먼저, 성공한 케이스가 하나 필요하다. 그 다음에는 validation 실패 테스트가 필요하다. 그리고 저장에 실패했을 때 테스트도 필요하다.

validation 룰이 매우 복잡한 경우에는 함수 하나에 테스트가 엄청나게 불어나게 된다.

게다가 테스트 코드 자체도 각종 실패 케이스를 조합해서 만드느라 바쁘다.

무엇이 문제일까?

 

이 경우는 위에서 이야기한대로 함수가 하는 일이 너무 많은 것이 문제이다.

유닛 테스트, 즉 단위 테스트를 한다고도 말하기 민망한 커다란 함수 였던 것이 문제이다.

이런 경우를 만나면 억지로 테스트 케이스를 모두 만들려고 하지 말고 함수를 분리하도록 하자.

먼저, 사용자의 이름을 validation 하는 함수 하나, 그리고 저장하는 함수 하나, 마지막으로 이 두 함수를 호출하는 커다란 함수 하나로 함수를 분리하면 테스트 코드가 훨씬 보기 좋아진다.

물론 원래 있던 함수도 덩달아 읽기 좋아진다.

 

 

이번에는 테스트 코드가 있는 경우와 없는 경우를 상상해 볼 것이다.

이렇게 생각해보면 테스트 코드의 역할을 더 분명하게 알 수 있다. 그리고 역할을 이해하면 훨씬 더 많은 케이스를 만들 수 있게 된다.

어떤 코드 파일 하나가 너무 커졌다. 함수도 많고, 모듈 자체도 분리가 필요한 경우이다.

예를 들면, 사용자에 관한 모듈이 있다고 생각해보자. 

처음 아이디어는 괜찮았다.

사용자에 대한 기능이 얼마 없어서 단순히 모아놓기만 해도 충분히 다른 모듈과 분리될 수 있었다.

하지만 서비스가 커지면서 Access token 등 계정에 관련된 코드부터 사용자 정보 자체를 변경하는 함수, 사용자 정보가 변경 되었을 때의 이벤트를 전파하는 함수 등이 추가되었다.

누가보아도 모듈 분리가 필요한 상황이다.

 

테스트 코드가 없다면 어떻게 할까?

각 함수 간의 관계를 찾느라고 무척 신중하게 접근해야 할 것이다.

만에 하나 잘못 분리하거나 놓치는 경우 무슨 일이 벌어질지 아무도 모른다.

심지어 서비스의 중추라고 할 수 있는 사용자 모듈을 분리하면 다른 모듈들까지 영향을 받을 수 있다.

어디서 side effect가 나는지 어떻게 예상할 수 있을까?

 

테스트 코드가 이런 문제들을 해결해준다.

아까보다는 조금 덜 신중하게 접근해도 된다. 왜냐하면 그동안 통과했던 테스트들이 최소한의 동작을 보증해준다.

(테스트가 최소한의 동작을 보증해주지 못해 불안한 상태라면 모듈을 분리하기 전에 테스트 코드를 더 추가하자.)

'이 테스트를 통과하면 정상 동작이다'라는 그물망 덕분에, 만약 분리를 했더라도 테스트가 깨진다면 그 부분을 찾아서 고쳐주면 된다.

다른 모듈에 side effect 를 미칠 수 있을까? 그랬다면 마찬가지로 다른 모듈의 테스트가 깨진다.

그러면 우리는 그 테스트가 정상적으로 동작하도록 코드를 수정해주기만 하면 된다.

 

물론, 다른 사람들이 촘촘하게 테스트 코드를 짜두었다는 이상적인 상황이다.

하지만 Duck Typing과 마찬가지로 '이 테스트를 통과하면 정상 동작이다'라는 가정이 있는 한,

테스트 코드를 통과한다는 것은 그 기능을 보장한다는 것을 의미한다.

반대로 좋은 테스트 코드의 의미는 코드가 어떤 변경사항을 겪어도, 그 코드의 정상적인 동작을 테스트가 보장하고,

side effect 가 생길 경우 테스트가 잡아낼 수 있다는 것이 될 수 있다.

조금은 어떤 테스트 코드가 좋은 코드인지 감이 올지도 모르겠다.

 

 

3. 서비스로서의 테스트

조금 더 실전적인 문제를 살펴보자.

게시판을 만들어보자고 기획자가 기획서를 가지고 왔다.

게시판에는 페이징 기능이 포함되고, 어떤 필드가 보여야 한다는 내용들이 기획서 안에 들어있다.

기획서 안에는 최소한의 요구사항들이 들어있다.

만약 TDD를 한다면, 이 요구사항들을 정리하여 이를 테스트 코드로 먼저 만들어 둘 것이다.

먼저, 게시판 목록이 정상적으로 나오는지를 확인하는 테스트를 만들 것이고 기획서에 있는 필드들이 보이는지를 테스트에 추가한다. 

페이징 기능도 모두 테스트 코드에 추가했다.

1차적으로 기획서의 요구사항들은 테스트 코드에 모두 녹아있어야 한다. 그래야 기획대로 동작한다는 최소한의 동작을 보장할 수 있다.

 

기획자가 실수로 '어떤 필드'에 값이 없을 때 어떻게 표시해야하는지를 놓쳤다고 해보자.

코드로는 null 이 나오든지, 프론트를 그릴 때 Exception이 나든지 할 것이다. 

하지만 개발자가 테스트 코드를 짤 때 상상력을 발휘한다면, 기획서에 없는 요구사항도 찾아낼 수 있다.

기획자에게 '이 경우는 어떻게 할까요?'라고 반대로 요구할 수도 있고, 실 서비스에서 Exception 이 떨어지는 상황도 피할 수 있다.

어떤 요구사항에 대해서 실패하는 케이스를 찾아내려고 노력하다보면 이렇게 서비스 전체적으로도 테스트를 유용하게 사용할 수 있다.

 

 

이제 테스트가 어떤 역할을 하는지 조금 보였으면 좋겠다.

역할을 이해하면 이제 테스트 코드를 작성할 때 어떤 역할을 수행하게 만들 것인지를 생각할 수 있게 된다.

그러면 조금 더 나은 테스트 코드를 작성할 수 있을 것이라고 믿는다.

두서 없이 작성한 글이지만 도움이 되었으면 좋겠다. 피드백은 언제나 환영.