본문 바로가기

테스트

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

테스트 코드 리팩토링 1탄의 방식으로 리팩토링 한 뒤, 다시 원래 사용하던 @SpringBootTest로 롤백했다.

속도면에서 개선된 것은 좋았으나, 속도라는 장점을 얻기 위해 많은 단점을 가져가야했다.

 

테스트 코드 리팩토링 2탄에서는 속도를 포기하고 다시 새롭게 리팩토링을 했다.

1탄과 2탄을 비교하여 어떤 점이 장점이고 단점인지 비교해봐도 좋을 것 같다.


프로젝트 진행중에 백엔드 회의에서 리팩토링에 대한 이야기가 나왔다

기능구현을 하면서 지속적으로 하는 회귀 테스트 또는 CI/CD를 통해서 지속적으로 빌드 를 통해서 코드를 작성할때 테스트 코드가 정상적으로 동작하는지 확인을 많이 하게된다.

그로 인해, 테스트 코드 리팩토링 작업이 필요하다는 의견이 나왔다.

 

문제

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

 

위와 같은 의견이 나왔고, 테스트 코드에 대한 리팩토링을 진행하려 한다.

(3)은 narrow integration test을 적용한다.

narrow integration test 가 궁금하다면 다음 글 을 참고하자.

 

 

리팩토링하기 전 코드를 한번 보자.

🌈 서비스 레이어 테스트 클래스

@SpringBootTest
@Transactional
@Import(TestConfig.class)
class StudyOnceCommentServiceImplTest {

	@Autowired
	private StudyOnceCommentService studyOnceCommentService;
	@Autowired
	private StudyOnceCommentRepository studyOnceCommentRepository;
	@Autowired
	private MemberPersistHelper memberPersistHelper;
	@Autowired
	private ThumbnailImagePersistHelper thumbnailImagePersistHelper;
	@Autowired
	private StudyOncePersistHelper studyOncePersistHelper;
	@Autowired
	private CafePersistHelper cafePersistHelper;
	@Autowired
	private StudyOnceCommentPersistHelper studyOnceCommentPersistHelper;
	@Autowired
	private EntityManager em;

