본문 바로가기

테스트

테스트 코드 리팩토링 2탄

좋은 테스트는 최소한의 유지비로 최대 가치를 끌어내야한다.

 

이전에 작성한 테스트 리팩토링 1탄에서는 최소한의 유지비로 최대 가치를 끌어낼 수 없었다.

(그 당시에 리팩토링 할때는 속도 개선에만 혈안이 되어있어 단점이 이렇게나 많을거라고 생각하지 못했다.)

 

[프로젝트] 테스트 코드 리팩토링

프로젝트 진행중에 백엔드 회의에서 리팩토링에 대한 이야기가 나왔다기능구현을 하면서 지속적으로 하는 회귀 테스트 또는 CI/CD를 통해서 지속적으로 빌드 를 통해서 코드를 작성할때 테스트

ghffu405.tistory.com

 

테스트 리팩토링 1탄에서 생각한 문제점들을 보자.

테스트 코드 구조 변경
1. 테스트 코드 구조 변경
- 현재 테스트가 그렇게 많지 않음에도 Spring에 의존하는 테스트 때문에 테스트 시간이 아주 오래 걸린다.
2. 단위 테스트와 통합 테스트를 구분할 필요가 있다.
 (1) 단위 테스트는 외부 의존성에서 독립된 테스트다.
     - 사실상 거의 실행 시간을 소모하지 않는다.
 (2) 통합 테스트는 외부 의존성(DB와 같은)을 실제로 이용하는 테스트다.
    - 시간이 오래 걸린다.
 (3) 특히, Service 레이어의 테스트에서 Repository를 테스트 대역으로 변경하는 것이 좋을 것 같다. 여기서 굳이 실제 Repository를 사용할 필요가 없다. 모두 인터페이스이기 때문에 더욱 그렇다.

 

문제점 1. 테스트 코드 구조 변경

- 현재 테스트가 그렇게 많지 않음에도 Spring에 의존하는 테스트 때문에 테스트 시간이 아주 오래 걸린다.

 

실상

- Spring에 의존하는 테스트는 꼭 필요한 테스트이다. 특히 DB의 연결은 꼭 필요하다. DB의 환경이 바뀌었을때 테스트가 실패하면 빠르게 오류를 파악할 수 있다.

DB를 Mock으로 대체했을 경우, 이러한 오류를 잡을 수 없다. 이는 가치있는 테스트가 아니다.

 

문제점 2. 단위 테스트와 통합 테스트를 구분할 필요가 있다.

(1) 단위 테스트는 외부 의존성에서 독립된 테스트다.
     - 사실상 거의 실행 시간을 소모하지 않는다.
 (2) 통합 테스트는 외부 의존성(DB와 같은)을 실제로 이용하는 테스트다.
    - 시간이 오래 걸린다.
 (3) 특히, Service 레이어의 테스트에서 Repository를 테스트 대역으로 변경하는 것이 좋을 것 같다. 여기서 굳이 실제 Repository를 사용할 필요가 없다. 모두 인터페이스이기 때문에 더욱 그렇다.

 

실상

- "단위 테스트와 통합 테스트를 구분할 필요가 있다" 라는 뜻은 Service 레이어는 비즈니스 로직을 다루는 계층으로써, DB에 의존하지 않는 테스트이여야 한다 라는 뜻으로 적었다.

하지만, 문제점1의 실상에서 이야기했듯이 실제 DB를 연결한 테스트는 필요하다.

- (2)에서 통합테스트는 시간이 오랜걸린다고 언급했다.

테스트는 빠르게 동작해야하므로 시간 단축하는 것은 중요하다. 하지만 테스트 해야하는 것을 테스트 하지 못하는 테스트가 필요할까?


테스트 코드 리팩토링 2탄

우선 테스트 리팩토링 1탄에서 진행한 리팩토링에 대해서 설명하고, 이를 어떻게 다시 리팩토링 했는지 설명한다.

 

1탄의 리팩토링

