https://www.yes24.com/Product/Goods/75189146?OzSrank=2
martinfowler
https://martinfowler.com/articles/mocksArentStubs.html
Stub / Mock
- Stub / Mock에 대하여 비슷한 부분이 있지만 완전히 같은거는 아닙니다.
- 둘다 가짜객체이지만 미리 준비한 결과를 제공은 비슷하지만 가장 큰 차이점은 다음과 같습니다.
- Stub : 상태 검증
- 미리 제공한 결과에 대한 받을 수 있고 이후 변화된 상태를 검증이 가능하다.
- Mock : 행위 검증
- 메소드를 하였을 때 Return의 값을 검증
1. 과도한 의존성 분리
Test Double을 남용하면 실제 코드와의 연결성이 느슨해지고, 테스트와 실제 동작 간의 불일치가 생길 수 있습니다. 이로 인해 테스트는 잘 동작할 수 있지만, 실제 운영 환경에서 예상치 못한 문제가 발생할 수 있습니다.
2. 가독성 저하
테스트 코드 내에 많은 Mocks, Stubs, Spies 등이 사용되면 코드의 가독성이 저하될 수 있습니다. 코드 내에서 어떤 부분이 실제 구현이고 어떤 부분이 테스트용 객체인지 구분하기 어려울 수 있습니다.
3. 유지보수 어려움
Test Double을 오래 사용하면서 원래 코드가 변경되면, 이에 따라 Test Double도 변경되어야 할 수 있습니다. 이 경우 많은 테스트 코드를 수정해야 할 수 있으며, 그로 인해 유지보수가 어려워질 수 있습니다.
4. Mocking 오버헤드
테스트 Double을 생성하고 설정하는 데 걸리는 시간이 실제 코드를 직접 실행하는 것보다 더 오래 걸릴 수 있습니다. 작은 규모의 단위 테스트의 경우 이 오버헤드가 큰 문제가 되지 않지만, 대규모 시스템에서는 테스트 속도에 영향을 미칠 수 있습니다.
5. 변경에 대한 취약성
실제 코드의 변경이 Test Double에 의존하는 테스트 코드에 영향을 미칠 수 있습니다. 실제 코드 변경 없이도 테스트 코드가 실패하는 경우가 발생할 수 있습니다.
6. 완전한 테스트 커버리지 보장의 어려움
모든 코드 경로를 커버하는 테스트를 작성하기 위해 너무 많은 종류의 Test Double을 만들어야 할 수 있습니다. 이렇게 되면 테스트 코드의 복잡성이 증가하며 유지보수가 어려워질 수 있습니다.
이러한 이유로 Test Double을 사용할 때는 적절한 균형을 유지해야 합니다. 단위 테스트의 목적은 실제 코드의 동작을 검증하는 것이므로, 필요한 경우에만 Test Double을 사용하고 실제 의존성을 최대한 활용하여 실제 동작을 검증하는 테스트를 작성하는 것이 중요합니다.
Mock을 쉽게 만들고 mock의 행동을 정하는 stubbing 로직이 실행이 되었는지 확인하기 위한 verify()를 이용이 가능하게 해주는 프레임워크 입니다.
public interface MyService {
String getName();
}
@Test
public void testMyService() {
MyService myService = mock(MyService.class);
when(myService.getName()).thenReturn("Mockito");
String name = myService.getName();
assertEquals("Mockito", name);
}
그래서 service에 getName()을 실행하면 위에 설정한 Mockito가 호출이 됩니다.
https://ko.wikipedia.org/wiki/%EB%AA%A8%EC%9D%98_%EA%B0%9D%EC%B2%B4
모의 객체를 사용하면 DB에서 값을 읽어오지 않습니다. 즉. DB와 연결을 하지 않는다.
테스트의 관점에서 보게되면 DB와 연결을 하면 위험합니다.
모의 객체를 사용하지 않으면 DB와 연결이 된다. 그렇게 되면 매번 작업을 하면 DB와 연동을 하기 때문에 부하가 많이 걸리게 됩니다. 이렇게 되면 테스트의 시간이 증가하게 됩니다.
테스트 코드를 통해 설명을 하겠습니다.
Mock과 Spy는 모두 Mockito에서 제공하는 목 객체(Mock Object)를 생성하는 방법 중 두 가지입니다. 하지만 Mock과 Spy는 목적이 다르므로 사용 시 주의해야 합니다.
Mock 객체는 완전한 가짜 객체로, 객체의 메서드 호출을 감시하고 기대하는 동작을 수행합니다. 실제 객체를 대체하여 테스트하기 때문에, 특정한 기능의 메서드를 호출하였을 때 기대한 대로의 반환값을 받아올 수 있는지, 메서드가 적절한 매개변수를 받는지 등을 검증할 때 사용합니다. Mock 객체는 Mockito에서 제공하는 mock() 메서드나 @Mock 어노테이션을 사용하여 생성할 수 있습니다.
Spy 객체는 실제 객체의 일부분을 대신하는 객체로, 실제 객체의 동작을 유지하면서 일부 기능을 변경하거나 추가할 때 사용됩니다. 특정한 객체의 일부 메서드를 호출할 때만 Mock 객체와 같은 방식으로 동작하지만, 그 외에는 실제 객체와 동일한 기능을 수행합니다. Spy 객체는 Mockito에서 제공하는 spy() 메서드나 @Spy 어노테이션을 사용하여 생성할 수 있습니다.
따라서 Mock 객체는 완전한 가짜 객체로 객체의 일부 메서드를 호출하지 않으며, Spy 객체는 실제 객체의 일부분을 가짜로 만들어서 사용합니다. 보통 Mock 객체는 객체의 메서드 호출을 검증하거나, 객체의 동작을 예측하는데 사용하며, Spy 객체는 실제 객체를 조작하거나 변경할 때 사용합니다.
https://scshim.tistory.com/439
https://sun-22.tistory.com/93?category=371101
Mockist
대부분 가짜 객체를 사용하여 의존성을 제거한다.
service 테스트 시 repository를 가짜 객체로 사용하는 것과 같다.
가짜 객체를 사용하기 때문에 내부 구현을 커스텀한 응답으로 응답해야 한다.
특정 행위가 실행되었는지 검증한다. (행위 검증)
Classicist (클래식주의자)
실제 프로젝트에서는 다른 의존성을 사용하며 일관성을 보장하기 위해 실제 로직을 테스트 한다.
물론 제어할 수 없는 코드는 Test Double을 사용해도 상관없음
프로젝트를 진행하면서 Classicist방식으로 진행을 하였습니다. 왜냐하면 모든 코드를 테스트를 하면 오히려 Mock 오버헤드가 발생하며 실제 테스트에 대한 로직을 검증하여 코드의 일관성을 확인하고 싶었기 때문이다.
LocalDataTime
public String createToken(Long id, String email, List<String> roles,
Long expire, byte[] secretKey) {
Claims claims = Jwts.claims().setSubject(email);
claims.put("memberId", id);
claims.put("roles", roles);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date())
.setExpiration(new Date(new Date().getTime() + expire))
.signWith(getSigningKey(secretKey))
.compact();
}
public String createTokenWithDate(Long id, String email, List<String> roles, Long expire, byte[] secretKey, Date date) {
Claims claims = Jwts.claims().setSubject(email);
claims.put("memberId", id);
claims.put("roles", roles);
Date expirationDate = new Date(date.getTime() + expire);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(date)
.setExpiration(expirationDate)
.signWith(getSigningKey(secretKey))
.compact();
}
@Test
@DisplayName("트레이드 오프에 따른 Date 토큰 생성하기")
public void createToken() throws Exception{
// given
Long id = 1L;
String email = "test@example.com";
List<String> roles = Arrays.asList("ROLE_USER", "ROLE_ADMIN");
Long expire = 3600000L; // 1 hour
byte[] secretKey = "0123456789012345678901234567890123456789".getBytes();
Date date = new Date(1656789000000L);//Tue Jun 02 2022 12:30:00 GMT+0000
// when
String token = jwtTokenizer.createTokenWithDate(id, email, roles, expire, secretKey,date);
//Then
assertThat(token).isEqualTo("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibWVtYmVySWQiO" +
"jEsInJvbGVzIjpbIlJPTEVfVVNFUiIsIlJPTEVfQURNSU4iXSwiaWF0IjoxNjU2Nzg5MDAwLCJleHAiOjE2NTY3OTI2MDB9." +
"l8ZHBTJj7hYS3jYdJHYp6Sd0Xf-igWTzWl74u6x75cU");
}
Service Layer 로직
실제 서비스 로직에서 시간을 사용하는 부분은 주로 대회를 처리하는 부분이 있었습니다.
이때 기존에는 LocalDateTime
을 Service 로직에 처리를 했지만 이걸 Controller에 LocalDateTime
에 위임하여 Controller에서 시간을 Service 로직에 매개변수로 전달하여 테스트의 일관성을 보장하게 리펙토링을 하였습니다.
이때 실제 서비스를 사용하기 보다는 Test Double을 사용하여 제어할 수 없는 부분을 일관성 있는 테스트 코드로 변경을 하였습니다.
@Test
public void testGetCompetitionListFinishTrue() {
// given
LocalDateTime now = LocalDateTime.of(2023, 8, 15, 12, 0);
boolean finish = true;
Pageable pageable = Pageable.ofSize(10).withPage(0);
List<Competition> competitionList = new ArrayList<>();
Competition competition = Competition.builder()
.competitionStart(now)
.participants(5)
.competitionEnd(now.plusHours(1))
.competitionTitle("제목")
.build();
competitionList.add(competition);
Page<Competition> competitionPage = new PageImpl<>(competitionList);
//when
when(competitionRepository.findByCompetitionEndBefore(any(), any())).thenReturn(competitionPage);
Page<CompetitionListResponseDto> result = competitionService.getCompetitionList(finish, pageable, now);
//then
CompetitionListResponseDto responseDto = result.getContent().get(0);
assertThat(responseDto.getTitle()).isEqualTo("제목");
assertThat(responseDto.getParticipants()).isEqualTo(5);
verify(competitionRepository).findByCompetitionEndBefore(any(), any());
}
@Test
public void testGetCompetitionListFinishFalse() {
// given
LocalDateTime now = LocalDateTime.of(2023, 8, 15, 12, 0);
boolean finish = false;
Pageable pageable = Pageable.ofSize(10).withPage(0);
List<Competition> competitionList = new ArrayList<>();
Competition competition = Competition.builder()
.competitionStart(now)
.participants(5)
.competitionEnd(now.plusHours(1))
.competitionTitle("제목")
.build();
competitionList.add(competition);
Page<Competition> competitionPage = new PageImpl<>(competitionList);
when(competitionRepository.findByCompetitionEndAfter(any(), any())).thenReturn(competitionPage);
// when
Page<CompetitionListResponseDto> result = competitionService.getCompetitionList(finish, pageable, now);
//then
CompetitionListResponseDto responseDto = result.getContent().get(0);
assertThat(responseDto.getTitle()).isEqualTo("제목");
assertThat(responseDto.getParticipants()).isEqualTo(5);
verify(competitionRepository).findByCompetitionEndAfter(any(), any());
}
@BeforeEach
void setUp(){
List<CreateChoicesAboutQuestionDto> createChoicesAboutQuestionDto = new ArrayList<>();
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(1)
.content("선택 1")
.build());
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(2)
.content("선택 2")
.build());
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(3)
.content("선택 3")
.answer("정답")
.build());
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(4)
.content("선택 4")
.build());
questionService.createQuestionChoice(CreateQuestionAndCategoryRequestDto.builder()
.createQuestionRequestDto(CreateQuestionRequestDto.builder()
.questionTitle("문제 제목")
.questionDesc("문제에 대한 설명")
.questionExplain("문제에 대한 해답")
.build())
.categoryRequestDto(CategoryRequestDto.builder()
.category("네트워크")
.build())
.createChoicesAboutQuestionDto(createChoicesAboutQuestionDto)
.build());
}
@Test
@DisplayName("생성된 문제에 대한 문제 찾기 및 카테고리")
public void findQuestionValidWithChoiceAndCategory() throws Exception {
//given
List<CreateChoicesAboutQuestionDto> createChoicesAboutQuestionDto = new ArrayList<>();
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(1)
.content("선택 1")
.build());
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(2)
.content("선택 2")
.build());
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(3)
.content("선택 3")
.answer("정답")
.build());
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(4)
.content("선택 4")
.build());
CreateQuestionAndCategoryRequestDto createQuestionAndCategoryRequestDto = CreateQuestionAndCategoryRequestDto.builder()
.createQuestionRequestDto(CreateQuestionRequestDto.builder()
.questionTitle("문제 제목")
.questionDesc("문제에 대한 설명")
.questionExplain("문제에 대한 해답")
.build())
.categoryRequestDto(CategoryRequestDto.builder()
.category("네트워크")
.build())
.createChoicesAboutQuestionDto(createChoicesAboutQuestionDto)
.build();
questionService.createQuestionChoice(createQuestionAndCategoryRequestDto);
//when
QuestionResponseDto result = questionService.findQuestionWithChoiceAndCategory(1L);
//Then
assertAll(
() -> assertThat(result.getTitle()).isEqualTo("문제 제목"),
() -> assertThat(result.getDescription()).isEqualTo("문제에 대한 설명"),
() -> assertThat(result.getExplain()).isEqualTo("문제에 대한 해답")
);
assertAll(
() -> assertThat(result.getChoices().get(0).getContent()).isEqualTo("선택 1"),
() -> assertThat(result.getChoices().get(1).getContent()).isEqualTo("선택 2"),
() -> assertThat(result.getChoices().get(2).getContent()).isEqualTo("선택 3"),
() -> assertThat(result.getChoices().get(3).getContent()).isEqualTo("선택 4")
);
assertAll(
() -> assertThat(result.getChoices().get(0).getNumber()).isEqualTo(1),
() -> assertThat(result.getChoices().get(1).getNumber()).isEqualTo(2),
() -> assertThat(result.getChoices().get(2).getNumber()).isEqualTo(3),
() -> assertThat(result.getChoices().get(3).getNumber()).isEqualTo(4)
);
assertThat(result.getCategoryTitle()).isEqualTo("네트워크");
}
@Test
@DisplayName("recursiveCreateQuestionChoice 테스트")
public void recursiveCreateQuestionChoice_Valid() {
// Given
List<CreateChoicesAboutQuestionDto> createChoicesAboutQuestionDto = new ArrayList<>();
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(1)
.content("선택 1")
.build());
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(2)
.content("선택 2")
.build());
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(3)
.content("선택 3")
.answer("정답")
.build());
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(4)
.content("선택 4")
.build());
List<CreateQuestionAndCategoryRequestDto> requestDtos = new ArrayList<>();
requestDtos.add(CreateQuestionAndCategoryRequestDto.builder()
.createQuestionRequestDto(CreateQuestionRequestDto.builder()
.questionTitle("문제 제목")
.questionDesc("문제에 대한 설명")
.questionExplain("문제에 대한 해답")
.build())
.categoryRequestDto(CategoryRequestDto.builder()
.category("네트워크")
.build())
.createChoicesAboutQuestionDto(createChoicesAboutQuestionDto)
.build());
// When
questionService.recursiveCreateQuestionChoice(requestDtos);
// Then
List<Question> questions = questionRepository.findAll();
assertThat(questions).hasSize(1);
Question question = questions.get(0);
assertThat(question.getTitle()).isEqualTo("문제 제목");
assertThat(question.getDescription()).isEqualTo("문제에 대한 설명");
assertThat(question.getExplain()).isEqualTo("문제에 대한 해답");
List<Choice> choices = question.getChoices();
assertThat(choices.get(0).getContent()).isEqualTo("선택 1");
assertThat(choices.get(1).getContent()).isEqualTo("선택 2");
assertThat(choices.get(2).getContent()).isEqualTo("선택 3");
assertThat(choices.get(3).getContent()).isEqualTo("선택 4");
assertThat(choices.get(2).isAnswer()).isTrue();
}
@DisplayName("문제 find & category")
@Nested
class findQuestionWithChoiceAndCategory {
void beforeCreateSet() {
List<CreateChoicesAboutQuestionDto> createChoicesAboutQuestionDto = new ArrayList<>();
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(1)
.content("선택 1")
.build());
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(2)
.content("선택 2")
.build());
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(3)
.content("선택 3")
.answer("정답")
.build());
createChoicesAboutQuestionDto.add(CreateChoicesAboutQuestionDto.builder()
.number(4)
.content("선택 4")
.build());
CreateQuestionAndCategoryRequestDto createQuestionAndCategoryRequestDto = CreateQuestionAndCategoryRequestDto.builder()
.createQuestionRequestDto(CreateQuestionRequestDto.builder()
.questionTitle("문제 제목")
.questionDesc("문제에 대한 설명")
.questionExplain("문제에 대한 해답")
.build())
.categoryRequestDto(CategoryRequestDto.builder()
.category("네트워크")
.build())
.createChoicesAboutQuestionDto(createChoicesAboutQuestionDto)
.build();
questionService.createQuestionChoice(createQuestionAndCategoryRequestDto);
}
https://jojoldu.tistory.com/226
https://scshim.tistory.com/439
https://sun-22.tistory.com/93?category=371101
https://ko.wikipedia.org/wiki/%EB%AA%A8%EC%9D%98_%EA%B0%9D%EC%B2%B4