Home [Unit Testing] Unit Test Anti Pattern
Post
Cancel

[Unit Testing] Unit Test Anti Pattern

단위테스트 책을 읽고 실습 하며 내용을 정리한 글입니다. 학습 과정에서 작성된 글이기 때문에 잘못된 내용이 있을 수 있으며, 이에 대한 지적이나 피드백은 언제든 환영입니다.

image







1. 비공개 메서드 단위 테스트


가능한 비공개 메서드를 테스트하면 안 됩니다. 비공개 메서드를 노출하면 테스트가 구현 세부 사항과 결합하고 결과적으로 리팩토링 내성이 떨어지기 때문입니다. 비공개 메서드를 직접 테스트하는 대신, 언제든지 바뀔 수 있는 세부 구현 사항을 테스트하는 대신 포괄적인 식별할 수 있는 동작으로 간접적으로 테스트하는 것이 좋습니다. 즉 아래와 같이 서비스에 복잡한 로직이 private 메서드로 있다면 이를 테스트하기보단 최종 결과를 테스트하며 이를 테스트하는 것을 말합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Service
@RequiredArgsConstructor
public class PostCommandService {

    private final PostJpaRepository postJpaRepository;

    @Transactional
    public void update(Long postId, PostUpdateRequest request) {
        Post findPost = postJpaRepository.findById(postId)
                .orElseThrow(() -> BusinessException.of(POST_NOT_FOUND));

        // 복잡한 로직은 최종 결과를 통해 알 수 있도록 테스트합니다.
        validateRequest(request);
    }

    // 이를 검증하기 위한 테스트가 있어선 안됩니다.
    private boolean validateRequest(PostUpdateRequest request) {
        if (Objects.isNull(request.getPostId())) {
            return false;
        }
        if (Objects.isNull(request.getContent())) {
            return false;
        }
        
        ......
        
        return true;
    }
}









1-1. 비공개 메서드를 테스트 했을 때의 문제점

때로는 비공개 메서드가 너무 복잡해서 식별할 수 있는 동작으로 테스트해서는 충분한 커버리지를 얻을 수 없는 경우도 있습니다. 식별할 수 있는 동작에 이미 합리적 테스트 커버리지가 있다고 가정하면 죽은 코드, 추상화 누락 두 가지 문제가 발생할 수 있습니다.





1-1-1. 죽은 코드

첫 번째는 죽은 코드일 수 있다는 점입니다. 테스트에서 벗어난 코드가 어디에도 사용되지 않는다면 리팩토링 후에도 남아서 관계없는 코드일 수 있습니다. 이런 코드는 삭제합니다. 즉 단위 테스트에서 충분한 테스트 커버리지가 나왔다면, 혹은 비즈니스 로직이 바뀌었는데 private 메서드에서 이를 검증하고 있다면 이는 죽은 코드이므로 코드를 삭제해줍니다.

1
2
3
4
5
6
7
8
9
10
private boolean validateRequest(PostUpdateRequest request) {
        if (Objects.isNull(request.getPostId())) {
            return false;
        }
        // 비즈니스 로직이 변경돼서 content가 Null인지 더 이상 신경쓰지 않는다면 이는 죽은 코드입니다.
        if (Objects.isNull(request.getContent())) {
            return false;
        }
        return true;
}









1-1-2. 추상화 누락

두 번째는 추상화가 누락 돼 있다는 점입니다. 비공개 메서드가 너무 복잡하면 역할과 책임을 나눠 별도의 클래스로 도출해야 하는 것입니다. 아래와 같이 검증을 별도의 클래스로 만들게 되면 private 메서드가 아니기 때문에 이를 마음껏 검증할 수 있으며, 책임과 역할 또한 명확해집니다. 이처럼 비공개 메서드가 너무 복잡한 경우 별도의 클래스로 도출해주는 것을 고려해봅니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class PostValidation {

    // 식별할 수 있는 동작으로 바뀌었기 때문에 마음껏 검증해도 되며 역할과 책임이 분명해졌습니다.
    public boolean validateRequest(PostUpdateRequest request) {
        if (Objects.isNull(request.getPostId())) {
            return false;
        }
        if (Objects.isNull(request.getContent())) {
            return false;
        }
        
        ......
        
        return true;
    }
}