@SpringBootTest를 제거하기 위해, Repository 객체를 직접 구현한 Mock으로 대체

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class InMemoryStudyOnceCommentRepository	implements StudyOnceCommentRepository {
	...
    
	@Override
	public List<StudyOnceComment> findAllByStudyOnceId(@NonNull Long studyOnceId) {
		return memory.stream()
			.filter(studyOnceComment -> studyOnceId.equals(studyOnceComment.getStudyOnce().getId()))
			.collect(Collectors.toList());
	}

	@Override
	public List<StudyOnceComment> findAll(Sort sort) {
		throw new UnsupportedOperationException("필요하면 구현하세요!");
	}
    
    ...
    }

 

implements 한 StudyOnceCommentRepository 는 스프링 데이터 Jpa의 Repository 이다.

InMemoryStudyOnceCommentRepository 라는 이름의 직접 작성한 Mock 객체를 만들고

스프링 데이터 Jpa에서 선언해놓은 메서드를 다시 직접 구현하고 있다.

이는 직접 작성한 코드를 신뢰 할 수 없을뿐만 아니라, 테스트에 Repository에 대해서 구현 세부사항을 노출하는 것이다.

 

Mock 객체를 사용한 테스트 코드 구성

class StudyOnceCommentServiceTest {
	
    private InMemoryStudyOnceCommentRepository repository = 
    	new InMemoryStudyOnceCommentRepository(...);
        
   @Test
    void 테스트() {
    	repository.save(...); // 목 Repository 를 직접 사용
    }
}

 

@SpringBootTest를 제거하고 실제 객체 대신 목 Repository를 사용한다.

 

2탄의 리팩토링

개선

  • 제거했던 @SpringBootTest를 다시 살린다.
  • H2와 같이 인메모리로 돌아가는 DB 대신에, 실제 환경과 동일한 DB를 사용한다.
@SpringBootTest
@Transactional
class StudyOnceServiceRepositoryTest {
	
    @Autowired
    private StudyOnceCommentRepository repository;
        
   @Test
    void 테스트() {
    	repository.save(...); // 의존 주입된 실제 객체를 사용
    }
}

 

코드 상에는 나타나지 않았지만 실제 환경과 동일한 DB를 사용한다.

데이터베이스 테스트를 잘 만들면 버그로부터 훌륭히 보호할 수 있다.
이러한 도구 없이는 해당 소프트웨어를 완전히 신뢰할 수 없다.
이러한 테스트는 데이터베이스를 리팩터링하거나 ORM을 전환하거나 데이터베이스 공급업체를 변경할 때 큰 도움이 된다.
관리 의존성에 직접 작동하는 통합 테스트는 대규모 리팩터링에서 발생하는 버그로부터 보호하기에 가장 효율적인 방법이다.
- 단위 테스트 10장 데이터베이스 테스트 -

1탄의 리팩토링

테스트 픽스쳐를 쉽게 만드는 PersistHelp 클래스 구현

public class MemberPersistHelper {

	@PersistenceContext
	private EntityManager em;
	
    // 기본적인 데이터만 가지고 있는 Member 객체를 생성해주는 메서드
	public Member persistDefaultMember(ThumbnailImage thumbnailImage) {
		Member member = new TestMemberBuilder().thumbnailImage(thumbnailImage).build();
		em.persist(member);
		return member;
	}
	
    // 이름을 파라미터로 받아 Member 객체를 생성해주는 메서드
	public Member persistMemberWithName(ThumbnailImage thumbnailImage, String name) {
		Member member = new TestMemberBuilder().name(name).thumbnailImage(thumbnailImage).build();
		em.persist(member);
		return member;
	}
}

 

Persist(Save) 를 도와주는 Helper 클래스이다.

Helper 클래스는 EntityManager를 의존하고 있다.

이는 추상화 계층을 의존하지 않고 실제 구현객체인 EntityManager를 의존하고 @PersistenceContext 을 이용해 필드 주입을 받고 있다.

이는 hibernate의 기술에 의존하고 있을 뿐만 아니라 DI를 위반한다.

 

필자는 프로덕션 코드가 아닌 테스트에서 사용하고 있는 Helper 객체여서 특정한 기술에 의존해도 상관없다고 생각한다.

하지만 테스트 또한 프로덕션 코드와 같이 관리해야할 대상이다.

 

기술이 바뀌면 테스트 또한 깨지게 되어 리팩토링 내성이 저하될 것이다.

 

