Test is always right.

Coding을 하면서 많은 것들을 고민하지만, 테스트만큼 고민스러운 것도 없다. 논리적으로 도움되고, 유지보수를 위해서라도 반드시 필요하다. 하지만 빨리 만들어서, 고쳐서 내보내야 한다는 심리적인 압박감이 강해지다보니 넘어가자. 바쁜데… 라는 합리성을 부여해버린다. 그래놓고 장애나면 급 후회를 하긴 하지. 언제나 그렇지만, 코딩/개발 단계의 시간보다 장애 대응하면서 보내는 시간이 훨씬 길다.

개발자의 입장에서 테스트는 반드시 필요하니 꼭 작성해두길 바란다. 한번 쓰고 버릴 일이 아니라면 말이다. 개인적으로 TDD 애찬론자이기도 하고, 테스트의 가치에 대해서도 백퍼 공감한다. 하지만 타협을 요구하는 현실이 당장의 우리의 현실인 것도 부정 못한다. 그 안에서 타협점을 찾아내고, 올바른 길로 개발자를 이끄는게 좋은 개발 리더가 아닐까 싶다.

글을 쓸려고 보면 사설이 길다. ㅋㅋ

Java coding을 하다보면 써야하는 테스팅 프레임웍이 JUnit이다. 이전 포스팅에서 5가 나왔다는 이야기를 했지만, 실제로 사용해보니 4보다는 확실한 버전업이 된 것 같다. 특히 Spring framework과 결합된 단위 테스트 속도를 확실히 보장할 수 있는 점이 체감되는 것 같다.

0. Performance

이전 JUnit에 비해서는 테스트 실행 성능이 좀 빨라진 느낌이다. 기분탓인가? 성능상에 영향을 미칠 수 있는 변경점은 Java8 이후부터 지원하는 Lambda가 보편적으로 구현에 사용됐고, 여러 라이브러리들로 쪼개져서 지금 내가 사용할려고 하는 테스트에 필요없는 모듈들을 런타임에 로딩하지 않는다는 정도? 뭐 이 두가지만 효과적으로 다뤄준다면 빨라진 걸 이해할만한 것 같기도 하다.

상세한 변경 점들에 대한 설명은 Major difference page에서 확인할 수 있다.

1. Enhanced unit testing in the spring-framework

확실히 스프링과의 통합은 JUnit4 보다는 개선된 것 같다. 특히 단위 테스트 측면에서. Spring project에서 테스트하다보면 내가 만드는 테스트가 Unit test인지 Integration test인지 헷갈린다. 특히나 실행시킬때보면. 겁나 느리다. 이렇게 느리면 단위 테스트 작성할 맘이 안생긴다. 걍 한방에 Integration Test로 검증하고 말지… 하지만 Integration Test는 상황 제어를 Mocking 가지고 하는게 아니기 때문에. 짜기 싫어지는 경우가 더 많아진다. (그러다가 걍 포기. ㅠㅠ)

JUnit5와 결합된 Spring-test에서는 이 부분을 완전 속시원히는 아니지만, 이전보다는 훨씬 더 개선된 형태로 사용법을 잡아줬다. 설정의 구태의연함이 있지만, 그럼에도 이제 Controller 수준에서도 Mocking을 활용한 단위 테스트를 제대로 작성할 수 있다.

@ExtendWith(SpringExtension.class)
@Slf4j
public class ValueV3ControllerUnitTest {
    @MockBean
    ValueService valueService;

    @InjectMocks
    ValueV3Controller controller;

    MockMvc mvc;

    @BeforeEach
    public void setup() {
        MockitoAnnotations.initMocks(this);
        mvc = MockMvcBuilders.standaloneSetup(controller)
            .addFilter(new CharacterEncodingFilter(Charsets.UTF_8.name()))
            .build();
    }

