https://www.inflearn.com/course/the-java-application-test
강의 내용 정리
Mockito 소개
Mock: 진짜 객체와 비슷하게 동작하지만 프로그래머가 직접 그 객체의 행동을 관리하는 객체.Mockito: Mock 객체를 쉽게 만들고 관리하고 검증할 수 있는 방법을 제공한다. Mock 프레임워크 중 가장 많이 사용된다.
단순한 로직의 단위 테스트는 간단하게 할 수 있지만, 만약 애플리케이션이 DB를 사용한다던가 외부 API를 호출한다고 하면, DAO 객체나 외부 API가 어떻게 동작하는지 Mock을 사용해 실제 API 호출 없이 사전에 테스트를 해볼 수 있다.
"모든 객체를 Mock으로 만들어야 단위 테스트다" 라는 견해가 있지만, 강의에서는 이미 구현이 되어있는 클래스를 굳이 Mock으로 만들 필요가 없다고 말한다. 다만 외부 서비스는 Mock 객체를 사용하는게 좋지만, 외부 서비스 조차도 자체 테스트 환경을 제공한다면 그걸 그대로 사용해도 괜찮다고 한다.
Mockito 시작하기
스프링 부트 2.2+ 프로젝트 생성시 spring-boot-starter-test에서 자동으로 Mockito를 추가해 준다.
스프링 부트를 쓰지 않는다면, 의존성을 직접 추가한다.
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>3.1.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>3.1.0</version>
<scope>test</scope>
</dependency>
Mockito 객체 만들기
우선 테스트에 사용할 도메인과 레포지토리, 서비스 클래스를 정의한다.
<Member>
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Member {
@Id
@GeneratedValue
private Long id;
private String email;
}
<Study>
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Study {
@Id
@GeneratedValue
private Long id;
private StudyStatus status = StudyStatus.DRAFT;
private int limitCount;
private String name;
private LocalDateTime openedDateTime;
@ManyToOne
private Member owner;
public Study(int limit, String name) {
this.limitCount = limit;
this.name = name;
}
public Study(int limit) {
if (limit < 0) {
throw new IllegalArgumentException("limit은 0보다 커야 한다.");
}
this.limitCount = limit;
}
public void publish() {
this.openedDateTime = LocalDateTime.now();
this.status = StudyStatus.OPENED;
}
}
<StudyStatus>
public enum StudyStatus {
DRAFT, OPENED, STARTED, ENDED
}
<StudyRepository>
public interface StudyRepository extends JpaRepository<Study, Long> {
}
<MemberService>
public interface MemberService {
Optional<Member> findById(Long memberId);
}
<StudyService>
public class StudyService {
private final MemberService memberService;
private final StudyRepository repository;
public StudyService(MemberService memberService, StudyRepository repository) {
assert memberService != null;
assert repository != null;
this.memberService = memberService;
this.repository = repository;
}
public Study createNewStudy(Long memberId, Study study) {
Optional<Member> member = memberService.findById(memberId);
study.setOwner(member.orElseThrow(() -> new IllegalArgumentException("Member doesn't exist for id: '" + memberId + "'")));
return repository.save(study);
}
}
StudyService에 대한 테스트를 할 것이기 때문에, StudyService를 주의깊게 보자. MemberService와 StudyRepository는 현재 interface로 구현 되어 있고, 구현체는 따로 존재하지 않는다. 이런 경우가 Mock 객체를 사용하기 좋은 환경이다.
구현체가 없이 인터페이스만 알고 있는데 이러한 인터페이스기반 코드가 제대로 작동하는지 확인하기 위해 Mock 객체를 사용할 것이다.
우선 Mock이 없다고 가정하고 테스트를 해보자.
class StudyServiceTest {
@Test
void createStudy() {
MemberService memberService = new MemberService() {
@Override
public Optional<Member> findById(Long memberId) {
...
}
};
StudyRepository studyRepository = new StudyRepository() {
@Override
public List<Study> findAll() {
...
}
@Override
public List<Study> findAll(Sort sort) {
...
}
...
};
StudyService studyService = new StudyService(memberService, studyRepository);
assertNotNull(studyService);
}
}
StudyService의 경우 MemberService와 StudyRepository 두 인터페이스 객체를 생성자 주입해주어야 하기 때문에, 테스트 시 두 인터페이스의 구현체가 필요하다.
그러나 현재 따로 구현해놓은 객체가 없기 때문에 이처럼 테스트시 구현체를 직접 정의해서 사용해야 한다. 그러나 일일이 메서드를 매번 정의하는 것도 번거롭고 repository의 경우 JpaRepository가 제공하는 모든 메서드를 구현해야 하는 아찔한 경험을 할 수 있다.
따라서 구현체를 임의로 생성할 수 있는 Mock 객체가 필요하다.
class StudyServiceTest {
@Test
void createStudy() {
MemberService memberService = Mockito.mock(MemberService.class);
StudyRepository studyRepository = Mockito.mock(StudyRepository.class);
StudyService studyService = new StudyService(memberService, studyRepository);
assertNotNull(studyService);
}
}
코드가 눈에 띄게 줄었다. Mockito를 사용해서 각 인터페이스들의 가상의 구현체들을 생성할 수 있다.
또한 간단하게 애노테이션으로도 Mock 객체를 생성할 수 있다.
@ExtendWith(MockitoExtension.class)
class StudyServiceTest {
@Mock
MemberService memberService;
@Mock
StudyRepository studyRepository;
@Test
void createStudy() {
StudyService studyService = new StudyService(memberService, studyRepository);
assertNotNull(studyService);
}
}
이 때 주의할 점은, @Mock만 붙이면 객체가 실제로 만들어지지 않아서. @ExtendWith(MockitoExtension.class) 애노테이션을 붙여 주어야 한다.
만약 전체 테스트가 아닌 특정 테스트 메서드에서만 Mock 객체를 사용하고 싶다면 따로 인자로 Mock 객체를 부여할 수도 있다.
@ExtendWith(MockitoExtension.class)
class StudyServiceTest {
@Test
void createStudy(@Mock MemberService memberService, @Mock StudyRepository studyRepository) {
StudyService studyService = new StudyService(memberService, studyRepository);
assertNotNull(studyService);
}
}
이 때도, @ExtendWith(MockitoExtension.class)는 붙여 주어야 한다.
Mock 객체 Stubbing
Stubbing은 아직 개발되지 않은 코드를 임시로 대치하는 역할을 수행한다. 아직 구현되지 않은 인터페이스를 Mock 객체로 대체했기 때문에, Mock 객체를 활용한 테스트를 위해서는 Stubbing이 필요하다.
만약 Stubbing이 없다면 기본적으로 Mock 객체의 행동은 다음과 같다.
- 기본적으로 Null을 리턴한다. (Optional 타입은 Optional.empty 리턴)
- Primitive 타입은 기본 Primitive 값을 리턴한다. (Integer -> 0)
- 컬렉션은 비어있는 컬렉션을 리턴한다.
- void 메서드는 예외를 던지지 않고 아무런 일도 발생하지 않는다.
테스트 시 findById 등으로 Null이 아닌 원하는 객체값을 반환할 수 있게 Mock 객체에 Stubbing을 해보자.
우선 memberService를 테스트해보자.
<MemberService>
public interface MemberService {
Optional<Member> findById(Long memberId);
void validate(Long memberId);
}
<테스트>
@Test
void memberServiceTest(@Mock MemberService memberService) {
Member member = new Member();
member.setId(1L);
member.setEmail("abc@email.com");
Optional<Member> findById = memberService.findById(1L);
assertEquals("abc@email.com", findById.get().getEmail());
}
MemberServiec를 Mock 객체로 만들었다.
아이디 값이 1인 Member 객체를 생성하고, memberService.findById(1L)로 앞서 생성한 객체를 조회하려 했으나 테스트는 실패하게 된다. 앞서 알아본 것처럼 Mock 객체는 기본적으로 Null을 반환하기 때문이다. (예제는 Optional.empty)
따라서 아이디가 1인 객체를 조회할 수 있도록 Stubbing을 진행한다.
@Test
void memberServiceTest(@Mock MemberService memberService) {
Member member = new Member();
member.setId(1L);
member.setEmail("abc@email.com");
when(memberService.findById(1L)).thenReturn(Optional.of(member));
Optional<Member> findById = memberService.findById(1L);
assertEquals("abc@email.com", findById.get().getEmail());
}
Mockito가 제공하는 Mockito.when() 메서드를 사용해 리턴값을 개발자가 직접 지정해줄 수 있다. findById(1L)은 Optional.of(member)를 반환할 것이라고 Stubbing 해주면 테스트를 성공하게 된다.
1L이라는 아이디 값을 직접 넣어주지 않더라도 ArgumentMathers.any()를 인자로 주면 어떠한 값이 들어가든 전부 Optional.of(member)를 반환하도록 설정할 수도 있다.
when(memberService.findById(any())).thenReturn(Optional.of(member));
+) 참고로 when()과 any() 같은 메서드들은 전부 static import를 통해 [클래스.] 부분을 생략했다.
Stubbing의 다른 예를 보자. 이번에는 리턴값이 없는 메서드에 대해 exception이 발생하도록 설정해보자.
@Test
void memberServiceTest(@Mock MemberService memberService) {
doThrow(new IllegalArgumentException()).when(memberService).validate(1L);
assertThrows(IllegalArgumentException.class, () -> {
memberService.validate(1L);
});
}
doThrow()로 when() 이하의 상황에 대해 exception을 날릴 수 있다.
예제는 memberService.validate() 에 대해 IllegaArgumentException은 발생시키도록 설정했다.
다음은 메서드가 동일한 매개변수로 여러번 호출될 때 각기 다르게 행동하도록 조작하는 방법이다.
@Test
void memberServiceTest(@Mock MemberService memberService) {
Member member = new Member();
member.setId(1L);
member.setEmail("abc@email.com");
when(memberService.findById(any()))
.thenReturn(Optional.of(member))
.thenThrow(new RuntimeException())
.thenReturn(Optional.empty());
Optional<Member> byId = memberService.findById(1L);
assertEquals("abc@email.com", byId.get().getEmail());
assertThrows(RuntimeException.class, () -> {
memberService.findById(2L);
});
assertEquals(Optional.empty(), memberService.findById(3L));
}
위 예제는 findById()에 대해서 첫 번째는 Optional.of(member)를 리턴하도록, 두 번째는 RuntimeException을 던지도록, 그리고 세 번째는 Optional.empty()를 리턴하도록 설정했다.
실제로 테스트시 findById()를 호출할 때마다 순서대로 각기 다른 값을 리턴하는 것을 볼 수 있다.
Mock 객체 확인
테스트를 위해 StudyService의 createStudy() 메서드에 memberService.notify() 메서드 호출부를 추가했다.
<StudyService>
public class StudyService {
private final MemberService memberService;
private final StudyRepository repository;
public StudyService(MemberService memberService, StudyRepository repository) {
assert memberService != null;
assert repository != null;
this.memberService = memberService;
this.repository = repository;
}
public Study createNewStudy(Long memberId, Study study) {
Optional<Member> member = memberService.findById(memberId);
study.setOwner(member.orElseThrow(() -> new IllegalArgumentException("Member doesn't exist for id: '" + memberId + "'")));
Study newStudy = repository.save(study);
memberService.notify(newStudy);
memberService.notify(member.get());
return newStudy;
}
}
<MemberService>
public interface MemberService {
Optional<Member> findById(Long memberId);
void validate(Long memberId);
void notify(Study newStudy);
void notify(Member member);
}
<테스트>
우선 기본적인 StudyService 테스트를 만들었다.
@Test
void memberServiceTest(@Mock MemberService memberService, @Mock StudyRepository studyRepository) {
Study study = new Study(10, "테스트");
Member member = new Member();
member.setId(1L);
member.setEmail("abc@email.com");
when(memberService.findById(1L)).thenReturn(Optional.of(member));
when(studyRepository.save(study)).thenReturn(study);
StudyService studyService = new StudyService(memberService, studyRepository);
studyService.createNewStudy(1L, study);
assertNotNull(study.getOwner());
assertEquals(member, study.getOwner());
}
createNewStudy()가 잘 작동하는지 확인해보았다.
그런데 테스트 도중 StudyService의 객체 상태를 확인하고 싶어졌다. 특히 지금까지 notify() 메서드가 몇 번 실행되었는지 확인하고 싶다면 verify()를 사용한다.
@Test
void memberServiceTest(@Mock MemberService memberService, @Mock StudyRepository studyRepository) {
Study study = new Study(10, "테스트");
Member member = new Member();
member.setId(1L);
member.setEmail("abc@email.com");
when(memberService.findById(1L)).thenReturn(Optional.of(member));
when(studyRepository.save(study)).thenReturn(study);
StudyService studyService = new StudyService(memberService, studyRepository);
studyService.createNewStudy(1L, study);
verify(memberService, times(1)).notify(study);
verify(memberService, times(1)).notify(member);
}
테스트 도중 notify()메서드가 몇 번 수행되었는지 확인할 수 있다. 이 때, 인자 값이 다르다면 각각 체크해주어야 한다.
어떤 메서드는 호출되지 않았음을 체크할 수도 있다.
@Test
void memberServiceTest(@Mock MemberService memberService, @Mock StudyRepository studyRepository) {
Study study = new Study(10, "테스트");
Member member = new Member();
member.setId(1L);
member.setEmail("abc@email.com");
when(memberService.findById(1L)).thenReturn(Optional.of(member));
when(studyRepository.save(study)).thenReturn(study);
StudyService studyService = new StudyService(memberService, studyRepository);
studyService.createNewStudy(1L, study);
verify(memberService, never()).validate(any());
}
validate() 메서드는 테스트 도중 실행되지 않았음을 테스트한다.
메서드가 어떤 순서대로 호출되었는지 테스트할 수 있다.
@Test
void memberServiceTest(@Mock MemberService memberService, @Mock StudyRepository studyRepository) {
Study study = new Study(10, "테스트");
Member member = new Member();
member.setId(1L);
member.setEmail("abc@email.com");
when(memberService.findById(1L)).thenReturn(Optional.of(member));
when(studyRepository.save(study)).thenReturn(study);
StudyService studyService = new StudyService(memberService, studyRepository);
studyService.createNewStudy(1L, study);
InOrder inOrder = inOrder(memberService);
inOrder.verify(memberService).notify(study);
inOrder.verify(memberService).notify(member);
}
Mockito가 제공하는 inOrder를 사용해서 memberService.notify(study) -> memberService.notify(member) 순으로 수행됨을 테스트할 수 있다.
특정 시점 이후에 아무 일도 벌어지지 않았는지 테스트할 수 있다.
@Test
void memberServiceTest(@Mock MemberService memberService, @Mock StudyRepository studyRepository) {
Study study = new Study(10, "테스트");
Member member = new Member();
member.setId(1L);
member.setEmail("abc@email.com");
when(memberService.findById(1L)).thenReturn(Optional.of(member));
when(studyRepository.save(study)).thenReturn(study);
StudyService studyService = new StudyService(memberService, studyRepository);
studyService.createNewStudy(1L, study);
verify(memberService, times(1)).notify(study);
verify(memberService, times(1)).notify(member);
verifyNoMoreInteractions(memberService);
}
verifyNoMoreInteractons(memberService)를 사용해 memberService에 대해 notify(member) 이후에는 아무 일도 벌어지지 않았음을 테스트할 수 있다.
BDD 스타일 Mockito API
BDD: 애플리케이션이 어떻게 "행동"해야 하는지에 대한 공통된 이해를 구성하는 방법으로, TDD에서 창안했다.
단위 테스트를 작성할 때, Given/When/Then 구조를 사용하면 좋다는 것을 한 번쯤 들어봤을 것이다.
앞선 테스트를 다시 보면 다음과 같이 구조를 나눌 수 있다.
@Test
void memberServiceTest(@Mock MemberService memberService, @Mock StudyRepository studyRepository) {
// Given
StudyService studyService = new StudyService(memberService, studyRepository);
Study study = new Study(10, "테스트");
Member member = new Member();
member.setId(1L);
member.setEmail("abc@email.com");
when(memberService.findById(1L)).thenReturn(Optional.of(member));
when(studyRepository.save(study)).thenReturn(study);
// When
studyService.createNewStudy(1L, study);
// Then
verify(memberService, times(1)).notify(study);
verify(memberService, times(1)).notify(member);
verifyNoMoreInteractions(memberService);
}
기본적인 상황이 주어지고 (Given) -> 어떤 특정한 상황에 대해 (When) -> 어떤 결과가 나올지 (Then) 테스트하는 구조를 바로 Given/When/Then 구조라고 하는데, BDD는 이러한 구조에 착안해 테스트케이스 자체에 요구사항을 만들 수 있도록 해서 행위에 대한 테스트를 집중 할 수 있도록 한다.
Mockito에서는 BddMockito라는 클래스를 통해 BDD 스타일의 API를 제공한다.
위 예제에서 BddMockito를 적용한 테스트를 보자.
@Test
void memberServiceTest(@Mock MemberService memberService, @Mock StudyRepository studyRepository) {
// Given
StudyService studyService = new StudyService(memberService, studyRepository);
Study study = new Study(10, "테스트");
Member member = new Member();
member.setId(1L);
member.setEmail("abc@email.com");
given(memberService.findById(1L)).willReturn(Optional.of(member));
given(studyRepository.save(study)).willReturn(study);
// When
studyService.createNewStudy(1L, study);
// Then
then(memberService).should(times(1)).notify(study);
then(memberService).should(times(1)).notify(member);
then(memberService).shouldHaveNoMoreInteractions();
}
Given 절에는 when이라는 표현 대신 BddMockito가 제공하는 given() 절을 사용해 Stubbing을 하였고,
Then 절에서 then()이라는 명확한 표현을 should()와 함께 사용했다.
'java > java' 카테고리의 다른 글
[Java] OpenCsv를 사용해 CSV 파일 읽기 (2) | 2021.10.28 |
---|---|
[Java] 람다식 (0) | 2021.09.28 |
[Java] JUnit 5 (0) | 2021.09.24 |
[Java] 제네릭 (1) | 2021.09.19 |
[Java] I/O (1) | 2021.09.08 |