요즘IT
위시켓
최근 검색어
전체 삭제
최근 검색어가 없습니다.

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

회원가입을 하면 원하는 문장을
저장할 수 있어요!

다음

회원가입을 하면
성장에 도움이 되는 콘텐츠를
스크랩할 수 있어요!

확인

개발

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

년차,
어떤 스킬
,
어떤 직무
독자들이 봤을까요?
어떤 독자들이 봤는지 궁금하다면?
로그인

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

 

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

 

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

 

<출처: 작가>

 

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

 

<출처: 작가>

 

 

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

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

 

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

 

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

 

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

 

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

 

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

 

 

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

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

 


public class Message {
    private String header;
    private String body;
    private String footer;
}

public interface Renderer {
    String render(Message message);
}

public class MessageRenderer implements Renderer {

    public List<Renderer> subRendererList;

    public MessageRenderer() {
        subRendererList = Arrays.asList(
                new HeaderRenderer(),
                new BodyRenderer(),
                new FooterRenderer()
        );
    }

    @Override
    public String render(Message message) {
        return subRendererList
                .stream()
                .map(renderer -> renderer.render(message))
                .reduce("", (html, str) -> html += str);
    }
}

 

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

 

public class RendererTest {

    @Test
    void MessageRenderer_uses_correct_sub_renderers() {
        // given
        MessageRenderer sut = new MessageRenderer();
        // when
        List<Renderer> renderers = sut.subRendererList;
        // then
        assertThat(renderers.get(0)).isInstanceOf(HeaderRenderer.class);
        assertThat(renderers.get(1)).isInstanceOf(BodyRenderer.class);
        assertThat(renderers.get(2)).isInstanceOf(FooterRenderer.class);
    }
}

 

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

 

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

 

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

 

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

 

public class RendererTest {

  @Test
  void Rendering_a_message() {
      // given
      MessageRenderer sut = new MessageRenderer();
      Message message = new Message("h", "b", "f");
      // when
      String html = sut.render(message);
      // then
      assertThat(html).isEqualTo("<h1>h</h1><p>b</p><h4>f</h4>");
  }

}

 

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

 

 

테스트 정확도 극대화하기

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

 

<출처: 작가>

 

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

 

 

좋은 유닛 테스트가 미치는 영향

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

 

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

 

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

 

 

마치며

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

 

하지만 무조건 테스트를 작성하는 것만이 정답은 아닙니다. 테스트를 작성하지 않을 때 초반 진척도가 더 빠르기 때문입니다. 만약 크기가 작은 프로젝트를 진행한다면 오히려 테스트를 작성하지 않는 쪽이 더 나을 수도 있습니다. 그러니 팀원들과 커뮤니케이션을 통해 프로젝트의 사이즈를 측정한 다음 테스트 도입 여부를 판단하는 것이 좋습니다.


<참고 자료>

Unit Testing Principles, Practices, and Patterns

Unit Test (단위 테스트) 에 관한 생각

 

<원문>

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

 

요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.

좋아요

댓글

공유

공유

댓글 0
작가
53
명 알림 받는 중

작가 홈

작가
53
명 알림 받는 중
'월요일 오후9시' 는 소프트웨어 개발에 열정을 가진 모든 개발자를 위한​​ 커뮤니티입니다. 매주 월요일 오후 9시에 모여 아티클 형태로 경험과 지식을 공유하며, 친절하고 전문적인 토론을 통해 함께 성장합니다.

좋아요

댓글

스크랩

공유

공유

요즘IT가 PICK한 뉴스레터를 매주 목요일에 만나보세요

요즘IT가 PICK한 뉴스레터를
매주 목요일에 만나보세요

뉴스레터를 구독하려면 동의가 필요합니다.
https://auth.wishket.com/login