식별할 수 있는 동작이란 자바를 기준으로 public 메서드를 말하며, 이는 공개 API라고도 합니다.









1-2. 비공개 메서드를 테스트해도 괜찮을 때

잘 설계된 API란 식별할 수 있는 동작을 공개로 하고 구현 세부 가항을 비공개로 돌리는데, 구현 세부 사항이 유출되면 코드 캡슐화가 깨지기 때문입니다. 물론 비공개 메서드를 테스트하는 것 자체는 나쁘지 않지만, 비공개 메서드가 구현 세부 사항의 프록시에 해당하기 때문에 조심해야 합니다. 하지만 비공개 메서드가 타당할 때도 있는데 이에 대해 살펴보겠습니다.

여기서 말하는 프록시란 대리인을 뜻하며, 테스트를 가지고 구현 세부 사항에 접근하기 때문에 그 사항을 바꾸고자 할 때 어려움을 겪을 수 있다는 말입니다.









아래는 데이터베이스의 쿼리에 해당하는데 회원 타입(UserType)은 이름(name)에 따라 결괏값이 달라집니다. 즉 getUserType( )은 비공개 메서드이지만 이에 대한 최종 결괏값은 올바른지 테스트해볼 가치가 있습니다. 이런 경우는 비공개 메서드를 테스트하는 것이 타당합니다. 물론 이때도 비공개 메서드 자체를 테스트하기보다는 최종 결과를 테스트하는 것이 더 좋습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class UserInformation {

    private final Long id;
    private final String name;
    private final UserType userType;

    public UserInformation(Long id, String name) {
        this.id = id;
        this.name = name;
        this.userType = getMemberType(name);
    }

    private UserType getUserType(String name) {
        return Objects.isNull(name) ? UserType.UN_KNOWN : UserType.KNOWN;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public UserType getUserType() {
        return userType;
    }
}

이는 비공개 메서드 지침과 같습니다. 제품 코드는 상태를 신경 쓰지 않으며, 제품 코드가 관심을 가지는 정보만 노출 및 테스트하는 것입니다. 제품 코드가 고객 상태 필드를 사용하기 시작하면 공식적으로 SUT의 식별할 수 있는 동작이 되기 때문에 테스트에서 해당 필드를 결합할 수도 있습니다. 테스트 유의성을 위해 공개 API 노출 영역을 넓히는 것은 좋지 않은 습관입니다.









2. 비공개 상태 노출


만약 이 부분을 테스트하게 된다면 나중에 이름에 관한 정책이 바뀌었을 때 해당 테스트는 회귀 방지 점수에서 낮은 평가를 받을 것입니다. 또한 테스트에서 구현 세부 사항을 노출했는데 해당 코드를 다른 곳에서 사용하게 된다면, 제품 코드가 고객 상태 필드를 사용하기 시작했다면 이는 SUT의 식별할 수 있는 동작이 되기 때문에 테스트에서 해당 필드를 결합할 수도 있습니다.

물론 이 경우도 타당할 때가 있습니다.









위 예제와 비슷한데 특정 결과에 따라 그 값이 달라지는 경우입니다. 아래와 같이 사용자의 타입에 따라 할인율이 결정된다면 할인율의 결과는 검증하는 것이 좋습니다. 물론 이때도 최종 결과를 테스트해야지 private 메서드 자체를 테스트하는 것은 좋지 않습니다. 이 또한 다른 곳에서 DiscountRate를 사용하는 순간 SUT의 식별할 수 있는 동작이 되기 때문에 테스트에서 해당 필드와 결합할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class UserInformation {

    private final Long id;
    private final String name;
    private final UserType userType;
    private final DiscountRate discountRate;

    public UserInformation(
        Long id,
        String name
    ) {
        this.id = id;
        this.name = name;
        this.userType = getMemberType(name);
        this.discountRate = getDiscountRate(memberType);
    }

    private UserType getUserType(String name) {
        return Objects.isNull(name) ? UserType.UN_KNOWN : UserType.KNOWN;
    }
    
    private DiscountRate getDiscountRate(UserType userType){
        return userType == UN_KNOWN ? DiscountRate.ZERO : DiscountRate.TEN_PERCENT;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public UserType getUserType() {
        return userType;
    }
}









3. 테스트로 유출된 도메인 지식


아래와 같이 회원 이름값 객체를 살펴보겠습니다. 여기에는 validateName( )라는 메서드를 통해 회원의 이름을 검증합니다. 이 부분을 테스트로 검증하게 된다면 현재 도메인의 지식이 테스트로 유출되게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Embeddable
public class Name {

    private String name;

    protected Name() {
    }

    public Name(String name) {
        validateName(name);
        this.name = name;
    }

    private void validateName(String name) {
        if (Objects.isNull(name) || name.isBlank()) {
            throw new IllegalArgumentException("이름을 입력해주세요.", ErrorField.of("Name", name));
        }
        if (name.length() > 7) {
            throw new IllegalArgumentException("입력 가능한 이름의 최대 길이를 초과했습니다.", ErrorField.of("Name", 8));
        }

        String blankDeletedName = name.trim().replaceAll(" ", "");
        if (name.length() != blankDeletedName.length()) {
            throw new IllegalArgumentException("이름에 공백이 존재할 수 없습니다.", ErrorField.of("Name", name));
        }
    }

    public String getName() {
        return name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Name name1)) return false;
        return getName().equals(name1.getName());
    }

    @Override
    public int hashCode() {
        return Objects.hash(getName());
    }

    @Override
    public String toString() {
        return name;
    }
}









