본문 바로가기

책 📖

'단위 테스트 - 생산성과 품질을 위한 단위 테스트 원칙과 패턴'을 읽고

책 링크

 

단위 테스트

단위 테스트에 대한 오해를 바로잡고, 올바른 단위 테스트에 대한 원칙, 테스트를 작성하는 스타일과 효과적인 테스트를 위한 소프트웨어 아키텍처를 이해할 수 있다. 또한 단위 테스트를 통합

www.aladin.co.kr

 

들어가며...

나의 경험
자바에서 테스트 코드라는 개념을 처음 알게 되었을 때 충격을 받았다.
내가 짠 코드에 대해 불안함을 해소시켜줄 수 있는 혁신적인 도구에 감탄했다.

 

하지만 서서히 형식적으로 테스트 코드를 작성하게 되었다.

이내 테스트는 통과하지만 버그가 존재하는 경우가 발생했다. 이 또한 충격적이었다.

테스트에 투자한 나의 시간과 노력이 물거품이 돼버린 경험은 나를 되돌아보게 했다.

 

그 후에는 정말로 꼼꼼하게 테스트를 작성했다. 심하게 꼼꼼했다.

프로덕션 코드보다 테스트 코드가 더 많은 경우가 많았다.

덕분에 버그가 거의 발생하지 않았고, 리펙토링도 자신 있게 할 수 있었다.

 

하지만 많은 시간이 소요되었다.

꼼꼼하게 테스트를 작성하면서 많은 생각이 들기도 했다.

이런 것도 테스트를 하는게 맞을까?
아니야..! 꼼꼼하게 해야지! 덕분에 리펙토링해도 버그가 없잖아?
그래도 너무 비효율적인데...?

 

물론 계속적인 고민과 경험이 쌓이면서 좋은 테스트 전략에 조금씩 다가가고는 있었지만, 여전히 확신은 없는 상태였고, 이에 대한 좋은 자료도 찾기 힘들었다.

이 책은 이런 나의 고민에 대해 많은 조언을 해주었다.

책을 읽으며 좋았던 내용과 나의 생각을 정리해두려고 한다.
(책을 읽고 이해한 바를 적었습니다. 자세하고 정확한 내용에 대해 알고 싶으신 분은 책을 읽어보시는 것을 추천드립니다.)

좋은 테스트에 대한 오해

종종 이런 오해를 하기도 한다.

만들기 힘들어서 그렇지 만들기만 하면 테스트는 많을수록 좋은 거 아니야?

 

테스트가 많으면 많을수록 좋다는 생각은 잘못된 생각이다.

코드가 많아질수록 버그에 노출되는 표면적이 넓어지고 유지비가 증가한다.
적은 양으로 문제를 해결할 수 있는 효율적인 테스트 작성이 필요하다.

 

좋은 테스트의 특성

코드베이스의 가장 중요한 부분만을 대상으로 한다.
최소한의 유지비로 최대의 가치를 끌어낸다.

좋은 테스트의 4대 요소

좋은 테스트에 대해서 조금 더 알아보자

좋은 테스트는 다음 4가지 요소로 판단해볼 수 있다.

  • 회귀 방지(버그 방지)
    버그를 방지해 주는 테스트가 좋은 테스트이다.
    이 말은 버그가 발생할 일이 적은 로직은 테스트 가치가 떨어진다는 의미이다.
    단순한 로직을 테스트하는 것은 가치가 거의 없다.
  • 리펙터링 내성
    코드를 리펙터링한 후, 실제로는 동작은 동일하게 하는데 테스트가 실패한다면 (거짓 양성이 나왔을 때) 리펙터링 내성을 갖고 있지 않다고 표현한다.
  • 빠른 피드백
    테스트 작성 및 실행 속도와 관련 있다.
  • 유지 보수성

아이러니하게도 회귀 방지, 리펙터링 내성, 빠른 피드백상호 배타적이다.
엔드 투 엔트 테스트는 빠른 피드백면에서 점수가 낮은 반면에 다른 회귀 방지리펙터링 내성면에서는 우수하다.
때문에 4가지 특성 모두에서 최대 점수를 얻는 것은 불가능하다.
상황에 따라 위 특성 간의 균형을 이루는 테스트를 작성해야 한다.

검증 구절은 꼼꼼하게

검증할 때는 테스트를 하는 대상 동작이 낼 수 있는 모든 결과를 꼼꼼하게 검증하는 것이 좋다.
다만 모든 속성 값을 일일이 비교하는 대신 equals()를 정의하여 비교하는 방법을 고려하자.

좋은 테스트 이름을 짓기 위한 규칙

규칙1. 엄격한 명령 정책 대신 표현의 자유를 허용하자.
규칙2. 해당 도메인에 익숙한 비개발자에게 시나리오를 설명하듯 짓자.

예시) isDeliveryValid_InValidDate_ReturnFalse ⇒ Delivery_with_a_past_date_is_invalid

테스트 작성은 블랙박스 테스트로