이를 개선해보자.

 

2탄의 리팩토링

개선

  • EntityManager를 의존하지 말고, 추상화인 Repository 인터페이스를 의존한다.
@RequiredArgsConstructor
public class MemberSaveHelper {

	private final MemberRepository memberRepository;

	public Member saveMember(ThumbnailImage thumbnailImage) {
		Member member = TestMemberFactory.createMemberWithThumbnailImage(thumbnailImage);
		return memberRepository.save(member);
	}

	public Member saveMemberWithName(ThumbnailImage thumbnailImage, String name) {
		Member member = TestMemberFactory.createMemberWithThumbAndName(thumbnailImage, name);
		return memberRepository.save(member);
	}
}

 

이름도 Persist에서 Save로 조금 더 직관적으로 바꾸었다.


 

이번에는 1차 리팩토링에서 진행하지 않은 리팩토링이다.

좋은 테스트와 좋지 않은 테스트를 구별하기

1차 리팩토링 당시 약 240개의 테스트가 존재하였는데, 좋은 테스트는 남기고 좋지 않은 테스트는 리팩토링을 하거나 삭제했다.

이로써 테스트의 개수는 약 200개로 줄어들었다.

 

테스트 판별에 대해서는 내용이 많아서 따로 포스팅하였다.

 

좋지 않은 테스트 판별하기

좋지 않은 테스트를 작성하는 것보다는 테스트를 작성하지 않는 것이 좋다.가치가 별로 없는 테스트는 좋지 않은 테스트다.실제 프로젝트에서 사용하고 있는 테스트를 검토하며 좋은 테스트인

ghffu405.tistory.com


 

이번에도 1차 리팩토링에서 적용하지 않은 리팩토링이다.

테스트 코드에서 테스트 픽스쳐를 생성하는 클래스만 사용해서 리팩토링 내성 높히기

기존 테스트에서는 테스트 픽스쳐 (객체)를 생성하는 방식이 여러가지였다.

객체를 생성하는 방식이 여러가지라는 것은 관리 포인트가 증가한다는 것이고

관리 포인트의 증가는 리팩토링 내성이 낮다는 의미를 뜻한다.

 

AS-IS

	// 첫번쨰 단위 테스트
	@Test
	@DisplayName("카공 시작시간 1시간 전까지 카공 참여신청이 가능하다.")
	void allows_joining_until_1hour_before_start() {
		//given
		Member leader = TestMemberFactory.createMember();
		Member member = TestMemberFactory.createMember();
		...
	}
    
    // 객체 생성을 편하게 해주는 테스트에서만 사용하는 Factory 클래스
    public class TestMemberFactory {

        public static Member createMember() {
            return Member.builder()
                .name("김동현")
                .email("cafegory@gmail.com")
                .thumbnailImage(ThumbnailImage.builder().thumbnailImage("testUrl").build())
                .build();
        }
    }

 

첫번째 단위 테스트에서는 TestMemberFactory 객체를 이용해서 테스트 픽스쳐를 생성하고 있다.

    // 두번쨰 단위 테스트
	@Test
	@DisplayName("카공 시작시간 1시간 전까지 카공 참여신청이 가능하다.")
	void allows_joining_until_1hour_before_start() {
		//given
		Member leader = makeMember();
		Member member = makeMember();
		...
	}
    
        private Member makeMember() {
            return new Member(...);
        }

 

두번째 단위 테스트에서는 테스트 클래스 내의 private 메서드를 통해서 테스트 픽스쳐를 생성하고 있다.

    // 통합 테스트
    @Test
	@DisplayName("카공 진행시간 절반이 지나면 출석체크를 할 수 없다.")
	void leader_can_not_check_attendance_after_half_whole_study_time() {
		//given
		Member leader = memberSaveHelper.saveMember(thumbnailImage);
		Member member = memberSaveHelper.saveMember(thumbnailImage);
        	...
	}
    
    public class MemberSaveHelper {

        private final MemberRepository memberRepository;

        public Member saveMember(ThumbnailImage thumbnailImage) {
            Member member = new Member(...);
            return memberRepository.save(member);
        }
   }

 