따라서 이런 부분은 아래와 같이 최종결과를 통해 검증하도록 합니다. 이렇게 하드코딩 하는 것이 직관적이지 않을 수 있지만 단위 테스트에서는 예상 결과를 하드코딩 하는 것이 좋습니다. 하드 코딩된 값의 중요한 부분은 SUT가 아닌 다른 것을 사용해 미리 계산하는 것입니다. 물론 알고리즘이 충분히 복잡한 경우에만 그렇습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
 * 올바르지 않은 값을 입력하는 케이스
 * - 이름이 공백 또는 빈 문자열일 경우   ex) null, "   "
 * - 이름의 길이가 7자 이상일 경우      ex) AFJOIAISDJOASJIDJO
 * - 이름에 띄어쓰기가 포함된 경우       ex) "J   U     N"
 */
@Test
@DisplayName("올바르지 않은 값을 입력하면 회원이 존재하더라도 IllegalArgumentException이 발생한다.")
void 올바르지_않은_값_입력으로_인한_회원이름_수정_실패_통합_테스트() {
        // given
        Long validMemberId = 1L;
        String invalidName = "ABASFIJFOJFOISJFOISJFSOFJOI";

        // when, then
        assertThatThrownBy(() -> getMemberCommandFacade().updateName(validMemberId, invalidName))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("입력 가능한 이름의 최대 길이를 초과했습니다.");
    }

레거시 애플리케이션을 리팩토링할 때 레거시 코드가 이러한 결과를 생성하도록 한 후 테스트에서 예상값으로 사용할 수 있습니다.









4. 코드 오염


테스트에만 필요한 제품 코드를 추가하는 것은 좋지 않습니다. 즉 테스트를 위해 프로덕션 코드에 테스트 코드를 위한 메서드를 만드는 것을 말합니다. 이는 테스트를 위한 테스트를 만들어서 불필요한 API를 만들 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Service
@RequiredArgsConstructor
public class PostCommandService {

    private final PostJpaRepository postJpaRepository;