	@Test
	@DisplayName("카공 질문을 저장한다.")
	void save_question() {
		//given
		ThumbnailImage thumb = thumbnailImagePersistHelper.persistDefaultThumbnailImage();
		MemberImpl leader = memberPersistHelper.persistMemberWithName(thumb, "카공장");
		MemberImpl otherPerson = memberPersistHelper.persistMemberWithName(thumb, "홍길동");
		CafeImpl cafe = cafePersistHelper.persistDefaultCafe();
		StudyOnceImpl studyOnce = studyOncePersistHelper.persistDefaultStudyOnce(cafe, leader);
		
        //when
		studyOnceCommentService.saveQuestion(otherPerson.getId(), studyOnce.getId(),
			new StudyOnceCommentRequest("몇시까지 공부하시나요?"));
		em.flush();
		em.clear();
		List<StudyOnceComment> questions = studyOnceCommentRepository.findAll();
        
        //then
		assertThat(questions.size()).isEqualTo(1);
	}

 

실제 프로젝트에 있는 하나의 테스트 코드를 가져온 것이다.

@SpringBootTest 어노테이션을 통해 필요한 Bean들을 주입받아 테스트 하는것을 볼 수 있다.

현재 글 작성 당시, 프로젝트에 존재하는 테스트에는 단위테스트 및 통합테스트의 수가 170개정도 존재하고, SpringBootTest의 개수가 절반이상을 차지하는것으로 보인다.

 

위의 서비스 레이어 테스트 클래스의 필드를 자세히 보면 아래와 같은 PersistHelper 객체를 볼 수 있다.

@Autowired
private MemberPersistHelper memberPersistHelper;

 

테스트를 더욱 편하고 빠르고 쉽게 하기위해 만든 객체이다.

PersistHelper이 어떻게 만들어졌는지 궁금하다면 다음을 참고하자.

 

🌈 간단히 보고가는 PersistHelper 클래스 내부

더보기

간단히 보고가는 PersistHelper 클래스 내부

public class MemberPersistHelper {

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

 

위 코드는 Member에 대한 PersistHelper 클래스이다.

PersistHelper는 엔티티를 더욱 쉽게 DB에 저장하기 위해서 만들어진 테스트를 위한 객체이다. 

 

필드로 EntityManager가 존재한다.

상황에 따라서 엔티티의 필드 초기화가 다르므로, 상황에 맞는 엔티티를 생성해주는 것을 의미하는 메서드들이 존재한다.

 

 

 

 

🌈 리팩토링 하기 전 테스트 빌드 시간

리팩토링 전 테스트 빌드 시간

 

171개의 테스트가 돌아가는데 4.11초가 걸렸다.

많지 않은 수의 테스트임에도 불구하고, 4초대가 걸렸다.

SpringBootTest를 사용하지 않는다면 속도가 얼마나 개선이 될까?

 

🌈 테스트 더블

SpringBootTest를 통한 Service 레이어에서의 테스트는 실제 Bean 에게 의존하고 있다.

SpringBootTest에 의존하지 않기위해,

테스트 더블의 한 종류인 Test Stub 객체(실제 프로덕션 코드에서 동작하는 객체가 아닌, 테스트를 위한 가짜 객체) 를 만들어 테스트가 동작할때 Test Stub 객체로 대신해서 동작하게 할것이다.

 

테스트 더블에 관한 내용은 다음을 참고하자

https://tecoble.techcourse.co.kr/post/2020-09-19-what-is-test-double/

 

Test Double을 알아보자

테스트 더블(Test Double)이란? xUnit Test Patterns의 저자인 제라드 메스자로스(Gerard Meszaros…

tecoble.techcourse.co.kr

 

🌈 인메모리 테스트 객체

기존 SpringBootTest에서 의존하던 Bean들을 테스트를 위한 객체로 대체해서 테스트를 진행 할 것이다.

기존 테스트에서는 Repository 를 통해서 실제 DB에 저장이 됐지만, 인메모리 Repository로 변경함으로써 메모리에 저장이 될것이다.

아래 코드는 Test Stub 객체(인메모리 테스트 객체)의 일부분이다.

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class InMemoryStudyOnceCommentRepository	implements StudyOnceCommentRepository {
	private final List<StudyOnceComment> memory = new ArrayList<>();
	public static final InMemoryStudyOnceCommentRepository INSTANCE = new InMemoryStudyOnceCommentRepository();

	private StudyOnceComment makeStudyOnceCommentWithId(StudyOnceComment studyOnceComment) {
		if (studyOnceComment.getId() != null) {
			return studyOnceComment;
		}
		return StudyOnceComment.builder()
			.id(count() + 1)
			.studyOnce(studyOnceComment.getStudyOnce())
			.member(studyOnceComment.getMember())
			.content(studyOnceComment.getContent())
			.children(studyOnceComment.getChildren())
			.parent(studyOnceComment.getParent())
			.build();
	}

	@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() {
		return List.copyOf(memory);
	}

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

 

Spring Data Jpa, 즉 JpaRepository의 필요한 부분만 직접 구현함으로써 Test Stub 객체를 만들었다.

public class InMemoryStudyOnceCommentRepository implements StudyOnceCommentRepository {

 

🌈 PersistHelper는 어댑터 패턴을 사용

리팩토링 전 테스트 코드에서는 PersistHelper 를 통해서 테스트에 필요한 엔티티들을 쉽게 저장한다 했다.

PersistHelper는 EntityManager를 의존하고 있고, EntityManager를 사용하려면 통합테스트는 필수적이다.

Service 레이어를 SpringBootTest에 의존하지 않기 위해서 EntityManager를 Fake 객체로 대체해야만 한다.

 

또한 PersistHelper 객체는 JPA를 사용하고 있는 Repository 를 테스트 할때도 사용하고 있다.

즉, 상황에 따라서 실제 EntityManager 와 Fake EntityManager가 필요하다.

 

이런 상황을 극복하기 위해 어댑터 패턴을 사용했다.

 

어댑터 패턴 구조

 

위와 같은 구조로 어댑터 패턴이 구현했다.

 

위의 어댑터 패턴 구조를 보고 이상함을 느꼈을 수도 있다.

InMemoryRepository가 어떻게 EntityManager의 구현체가 될 수 있나? 라는 생각을 했을 수도 있다.

이 부분은 코드를 한번 보자

 

public class InMemoryEntityManagerAdaptor<T> implements EntityManagerForPersistHelper<T> {
	private final Function<T, T> function;

	public InMemoryEntityManagerAdaptor(Function<T, T> function) {
		this.function = function;
	}

	@Override
	public T save(T entity) {
		return function.apply(entity);
	}
}

 

InMemoryEntityManagerAdaptor는 위와 같이 되어있는것을 볼 수 있다.

필드로 Function 함수형 인터페이스를 가지고 있다.

 

다음 코드를 보자.

PersistHelper persistHelper = new PersistHelper(
		new InMemoryEntityManagerAdaptor<>(InMemoryRepository.INSTANCE::save));

 

위와 같이 InMemoryRepository의 save 메서드를 전달 해줌으로써 함수형 인터페이스를 대신하게 된다.

 

🌈 Test Stub 객체를 구현할때, 모든 메서드를 다 구현해야할까?

아니다.

실제 프로덕션 코드가 아니라, 테스트를 위한 코드이기때문에 모든 메서드를 다 구현할 필요는 없다.

 

그렇다면, 테스트를 위한 코드를 구현을 해야하는데 구현해야하는 로직이 복잡하다.

그러면 그 코드를 구현해야 맞을까?

 

개인적인 의견으로 구현하지 않는것이 좋다고 생각했다.

구현해야하는 로직이 복잡하다면,

1. 테스트를 위한 비용이 너무 많이 든다.

2. 로직이 복잡하면 구현한 로직에 대해 신뢰가 떨어진다.

 

테스트를 하는 목적이 무엇인가?

작성한 코드에 대한 신뢰이다. 테스트를 위한 Test Stub 객체의 메서드를 작성하는데 그 메서드를 신뢰 할 수 없다.

그러면 그 코드를 위한 테스트를 또 작성해야하는가?

배보다 배꼽이 더 커지는 상황을 만나게 될것이다.

 

🌈리팩토링 후 테스트 빌드 시간

리팩토링 후 테스트 빌드 속도

 

리팩토링 후 테스트 빌드 시간은 1초 555밀리초이다.

기존 4초 11밀리초 -> 1초 555밀리초로 절반 이상의 시간을 줄였다.