통합 테스트에서는 SaveHelper 클래스를 통해서 객체를 생성하고 repository에 저장한 뒤 객체를 반환하고 있다.

 

다시 정리하면

  • 첫번째 단위 테스트에서는 TestMemberFactory 객체를 이용해서 테스트 픽스쳐를 생성하고 있다.
  • 두번째 단위 테스트에서는 테스트 클래스 내의 private 메서드를 통해서 테스트 픽스쳐를 생성하고 있다.
  • 통합 테스트에서는 SaveHelper 클래스를 통해서 객체를 생성하고 repository에 저장한 뒤 객체를 반환하고 있다.

객체를 여러가지 방법으로 생성하고 있다.

 

이는 관리 포인트가 증가하는 것으로 클래스가 수정이 되면 컴파일 오류가 나게 되면서 리팩토링 내성이 저하된다.

 

다음과 같이 수정해보자.

 

TO-BE

	public class TestMemberFactory {

        public static Member createMember() {
            return Member.builder()
                .name("김동현")
                .email("cafegory@gmail.com")
                .thumbnailImage(ThumbnailImage.builder().thumbnailImage("testUrl").build())
                .build();
        }
    }
    
    public class MemberSaveHelper {

        private final MemberRepository memberRepository;

        public Member saveMember(ThumbnailImage thumbnailImage) {
        	// TestMemberFactory 를 SaveHelper 에서 사용
            Member member = TestMemberFactory.createMember();
            return memberRepository.save(member);
        }
   }

 

바뀐 내용을 보면 아래와 같다.

        public Member saveMember(ThumbnailImage thumbnailImage) {
            Member member = new Member(...);
            return memberRepository.save(member);
        }
        
        public Member saveMember(ThumbnailImage thumbnailImage) {
        	// TestMemberFactory 를 SaveHelper 에서 사용
            Member member = TestMemberFactory.createMember();
            return memberRepository.save(member);
        }

 

saveMember 에서 new 를 통해 생성하지 않고 Factory를 이용했다.

	// 첫번쨰 단위 테스트
	@Test
	@DisplayName("카공 시작시간 1시간 전까지 카공 참여신청이 가능하다.")
	void allows_joining_until_1hour_before_start() {
		//given
		Member leader = TestMemberFactory.createMember();
		Member member = TestMemberFactory.createMember();
		...
	}
    
    	// 두번쨰 단위 테스트
	@Test
	@DisplayName("카공 시작시간 1시간 전까지 카공 참여신청이 가능하다.")
	void allows_joining_until_1hour_before_start() {
		//given
		Member leader = TestMemberFactory.createMember();
		Member member = TestMemberFactory.createMember();
		...
	}
    
    	// 통합 테스트
    	@Test
	@DisplayName("카공 진행시간 절반이 지나면 출석체크를 할 수 없다.")
	void leader_can_not_check_attendance_after_half_whole_study_time() {
		//given
		Member leader = memberSaveHelper.saveMember(thumbnailImage);
		Member member = memberSaveHelper.saveMember(thumbnailImage);
        	...
	}

 

  • 엔티티(객체) 생성은 Factory 클래스가 담당한다.
  • 저장을 도와주는 SaveHelper 클래스 내에서도 Factory 클래스에 의존한다.
  • 엔티티 생성은 Factory클래스만 담당하므로, 엔티티가 수정되도 Factory 클래스만 수정하면 된다.

위와 같이 변경함으로써 리팩토링 내성이 증가한다.


 

지금까지 테스트 코드 리팩토링에 대해서 보았다.

 

좋은 테스트를 위한 리팩토링이 진행되었는데 그럼 좋은 테스트란 무엇일까?

  • 회귀 방지
  • 리팩토링 내성
  • 빠른 피드백
  • 유지 보수성

회귀 방지가 잘되고 리팩토링 내성이 높고 빠른 피드백을 받으면서 유지 보수성이 좋은것이 좋은 테스트라고 볼 수 있다.

여기서 다루었던 대부분 내용은 리팩토링 내성을 높히는 방향으로 리팩토링을 진행하였다.

 

필자의 블로그에 테스트 관련 포스팅이 많으니 궁금하면 찾아봐도 좋을 것 같다.