    @Transactional
    public void update(Long postId, PostUpdateRequest request) {
        Post findPost = postJpaRepository.findById(postId)
                .orElseThrow(() -> BusinessException.of(POST_NOT_FOUND));

        // 복잡한 로직은 최종 결과를 통해 알 수 있도록 테스트합니다.
        validateRequest(request);
        
        ......
    }

    // 프로덕션 코드
    private boolean validateRequest(PostUpdateRequest request) {
        if (Objects.isNull(request.getPostId())) {
            return false;
        }
        if (Objects.isNull(request.getContent())) {
            return false;
        }
        
        ......
        
        return true;
    }

    // 테스트를 위한 코드
    public boolean validateRequestForTest(PostUpdateRequest request) {
        if (Objects.isNull(request.getPostId())) {
            return false;
        }
        if (Objects.isNull(request.getContent())) {
            return false;
        }
        
        ......
        
        return true;
    }
}









5. 구체 클래스를 목으로 처리하기


일부 기능을 지키려고 구체 클래스를 목으로 처리해야 하면 이는 단일 책임 원칙을 위반하게 됩니다. 따라서 이런 경우 험블 객체 패턴(Humble Object Pattern)으로 나누어 줍니다. 예를 들어 날씨 API의 결과를 받아와서 데이터베이스에 저장하는 것을 가정해보겠습니다. 이를 위해서는 날씨 API 결과를 받아온 후 내부 데이터베이스에 저장하는 두 단계를 거치게 됩니다. 이를 하나의 목으로 처리해서는 안 됩니다. 따라서 아래와 같이 날씨 API 결과를 받아오는 객체를 별도의 클래스로 만들어 책임/역할을 분리해 줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Import(TestConfig.class)
@ExtendWith(MockitoExtension.class)
class OpenApiClientTest extends AbstractTestConfiguration {

    @Mock
    private OpneApiComponent<WeatherResponse> weatherOpenApiComponent;  // 외부 API 호출 객체 래핑

    @Test
    @DisplayName("하나의 외부 API를 테스트하기 위해서는 페이크를 통한 하드코딩으로 모킹을 할 수 있다.")
    void 페이크_모킹_테스트() {
        // given
        String date = "20230101";
        String hou = "0100";
        
        // 구체 클래스를 목으로 대체
        FakeWeatherOpenApiComponentMock fake = new FakeWeatherOpenApiComponentMock();
        WeatherResponse response = fake.callBackData(date, hou);

        // when
        when(weatherOpenApiComponent.callBackData(any(), any())).thenReturn(response);
        WeatherResponse result = weatherOpenApiComponent.callBackData(date, hou);

        // then
        assertNotNull(result);
    }
}

험블 객체 패턴은 주로 유닛 테스팅의 맥락에서 사용되는 디자인 패턴입니다. 이 패턴의 주요 목적은 테스트하기 어려운 코드와 쉽게 테스트할 수 있는 코드를 분리하는 것입니다.









이렇게 되면 인터페이스를 통해 목으로 대체해서 테스트를 진행할 수 있습니다. 비관리 프로세스 외부 의존성은 직접적으로 참조해서는 안 됩니다. 이를 별도의 클래스로 독립시켜 인터페이스를 통해 스텁으로 대체해줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 실제 클래스를 목으로 대체하기 위해 인터페이스를 구현했고, 이를 빈으로 등록해 테스트를 진행
@Component
public class FakeWeatherOpenApiComponentMock implements OpneApiComponent<WeatherOpenApiResponse> {

    @Override
    public WeatherOpenApiResponse callBackData(
        String date,
        String hour
    ) {
        return getResponse();
    }

