좋은 유닛 테스트란 무엇일까?

무작정 테스트를 많이 만드는 것이 좋을까요?

kyeong su kim
월요일 오후 9시

--

이제 성장중인 기업의 입장에선 최신 기술보다는 지속 가능한 소프트웨어를 개발하는 것이 중요할 것입니다. 그렇다면 지속 가능한 소프트웨어를 개발하기 위해서는 무엇을 준비해야 할까요? 요즘 Unit Testing:생산성과 품질을 위한 단위 테스트 원칙과 패턴이라는 책을 보면서 좋은 유닛 테스트에 대해 많은 생각을 하게 되었습니다. 이번 아티클에선 좋은 유닛 테스트라는 것은 무엇인가에 대해 말해보겠습니다.

애플리케이션, 혹은 코드는 시간이 지날수록 계속 나빠지는 경향이 있습니다. 그래서 유닛 테스트가 안전망 역할을 해주어서 이런 상황을 막고 있습니다. 유닛 테스트는 소프트웨어 프로젝트가 지속적으로 성장하게 도와줍니다. 유닛 테스트가 있으면 처음부터 시작할 때에는 성장하기 어렵지만, 시간이 지나면서 프로젝트의 크기가 커지면서 작업에 걸리는 시간이 테스트가 없을 때보다 빨라지게 됩니다.

하지만 유닛 테스트가 좋다고 하더라도 나쁜 유닛 테스트를 작성하게 된다면 어떻게 될까요? 오히려 나쁜 유닛 테스트가 개발 생산성을 낮추게 됩니다. 이는 결국 테스트가 없는 프로젝트와 도긴개긴인 결과를 낳게 됩니다. 지속 가능한 소프트웨어라는 목표를 달성하기 위해선 좋은 테스트와 좋지 않은 테스트를 구별할 줄 알고 테스트를 리팩터링해서 더 가치 있게 만들어야 합니다.

좋은 테스트를 구성하는 요소

첫번째로 회귀를 방지해야합니다. 회귀코드를 수정 후에 기능이 의도한대로 작동하지 않는 경우를 의미합니다. 최악인 것은 코드베이스가 커지면서 개발할 기능이 많아질 때, 새로운 릴리스에 버그날 가능성이 높다는 점입니다. 그래서 회귀에 대해 효과적인 보호를 개발하는 것이 중요합니다. 자신의 테스트 코드가 회귀 방지를 얼마나 효과적으로 보호하고 있는지는 다음 기준을 참고하면 됩니다.

  • 테스트 중에 실행되는 코드의 양
  • 코드 복잡도
  • 코드의 도메인 유의성

회귀 방지 지표를 극대화할려면 테스트가 가능한 많은 코드를 실행하는 것을 목표로 해야합니다.

두번째는 리팩토링 내성입니다. 리팩토링 내성은 테스트를 빨간색으로 바꾸지 않고 코드를 리팩토링 할 수 있는 정도를 의미합니다. 만약 새로운 기능을 만들었다고 가정해봅시다. 해당 코드가 잘 동작하며 모든 테스트가 통과했습니다. 이 상태에서 코드의 일부분을 리팩토링을 하였습니다. 로직적으로 문제가 없고 가독성이 좀 더 높아지는 결과를 만들어냈습니다. 하지만 테스트가 실패하는 결과를 얻게 되었습니다.

기능이 의도한대로 작동하지만 테스트가 실패되는 상황을 거짓 양성(False Positive)라고 합니다. 여기선 알기 쉽게 가짜 실패라고 부르겠습니다. 이 가짜 실패는 지속 가능한 소프트웨어를 만드는데 방해하기 때문에 개발자 입장에서는 신경써야하는 이슈입니다.

  • 테스트가 타당한 이유 없이 실패하면 코드 문제에 대응하는 능력과 의지가 희석됩니다.
  • 거짓 양성이 빈번하면 테스트 스위트에 대한 신뢰가 서서히 떨어지면, 더 이상 믿을만한 안정망으로 인식되지 않습니다.

그럼 가짜 실패를 만드는 원인을 무엇일까?

다음 요구사항을 구현한다고 가정해봅시다. header, body, footer 담고 있는 Message 객체를 html로 렌더링하는 MessageRenderer라는 클래스를 구현했습니다. MessageRenderer는 하위 Renderer 클래스로 HeaderRenderer, BodyRenderer, FooterRenderer 객체가 존재합니다.

html을 렌더링하는 클래스가 있다고 가정해봅시다. 그리고 이런 테스트를 작성했습니다.

이 테스트는 하위 렌더링 클래스가 예상하는 모든 유형이며 올바른 순서로 나타나는지 여부를 확인합니다. 하지만 하위 렌더링 클래스를 재배열하거나 그 중 하나를 새 것으로 교체한다면 어떻게 될까요?

