요즘 SNS에서 왠지 TDD 혹은 테스트에 대한 글을 많이 봤다. 나름 테스트를 열심히 작성하는 처지에서 다른 개발자의 테스트 글을 보다 보니 나도 한번 정리해봐야겠다고 생각했다.
한 6~8년 정도 전에는 TDD가 한창 뜨겁던 시절이었는데 가장 유명한 책은 역시 켄트 벡의 TDDBE인데 여기서도 꽤 많은 통찰력을 얻었지만, 실용적으로 나에게 다가오기 시작한 것은 채수원 님의 테스트 주도 개발을 읽고 나서부터였던 것 같다. 이때까지만 해도 업무환경에서 TDD는커녕 테스트 작성도 쉽지 않았던 터였다가 내가 알아서 TDD를 할 수 있는 환경에 오고 나서는 열심히 TDD를 적용하려고 노력했고 한 달 정도 노력한 뒤에 TDD를 한 달여 정도 해보고 나서....라는 글을 남겼다.
그전까지는 책이나 글로 배운 이론상의 TDD가 머릿속에 있었으면 이때부터 내가 실제로 적용해 보면서 내가 편한 부분이나 책에서는 좋다고 했는데 나는 잘 모르겠는 부분 등이 구분되기 시작했던 것 같다. 이때의 경험을 TDD를 한 달여 정도 해보고 나서....에 정리해 놓았는데 오랜만에 읽어보니 지금 읽어도 고민의 수준이 크게 달라지진 않은듯한 기분이 들어서 약간 좌절 중이다. 그리고 1년 정도 뒤에 TDD에 대한 생각을 좀 더 적은 글도 있다.
예전에는 고민했던 내용
테스트를 정말 작성해야 하는가?
이 부분은 지금은 완전히 종결된 질문이다. 이젠 테스트의 유용성 같은 건 고민하지 않고 오히려 테스트가 없으면 코드를 작성할 수 없는 지경이 이르렀다. 아주 간단한 코드가 아니라면 테스트 없이는 코드를 작성하지 않는다. 단 몇십 줄의 코드를 작성하더라도 내 코드가 의도대로 동작하는지 확인하려면 REPL에서 돌려보던 main()
메서드를 쓰던 HTTP로 찔러보든 간에 테스트가 필요한데 어차피 테스트할 거 유닛테스트로 만들어서 계속 확인하지 않을 이유가 없다.
내가 작성한 코드가 수정 후에도 계속 동작하고 이번 릴리스가 배포된 후에도 문제가 생기지 않을 거라는 보장을 하는 방법에서 테스트 코드 이상의 방법을 알지 못한다. (잘 작성되었다는 하에) 테스트코드가 있으면 CI에서 계속 돌려보면서 코드의 버그를 파악할 수 있고 배포 전에도 최소한의 안정성을 기대할 수 있다. 그러다 보니 테스트를 항상 같이 작성하게 되었고 특히 테스트가 없으면 리팩토링은 전혀 못 하는 상황까지 왔다.
내가 TDD를 잘하고 있나?
처음 TDD로 접해서 그런지 예전에는 TDD에 집착을 많이 했다.(TDD와 유닛테스트를 혼용해서 쓰기도 하는데 여기선 구분해서 쓰고 내 입장은 이젠 이 둘을 구분하는 것 자체가 큰 의미 없다고 보는 편이다) 예를 들면 테스트를 먼저 작성한다는 게 어느 정도인지? 테스트를 먼저 작성해서 Fail을 보고 코드를 작성해서 Pass를 보라는데 항상 그래야 하나? 간단한 로직이라도? 이런 걸 고민하면서 TDD를 하려고 노력하면서 내가 하는 게 TDD가 맞나? 방금 한 것은 TDD 규칙을 어긴 건가? 하는 고민을 했다.
지금은 이런 규칙을 거의 신경 쓰지 않는다. 난 내가 하는 걸 TDD라고 부르지는 않고 그냥 유닛테스트라고 부르는데 TDD의 규칙에서 벗어나기 위해서인지도 모르겠다. 내가 테스트를 작성할 때의 느낌을 생각해 보면 코드와 같이 테스트를 작성한다는 말이 정확할 것 같다. 그냥 코드를 작성하는 과정에서 테스트 코드를 작성하는 게 포함되어 있으므로 테스트가 먼저인지 나중인지는 크게 신경 쓰지 않지만, 코드를 작성하면 테스트도 다 작성되어 있다. 코드만 먼저 작성하고 나중에 테스트를 작성하는 식으로 분리되어 있지 않다.
테스트를 작성해서 좋은 점
기술부채를 갚기 위한 기반
테스트를 열심히 작성하는 이유는 실제로 도움이 되기 때문이다. 위에서 말한 대로 코드를 돌려보지 않고 직접 버튼을 눌러보던 호출해 보던 테스트를 해보지 않을 수는 없다. 이런 테스트는 반복해서 하게 되는데 이를 유닛테스트로 작성해 놓으면 쉽게 코드를 돌려볼 수 있다. 코드를 작성하다 보면 자연히 기술부채가 쌓이게 마련이다. 설계를 잘못했을 수도 있고 일정에 쫓기다 보니 어설픈 코드가 들어갈 수도 있다. 아니 잘못한 게 없다고 하더라도 빠르게 기술도 달라지게 마련이고 비즈니스에 맞추다 보면 요구사항도 많이 달라져서 기술부채가 쌓이기도 한다.
이런 기술부채를 쌓지 않으려고 노력해야 하지만 노력한다고 완전히 없어지진 않는다. 완전히 없앨 수 있다면 나중에 기술부채를 쉽게 갚을 수 있는 기반을 마련해 두는 게 좋다는 게 내 생각이다. 그리고 기술부채를 쉽게 갚을 수 있는 가장 좋은 방법이 유닛테스트를 잘 작성해 놓는 것이다. 더 좋은 방법이 있다면 사용하겠지만, 아직 찾지 못했다. 테스트를 잘 작성해 놓으면 기능 추가할 때도 큰 걱정 없이 할 수 있고 리팩토링할 때도 맘 놓고 할 수 있다. 다른 곳에 영향이 없을 거라고 생각하고 수정했는데 테스트가 깨져서 다른 부분에 영향을 준 것을 발견하게 되면 정말 테스트 작성해 놓길 잘했다는 생각이 든다.
코드의 사용자 입장
테스트를 작성하면 내가 작성한 메서드나 클래스의 사용자 입장이 되는 기분이 든다. 예전에는 테스트를 어떻게 작성해야 하는지 고민하기 바빠서 이런 생각을 못 했는데 점점 익숙해질수록 코드의 사용자 입장이 되어 본다는 것은 중요하게 느껴진다. 여기서 사용자 입장이라는 것은 라이브러리를 만들어서 다른 사람에서 제공하기 때문에 필요하다기보다는 내가 혼자 작성하더라도 그 코드를 다른 코드에서 다시 불러서 사용해야 하므로 항상 사용자와 작성자 입장이 반복되게 된다. 코드를 작성할 때 필요한 기능을 고민하고 코드를 어느 정도 머릿속에서 그리면서 코드를 작성하게 되는데 뜻밖에 실제로 사용하려고 하다 보면 인터페이스 등이 사용패턴에 잘 맞지 않는 경우가 있다. 머릿속에서 한 번에 잘 그려지는 사람은 상관없겠지만 나 같은 경우에는 테스트코드를 작성하면서 이런 경험을 많이 하게 된다. 이렇게 파라미터를 받고 값을 반환하면 될 거라고 생각했는데 테스트를 작성하다 보니 생각한 형태로 사용하기 어렵다거나 다른 값이 더 필요하다거나 하는 것을 발견하게 된다.
코드 사용법의 문서화
테스트를 잘 갖춰놓으면 문서를 따로 만들지 않더라도 자연히 코드 사용법을 알려주는 문서가 된다. 오픈소스를 볼 때 문서를 보기도 하지만 문서는 최신화가 안 된 경우도 많으므로 어떻게 사용하는지 확인할 때는 테스트 코드를 보는 경우가 많다. 마찬가지로 내가 작성한 코드에서도 이 코드를 어떻게 사용하고 예외는 어떤 식으로 다루길 원하는지가 테스트코드에 자연스럽게 나타난다. 일일이 사용법 설명을 굳이 적지 않아도 해당 메서드를 사용할 때 테스트코드를 참고할 수 있다.
코드 작성 방식의 변환
정확히 예전엔 어떻게 코드를 작성했는지 다 기억나진 않지만, 예전에는 코드를 테스트(유닛테스트 말고)해봐야 하니 위에서부터 아래로 만들었다면 지금은 밑에서부터 쌓아 올리는 느낌이다. API를 구현한다거나 화면에 무슨 기능을 추가하면 API 엔트리포인트나 화면에 버튼 등을 만들고 이를 테스트해보면서 구현했던 것 같다. 코드를 작성하면서 계속 테스트해보려고... 지금은 유닛테스트로 어떤 단위의 코드든 실행해 볼 수 있으므로 모델에서 기능을 구현하면서 필요한 유틸리티 함수를 만들면서 올라가서 마지막에 API 엔트리포인트나 화면 등은 붙이는 식으로 작업할 때가 많다. 어느 쪽이 더 좋은지는 모르겠지만 나는 지금의 방식이 더 견고한 느낌이 들어서 좋고 함수의 재사용이나 오류를 어느 단계에서 처리할지가 더 명확해져서 좋다.
여전히 고민하는 부분
어떤 테스트가 잘 작성한 테스트인가?
계속해서 테스트를 작성하지만 지금 테스트를 잘 작성하고 있는가는 여전히 고민인 부분이다. 어떤 코드가 좋은 코드인가를 계속 고민하듯이 어떤 테스트가 좋은 테스트인지도 아마 계속 고민해야 할 것 같다. 이건 커버리지를 얘기하는 게 아니다. 경험상 코드를 다 작성하고 나중에 테스트를 추가하는 게 아니라 코드를 작성하면서 같이 유닛테스트를 작성한다면 커버리지는 80% 안팎으로는 나온다. 커버리지는 테스트에서 빠진 부분이 있는지 확인하는 기본 지표이고 최대한 간결한 테스트코드로 문제가 생기면 발견할 수 있도록 다양한 경우를 커버하는 것이다.
예전에 테스트를 어느 정도까지 작성해야 하나요? 라는 질문에 "안심될 때까지"라는 말을 본 적이 있다. 나는 걱정이 많아서인지 안심이 잘 안 돼서 테스트를 많이 짜는 편이라 테스트코드가 좀 장황하다. 대형 오픈소스 프로젝트를 보면 이 정도로 테스트가 충분한가 싶을 정도로 테스트 코드가 간결한 경우를 많이 보게 된다. 익명의 다수가 참여하는 오픈소스를 관리하는 것과 팀에서 개발하는 프로젝트는 좀 관점이 다르긴 하지만 난 오픈소스가 가장 진보된 형태의 개발문화를 가지고 있다고 생각해서 충분히 안심할 수 있으면서 간결한 테스트를 작성하고 싶은데 아직은 그게 잘 안된다.
위에서 코드를 작성할 때 아래부터 작성해서 올라간다고 설명했는데 이럴 때 테스트가 너무 장황한가? 하는 고민을 하게 된다. "컨트롤러" - "서비스" - "모델" 로 구성되어 있다고 하면 모델부터 작성하므로 서비스 단계에서 테스트를 작성할 때는 당연히 모델 쪽의 코드가 다시 테스트가 되고 컨트롤러로 가면 더욱 그렇다. 아래 계층에서 테스트한 건 굳이 테스트하진 않지만 그럼에서 각 계층에 로직이 있으므로 이 부분을 테스트하다 보면 자연히 테스트 대상이 중첩된다. 이럴 때는 이렇게 하는 게 맞을까 하는 고민을 계속 하고 있다.
mocking
테스트를 작성할 때 mocking, stub, spy 등 다양한 기법이 있지만 여기서는 통칭 mocking이라고 하겠다. 처음 테스트를 공부할 때 "인터넷 선을 뽑아도 테스트는 돌아가야 한다"라는 얘기를 들었었는데 나는 심하게 mocking을 하는 편은 아니다. 대표적으로 데이터베이스는 로컬에 데이터베이스를 띄워서 데이터를 직접 넣었다 지웠다 하면서 테스트하고 다른 외부 자원도 가능하다면 직접 사용해서 테스트하는 편이다.
mocking을 많이 하면 테스트는 편하지만(mocking 코드를 작성하는 건 귀찮지만) 실제로는 안 돌아가는데 테스트만 통과할 가능성이 커진다. 프로덕션에 배포하면 mocking 없이 돌아가야 하므로 테스트에서 가능하면 실제와 비슷하게 만들려고 한다. 그런데도 mocking이 필요한 부분이 있는데 가장 많이 하는 것은 소셜로그인이나 외부 자원이 확인이 어려운 경우 정도이다. 소셜로그인이나 외부 API를 사용하는 경우에는 토큰 발급 등 테스트코드에서 자동화하기 쉽지 않은 부분이 있으므로 이 호출 자체를 mocking 해서 내가 작성한 코드만 확인한다. 타 서비스를 사용해서 이메일이나 SMS를 발송하는 때도 발송 자체는 외부 서비스에 의존하고 있고 발송 여부 자체를 내가 따로 확인할 수는 없으므로 mocking 해버린다. 아~ 그리고 외부자원을 사용할 때 과금이 되는 경우도 mocking을 한다. 돈이 나가면 안 되니까... ㅎ
내가 유닛테스트를 작성할 때는 실제로 코드가 어떻게 동작하는지 확인하는 용도이다. 외부 API를 사용한다면 실제로 외부 API가 어떻게 동작하는지 확인해야 하므로 코드를 작성할 때는 외부 API를 사용해서 개발한다. 그래서 난 이 코드를 그대로 남겨두고 코드 작성과 테스트가 완료되면 mocking을 하는 식으로 작성한다. 외부 API 변경이나 버그 등으로 나중에 다시 확인해 보고자 할 때는 이 mocking 코드만 주석 처리하면 큰 노력 없이 실제로 테스트해볼 수 있다.
복잡한 픽스쳐(Fixture)
픽스쳐에 다양한 과정이 포함되지만, 대표적으로는 데이터베이스가 있다. 위에 말한 대로 데이터베이스에 직접 테스트하므로 픽스쳐단계에서 필요한 사용자 생성이나 관련 정보 설정을 해주어야 하고 tear down 단계에서 다시 정리를 해주어야 한다. 테스트를 작성할수록 다양한 픽스쳐가 필요하므로 금세 많이 복잡해진다. 픽스처를 만드는 부분을 공통화하고 보기 좋게 하려고 노력하지만, 테스트에 필요한 준비를 하다 보면 쉽게 정리가 잘 안 된다. 픽스처를 아주 깔끔하게 관리하고 싶은데 이 부분은 여전히 큰 고민 중 하나이다.
테스트에 대해 고민하고 생각했던 부분들이 많이 담겨 있어서 너무 행복하네요. 안심할 수 있는 만큼 작성하라는 얘기도 기억에 남습니다. 좋은 글 감사합니다!
잘 읽어 주셔서 감사합니다. ^^
제가 했던 고민에 대한 답을 얻은 것 같습니다. 고맙습니다.
도움이 되셨다니 다행이네요 ^^
글 잘 읽었습니다. 좋은 테스트에 대한 지표 중에 하나로 mutation testing이라고 있습니다. 코드를 조금 변경했을 때, 성공했던 테스트케이스가 실패하는지를 검사하는데요. 변경된 코드를 뮤턴트(mutant)라고 하고 테스트케이스가 많은 뮤턴트에 대해 실패할 수록 좋은 테스트케이스라고 합니다. 각 언어에 대한 mutation testing 도구가 있고 이러한 도구는 뮤턴트 생성, 테스트 수행 및 레포팅을 자동으로 해줍니다. 자바스크립트도 Stryker라는 툴이 있네요. 아웃사이더님 글들을 즐겨보는데 도움만 얻다가 제가 아는 것이 나와서 끄적여 보았습니다. ^^
오! 처음 들어본 개념이군요. 단순 커버리지는 큰 의미가 없으니 변화나 영향을 주는 부분에 대한 테스트가 많이 작성되어 있을수록 좋다는 개념인가 보군요. 좋은 정보 잘 얻었습니다. 자료를 좀 찾아서 공부해 봐야겠네요. 감사합니다!
이제 막 JUnit을 이용해서 테스트 코드를 작성하려는 프로그래머 입니다. 아직 알아야 할게 많지만, 유닛 테스트가 얼마나 코드 작성에 중요한지 알 것 같습니다. 또 어떤 책을 참고해야하는지도 알겠구요. 좋은 글 감사해요~
네 도움되셨다니 다행입니다. ㅎ
유닛테스트를 배운지 한달 정도 된 초보입니다. 테스트를 작성하실 때 비헤이비어 위주로 하시나요, 아니면 함수 단위로 하시나요? 함수 단위로 하자니 private 함수의 테스트 때문에 머리가 아픕니다. 테스트를 위해 public으로 푸는 것도 이상하구요. (유니티3d를 쓰는 중이라 모킹 툴 지원이 안됩니다 ㅠㅠ) 비헤이비어 단위로 하자니 '의도한 대로 돌아가는지 보자' 가 되버리고 함수단위로 하자니 이젠 리팩토링등으로 함수들을 만들고 지우고 하니 테스트가 무용지물이 되는 문제 (private 테스트 딜레마 포함)가 생기네요...
저는 함수단위로 해서 위로 올라갑니다. 비헤이비어단위가 어떤걸 만드느냐에 따라 다르긴 한데 기본적으로는 함수단위로 테스트를 실행하고 필요한 경우(정확히는 제가 불안하게 느끼는 경우)에는 비헤이비어 단위로도 테스트를 합니다.
private는 어떤 함수이냐에 따라 다르긴 한데 테스트가 필요한 경우에는 public으로 푸는 편입니다. piravte/public 제어보다는 테스트를 좀더 중요시 하는 편입니다. 리팩토링을 하면서 테스트 코드도 같이 사라지는 경우나 의미 없어지는 경우도 있긴 하지만 이런 부분은 저도 고민을 많이 했습니다만 이 자체도 코드가 발전하는 과정이라고 보는 편입니다. 리팩토링해서 코드를 제거하는 겻과 동일하게 테스트코드도 라이프사이클을 같이 가지게 되는데 이런 부분이 낭비라기 보다는 그동안 그 역할을 하고 지금은 달라져서 사라지는 정도로만 보고 있습니다.