블랙박스 테스트
- 시스템 내부 구조를 몰라도 기능을 검사할 수 있는 방법. 어떻게 해야 하는지가 아니라 무엇을 해야 하는지에 초점을 맞춘다.

화이트 박스 테스트
- 내부 작업을 검증하는 방법, 블랙박스와 정반대

 

기본적으로 테스트는 블랙박스 테스트를 선택하는 것이 좋다.
화이트박스 테스트는 깨지기 쉬운 테스트이기 때문이다. 즉 리펙터링 내성을 갖추고 있지 않다. (예외적으로 복잡도가 높은 유틸리티 코드의 경우에는 화이트 박스 테스트를 고려해볼 수 있다.)


단, 테스트를 분석할 때는 화이트 박스 방법을 사용할 수 있다.
코드에 여러 분기가 있을 때, 어떤 분기를 실행하지 않았는지를 고려하여 해당 경우에 따른 테스트를 추가로 작성할 수 있다.

한마디로 정리하자면(내가 이해한 것으로 정리하자면)

테스트 작성은 블랙 박스 스타일로 하되 어떤 경우들을 테스트할지는 화이트 박스식으로 접근하여 케이스를 도출하고 작성하라.

출력, 상태, 통신 그중 제일은 출력

테스트는 스타일에 따라 출력 기반, 상태 기반, 통신 기반으로 나눌 수 있다.

출력 기반

출력 기반은 테스트 대상 시스템의 출력 값을 검증하는 방법이다.

    @Test
    void 출력기반() {
        List<Integer> numbers = Arrays.asList(
                6,3,4,10
        );

        //최대값을 리턴한다.
        int max = findMax(numbers);

        //리턴 값을 검증한다.
        Assertions.assertThat(max).isEqualTo(10);
    }

상태 기반

상태 기반은 작업이 완료된 후 상태를 확인하는 방법이다.

@Test
void 상태기반() {
    //position이 0인 car를 생성
    Car car = new Car("carName");

    //randomNumber가 4이상인 경우 position이 증가하는 메서드 실행 
    int randomNumber = 9;
    car.moveOrNot(randomNumber);

    //position 값이 증가한 car 상태를 검증한다.
    Assertions.assertThat(car.getPosition()).isEqualTo(1);
}

통신 기반

통신 기반은 mock을 사용하여 테스트 대상에서 호출하는 메서드를 호출했는지를 등을 검증하는 방법이다. (예시 생략)

 

위 세 가지 스타일을 '좋은 테스트의 4대 요소'를 기준으로 비교해보면 아래와 표와 같다.

  회귀 방지 리펙터링 내성을 위한 노력 빠른 피드백 유지 보수성
출력 기반 좋음 적게듦 좋음 좋음
상태 기반 좋음 중간 좋음 중간
통신 기반 좋음 중간 좋음 나쁨

결론적으로 테스트는 출력 기반으로 작성하는 것이 좋다.

하지만 출력 기반 테스트는 입출력을 명시한 수학적 함수 유형의 메서드에서만 가능하다.

다음으로는 메서드를 수학적 함수 유형으로 바꾸면서 테스트를 출력 기반 스타일로 변경하는 방법에 대해 알아보자

출력 기반 스타일로 리펙토링

아래와 같이 동작하는 방문자 관리 시스템이 있다고 가정해보자

- 방문자의 이름과 방문 일시를 파일로 기록한다.

- 파일 하나는 3개의 기록만 존재한다.

- 파일이 가득 차면 새로운 파일을 생성하여 기록한다.

 

간단하게 의사코드로 작성해보면 아래와 같다.

//의사 코드
public class VisitorManager {
    ...
    private final CFileSystem cFileSystem;//파일을 읽고 쓰기할 때 사용하는 인터페이스

    public void addRecord(String visitorName, LocalDateTime visitTime) {
        현재 저장된 방문자 출입 기록 파일을 읽어온다;

        if (저장된 파일이 하나도 없다면){
            'file_1.txt' 파일을 생성하여 기록한다;
            return;
        }

        if (마지막 파일이 3 줄이상이라면){
            순서에 맞는 파일명을 정하여 생성하고 기록한다;
            return;
        }

        마지막 파일에 기록한다;
    }
}

이 시스템을 테스트한다면 아마 다음과 같이 테스트할 수 있을 것이다.

@Test
void 방문자시스템_테스트() {
    //given
    cFileSystem을 mock 선언

    //when
    visitorManager.addRecord("eddy", LocalDateTime.now());

    //then
    선언한 mock으로 write 관련 메서드를 호출했는지 검증
}

이 테스트는 출력 기반의 스타일이 아니다.

이 시스템과 테스트를 출력 기반으로 리펙토링 해보자.

먼저 void인 addRecord 메서드의 반환형을 변경해야 한다.
어떤 파일에 어떤 내용을 기록할지에 대한 정보를 가지고 있는 FileUpdate 클래스를 만들고 적용한다.