동작으로서 렌더링 클래스를 재배열했다고 동작하는데 버그가 생기지 않습니다. 그리고 그 중 하나를 새것으로 바꾸었다고 동작에 이상이 생기지 않습니다. 하지만 테스트 상에선 순서가 바뀌었거나 새거로 교체했다는 이유로 실패하게 됩니다.

최종 결과가 바뀌지 않을지언정, 테스트를 수행하면 실패하게 됩니다. 이는 테스트가 SUT가 생성한 결과가 아니라 SUT의 구현 세부 사항과 결합했기 때문입니다. 이 테스트는 똑같이 적용할 수 있는 다른 구현은 고려하지 않고 특정 구현만 예상해서 알고리즘을 검사하게 됩니다.

그러면 가짜 실패를 피하는 방법은 무엇일까? 당연히 테스트 검증 대상이 구현 세부 사항이 아닌 최종 결과에 집중하면 됩니다. 그럼으로써 SUT의 구현 세부 사항과 테스트간의 결합도를 낮추게 됩니다.

앞에 작성된 테스트와 달리, SUT에서 나온 결과물에 대해서만 검증을 수행하고 있습니다. 이렇게 되면 SUT에선 결과물만 유지한 채 리팩토링을 진행할 수 있고, 리팩토링이 테스트에 영향을 주지 않게 됩니다. 이렇게 SUT와 테스트간의 결합도를 낮출 수 있습니다.

테스트 정확도 극대화

앞에서 말한 가짜 실패(거짓 양성)외에도 테스트 정확도를 측정하는 지표로 가짜 성공(거짓 음성)이 있습니다. 가짜 통과는 가짜 실패와 반대로 테스트는 통과했지만 로직상으로 문제가 있는 상황을 의미합니다. 가짜 실패과 가짜 통과는 테스트 정확도를 떨어뜨리는 요인들입니다.

발견된 버그의 수가 많아지고 허위경보 발생 수가 적어질수록 테스트 정확도가 높아지게 됩니다. 개발자는 회귀를 방지하고 리팩토링에 내성을 가진 코드를 작성함으로써 테스트 정확도에 기여할 수 있습니다. 회귀 방지, 리팩토링 내성 이외에도 테스트가 빨리 실행되거나, 유지 보수가 좋은 테스트 역시 좋은 테스트를 구성하는데 기여합니다.

좋은 테스트가 미치는 영향

물론 좋은 유닛 테스트는 지속 가능한 소프트웨어를 만드는데 큰 도움을 줍니다. 구체적으로 리팩토링에 내성을 가진 테스트 코드를 만들었기 때문에, 리팩토링에 대한 두려움을 없앨 수 있습니다.

그리고 개발자에게 코드를 이해시키는 데 도움이 된다고 생각할 수 있습니다. 개발자가 단순히 코드만 보고 이 프로젝트가 어떤 요구사항이 있는지 구체적으로 파악하기 쉽지 않습니다. 하지만 유닛 테스트를 통해서 해당 클래스가 어떤 방식으로 동작하고 개발자 입장에서 이해하기 쉽게 보여준다고 생각합니다. 더더욱이 케이스별로 테스트를 하기 때문에 개발자가 더 쉽게 이해할 수 있습니다.

저 같은 경우는 프로덕트 코드를 먼저 보기보다는 테스트 코드를 먼저 파악해서 요구사항을 먼저 파악하고 프로덕트 코드와 번갈아가면서 보는 타입이기도 합니다. 코드 변경시, 변경한 부분으로 인한 영향도를 쉽게 파악할 수 있습니다.

정리

물론 테스트 코드를 작성하는 것은 매우 귀찮은 일입니다. 대부분 개발자분들은 프로덕트 코드를 작성하기도 바쁘기 때문에, 대부분 검증을 눈대중으로 하기 마련입니다. 하지만 프로젝트의 크기가 커지게 되면 기존 코드를 검증할 시간 없이 코드를 작성하게 되고, 이는 소프트웨어의 품질이 나빠지게 됩니다. 비록 테스트코드를 작성하면서 프로젝트를 진행하면 속도는 더디지만 품질이 보증된 소프트웨어를 작성할 수 있습니다.

하지만 무작정 테스트를 작성하는 것도 생각해봐야 합니다. 테스트를 작성하지 않을때는 초반 진척도가 빠르기 때문입니다. 만약 크기가 작은 프로젝트를 진행한다고 하면 오히려 테스트를 작성하지 않는 것이 좋기 때문입니다. 그래서 여러 커뮤니케이션을 통해서 프로젝트의 사이즈를 측정하고 테스트 도입 여부를 판단하는게 좋습니다.

Reference

--

--