    public static WeatherOpenApiResponse getResponse() {
        return
                new WeatherOpenApiResponse(
                        new WeatherOpenApiResponseDetail(
                    
                                ......
        
}

이 또한 안티패턴이지만 두 단계를 하나로 합쳐서 테스트 하는 것보다는 낫습니다.









6. 시간 처리하기


현재 시각 값에 따라 테스트 결과가 달라질 수 있으므로 시간 값은 항상 명시적으로 주입해줍니다. 아래와 같이 잘못된 예제를 사용하면 주입하는 현재 날짜에 따라 테스트 결과가 달라질 수 있습니다. 테스트는 멱등성이 있어야 합니다. 따라서 올바른 사례와 같이 특정 날짜를 입력해서 명시적으로 데이터를 주입할 수 있도록 해줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Tag(description = "날짜 테스트 데이터 생성 클래스")
public final class DateTestData {

    // 올바른 케이스
    public static LocalDate createCorrectExampleTestData(
        int year,
        int month,
        int day
    ) {
        return LocalDate.of(year, month, day);
    }

    // 잘못된 케이스
    public static LocalDate createBadExampleTestData() {
        return LocalDate.now();
    }
}









모호한 문맥(ambient context)이라는 테스트 용어가 존재합니다. 즉 이 문맥에 따라 테스트가 실패할 수도, 성공할 수도 있는 것을 의미합니다. 아래 예제와 같이 특정 시간 값에 따라 테스트가 성공하거나 실패할 수 있으므로 이 값을 명시적으로 바꿔주는 것이 필요합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@DisplayName("Ambient Context 테스트")
class DateTest {

    @Test
    @DisplayName("당일 날짜에 따라 테스트가 참 또는 거짓이 될 수 있다.")
    void Ambient_Context_테스트() throws Exception {
        LocalDate date = DateTestData.createBadExampleTestData();
        
        // 항상 실패하던 테스트가 2023년 10월 23일에는 성공하게 된다.
        Assertions.assertNotEquals(
                LocalDate.of(2023, 10, 23),
                date
        );
    }
}

A Volatile Dependency is a Dependency that involves side effects that can be undesirable at times. This may include modules that don’t yet exist or that have adverse requirements on its runtime environment. These are the Dependencies that are addressed by DI and hidden behind Abstractions.









7. 결론


  • 단위 테스트를 가능하게 하고자 비공개 메서드를 노출하게 되면 테스트가 구현체에 결합되고 결국 리팩토링 내성이 떨어집니다. 비공개 메서드를 직접 테스트하는 대신 식별할 수 있는 동작으로 간접적으로 테스트합니다.

  • 비공개 메서드가 너무 복잡해서 공개 API로 테스트할 수 없다면 추상화가 누락됐다는 뜻입니다. 비공개 메서드를 공개로 하지 말고 해당 추상화를별도 클래스로 추출합니다

  • 비공개 메서드가 클래스의 식별할 수 있는 동작에 속한 경우가 있습니다. 보통 클래스와 ORM 또는 팩토리 간의 비공개 계약을 구현하는 것이 여기 해당합니다.

  • 비공개였던 상태를 단위 테스트만을 위해 노출하지 않습니다. 테스트는 제품 코드와 같은 방식으로 테스트 대상 시스템과 상호 작용해야 합니다. 어떠한 특권도 가져서는 안 되기 때문입니다.

  • 테스트를 작성할 때 특정 구현을 암시해선 안 됩니다. 블랙박스 관점에서 제품 코드를 검증합니다. 또한 도메인 지식을 테스트에 유출하지 않도록 합니다.

  • 코드 오염은 테스트에만 필요한 제품 코드를 추가하는 것입니다. 이는 테스트 코드와 제품 코드가 혼재되게 하고 제품 코드의 유지비를 증가시키기 때문에 안티패턴입니다.

  • 기능을 지키려고 구체 클래스를 목으로 처리해야 하면 이는 단일 책임 원칙을 위반하는 결과입니다. 해당 클래스를 두 가지 클래스, 즉 도메인 로직이 있는 클래스와 프로세스 외부 의존성과 통신하는 클래스로 분리합니다.

  • 현재 시각을 앰비언트 컨텍스트로 하면 제품 코드가 오염되고 테스트하기 더 어려워집니다. 서비스나 일반 값의 명시적인 의존성으로 시간을 주입합니다. 가능한 한 항상 일반 값이 좋습니다.


This post is licensed under CC BY 4.0 by the author.

Custom Response

[Unit Testing] Unit Test Principles