public class FileUpdate {
    private String fileName;
    private String content;

    ...
}
public class VisitorManager {
    ...
    //fileSystem이 사라졌다. => 아래 메서드 인자에 files가 추가되었다.

	//반환형을 FileUpdate 로 변경
    public FileUpdate addRecord(FileContent[] files, String visitorName, LocalDateTime visitTime) {
        if (저장된 파일이 하나도 없다면){
            return new FileUpdate("file_1.txt", 기록할 내용);
        }

        if (마지막 파일이 3 줄이상이라면){
            return new FileUpdate(순서에 맞는 파일명, 기록할 내용);
        }

        return new FileUpdate(마지막 파일명,기록할 내용);
    }
}

이제 FileUpdate를 처리해줄 클래스인 Persister클래스를 만들어준다.

public class Persister {
    public FileContent[] read(String directoryName) {
        파일 읽기 작업
    }

    public void applyUpdate(FileUpdate fileUpdate) {
        파일 쓰기 작업
    }
}

마지막으로 이 모든 것을 service 레이어에서 연결해준다.

public class VisitorManagerService {
    private final VisitorManager visitorManager;
    private final Persister persister;
    
    ...
    
    public void addRecord(String visitorName, LocalDateTime localDateTime) {
        FileContent[] files = persister.read(디렉토리이름);
        FileUpdate fileUpdate = visitorManager.addRecord(files, visitorName, localDateTime);
        persister.applyUpdate(fileUpdate);
    }
}

이렇게 되면 테스트는 아래와 같이 바뀐다.

@Test
void 방문자시스템_테스트() {
	...

    //fileUpdate 리턴
    FileUpdate fileUpdate = visitorManager.addRecord(files, "eddy", LocalDateTime.now());

    //리턴 값 검증
    Assertions.assertThat(fileUpdate.getFileName()).isEqualTo("file_3.txt");
    Assertions.assertThat(fileUpdate.getContent()).isEqualTo("eddy_2022-03-15T16:00:00");
}

테스트가 훨씬 간단해졌다.

mock도 사라져 가독성이 향상되었다.
설계적인 측면에서도 핵심 로직이 분리되어 유지보수도 좋아졌다.

단점도 있다

단, 당연히 그렇듯 모든 코드 베이스를 함수형으로 전환할 수는 없다.

위와 같이 했을 때의 단점은 작성해야 할 코드가 많아지고 경우에 따라서는 성능이 저하될 수도 있다.

코드 베이스가 단순하거나 중요도가 떨어진다면 함수형 아키텍처로 구현했을 때 효과가 별로 없다.

시스템의 복잡도와 중요성을 고려하여 전략적으로 선택하는 것이 좋다.

 

Mock 테스트에 대한 요점

비관리 의존성에만 적용하라

비관리 의존성이란?
프로세스의 외부 의존성 중 SMTP 서버(메일 발송)같이 제어할 수 없는 의존성
참고로 데이터 베이스는 관리 의존성에 속한다

시스템 끝에서 검증

예를 들어 다음과 같은 코드가 있다.

public class MemberJoinService {
    private final MemberRepository memberRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void joinMember(String memberEmail) {
        memberRepository.save(new Member(memberEmail));
        //회원 가입 후 이벤트 발행
        eventPublisher.publishEvent(new MemberJoinEvent(this, memberEmail));
    }
}
public class MemberJoinEvent extends ApplicationEvent {
    private final String memberEmail;

    public MemberJoinEvent(Object source, String memberEmail) {
        super(source);
        this.memberEmail = memberEmail;
    }

    public String getMemberEmail() {
        return memberEmail;
    }
}
public class MemberJoinEventHandler {
    private final EmailSender emailSender;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
    public void eventHandle(MemberJoinEvent memberJoinEvent) {
    	//환영 메일 발송
        emailSender.sendWelcomEmail(memberJoinEvent.getMemberEmail());
    }
}

간단히 설명하자면 다음과 같다.
1. 회원가입이 완료되면 MemberJoinEvent를 발행한다.
2. MemberJoinEventHandler에서 MemberJoinEvent를 처리한다. (메일 발송)

 

이 로직을 테스트할 때 mock을 처리하는 방법에는 두 가지가 있다.

MemberJoinEventHandler를 mock 처리하는 방법 vs EmailSender를 mock 처리하는 방법

 

결론은 후자를 선택하는 것이 회귀 방지와 리펙터링 내성 측면에서 더 좋다.

호출 횟수도 검증

호출 횟수도 검증하여 예상치 못한 호출이 있는지도 확인하라

verify(mock).method(0); //이것보다는
//---
verify(mock, times(1)).method(anyInt()); 
verifyNoMoreInteractions(mock); //이렇게

단위 테스트에서는 목을 사용하지 않기

mock은 통합 테스트에서만 사용하자.
만약 단위 테스트에서 목을 사용할 수밖에 없다면 설계를 다시 해보는 것을 고려...

반응형