    @Test
    public void shouldQueryByKeyContainFederatedInfo() throws Exception {
        // given
        final String givenKey = "1111-2222-3333-4444";
        final Value value = Account.builder()
            .key(givenKey)
            .identities(Arrays.asList(new String[] { "google" }))
            .build();

        given(valueService.value(givenKey))
            .willReturn(account);

        // when
        mvc.perform(get("/api/v3/value/" + givenKey))
            .andExpect(status().isOk())
            .andExpect(content().json(new Gson().toJson(value)));

        // then
        verify(accountService, times(1)).value(givenKey);
    }

JUnit4 기반의 spring-test의 경우에는 Controller 테스트를 실행할 때 테스트와 무관한 다른 초기화 모듈들(Filter 혹은 Configuration 객체들)이 실행되었다. 인간적으로 지금 코딩할 부분도 아닌 부분에 뭔가를 해줘야 하는 것도 좀 찜찜하다. 하지만 그것보다도 더 짜증났던 건 테스트 실행 시간이 5초 혹은 10초 이상 걸리는 경우가 생긴다. 특히 그 초기화 블럭에서 JPA와 같은 부분이 있다면 상당히 시간을 잡아드신다. 수정하고 빠르게 테스트를 돌려서 확인하고 싶은데 이렇게 시간 걸리는게 쌓이면 전체 단위 테스트를 돌리는데 5분 이상도 걸린다. (거의 10년 다되가지만, 네이버때 테스트 돌리는 시간이 5분 가까이 되서 빡돌뻔!)

여기 설정에서 핵심은 아마도 이 부분이지 않을까 싶다.

@BeforeEach
public void setup() {
    MockitoAnnotations.initMocks(this);
    mvc = MockMvcBuilders.standaloneSetup(controller)
        .addFilter(new CharacterEncodingFilter(Charsets.UTF_8.name()))
        .build();
}

Controller 자체를 standalone 방식으로 초기화하고, 동작중에 필요한 filter 부분도 선별적으로 추가할 수 있다. 실제 서비스에는 해당 필터가 들어가겠지만, 당장 테스트할 부분은 로직에 대한 부분이니 집중해서 테스트를 작성할 수 있다. 필터에 대한 테스트가 필요하다면 그 부분만 따로 단위 테스트를 작성하면 되니까.

2. Nested: grouping tests with a purpose

하나의 객체에 여러 책임과 역할을 부여하지 말자라는게 아마도 OOP 좀 해봤다라는 분들의 공통된 의견일 것이다. 하지만 객체는 객체다보니 외부와 상호 작용할 수 있는 2~3개의 Public Method 들은 필수다.

각 method의 구현을 진행하면서 계속 단위 테스트를 추가한다. 하나 구현을 완료하고, 다음꺼를 구현할 수 있다면 좋겠지만, 객체 상태라는게 한 메소드에 의해서만 좌우되는거는 아니니까. (그래서 역할을 명확하게 해서 객체당 메소드의 개수를 줄이는게 필요하다.) 구현하는 객체의 메소드야 그렇지만, 이에 대한 테스트는 뭔 죄냐? 객체의 상태 변경에 따라 methodA, methodB 에 대한 테스트가 한 테스트 클래스에 뒤죽박죽 섞인다.

예전에는 methodA에 대한 테스트 케이스가 여기저기 널려있는게 보기 싫어서 경우가 많아지는 경우에 아예 테스트 클래스 자체를 분리했었다. 사실 이것도 나쁜 대안은 아닌 것 같다. 단점은 테스트 대상 클래스를 초기화하는 과정이 중복되거나 뭔가 빠지는 부분이 생긴다는거. 이걸 극복할려면 대상 클래스를 초기화하는 Builder 혹은 Factory 클래스를 테스트 패키지쪽에 만들어줘야 한다. 이론적으로는 백퍼 맞는 이야기지만 흐름을 잃지않고 메인 로직의 개발을 이어가고 싶은 사람의 입장에서는 맥 끊어버리는 일이다.

@Test
public void shouldTwoIdentities_WhenDefaultValueAndFederatedIdentityExist() {
    // given
    DefaultIdentity identity = RiotIdentity.builder().defaultValue("default").build();
    givenAccount.setDefaultIdentity(identity);
    final List givenIdentities = Arrays.asList(new String[] { "google" });
    givenAccount.setFederatedIdentities(givenIdentities);

    // when
    final Account actualAccount = V3Factory.create(givenAccount);

    // then
    assertThat(actualAccount.getIdentities().size()).isEqualTo(2);
}

@Nested
@DisplayName("Flat Tests")
class FlatizeTest {
    @Test
    public void shouldMakeFieldsFlat_ForGovtFields() {
        // given

        // when

        // then
    }
    ...

“@Nest” annotation은 그 관점에서 한 테스트 클래스에서 여러 테스트들을 관심 그룹에 맞춰서 묶을 수 있는 기능을 제공한다. 테스트 대상 객체에 대한 초기화 부분도 물론 공유할 수 있을 뿐더러 하나의 상태 변경 요소가 다른 메소드의 동작에는 어떤 Break를 줄 수 있는지도 바로 확인할 수 있다. 깨지면 어느 부분이 어떻게 깨졌는지도 계층 트리를 통해서 직관적으로 확인할 수 있다는 건 덤이다.

근데 좀 get/set 하는 함수 좀 그만 만들었으면 좋겠다. 경력 10년 20년 다 되가는 분들도 이런 식의 naming을 하던데, 정말 안타깝다. 객체의 state change를 유발하는 동작을 일으키는 method일텐데, 그 동작이 set 혹은 get 이라는 동사로 시작하지는 100% 아닐텐데 말이다. 생각이라는게 싫은건지 아니면 영어로 된걸 읽어본지 한참이 지나서일 것이다.

3. DisplayName

Test method 이름을 어떻게 할지를 결정하지 못했다면 요 annotation을 활용하면 좋다. Super natural language를 이용해서 달아둘 수 있다. 물론 가급적 테스트 메소드 자체에 대해서 DisplayName을 다는 건 바보같은 짓이라고 생각한다. 개발자라면 당연히 테스트 메소드의 이름이 테스트 의미를 가지도록 해야 나중에 테스트를 수정하더라도 이름을 맞게 고칠테니까 말이다. 이건 테스트 메소드말고도 서비스단의 메소드 이름을 짓는 경우에도 마찬가지다.

하지만 이게 쓸모있는 경우는 위에서 이야기했던 “@Nest”와 같은 보조 도구들과 함께 사용할때다. Test Grouping을 하거나 정말 부가적인 설명이 필요한 경우에, 이를 설명하기 난해한 경우가 있다. 이 경우에 활용하면 뱅뱅 헷갈리지 않고 테스트를 읽거나 실행하는 사람이 그 결과를 해석하는데 헷갈리지 않을 것 같다.