JUnit과 Mockito로 단위 테스트 작성해보며 이해하기

wo_ogie·2024년 10월 8일
0

소프트웨어를 검증하는 방법: 단위 테스트(Unit Test)에서 단위 테스트가 무엇인지, 그리고 단위 테스트에 대한 기본적인 개념들에 대해 살펴보았습니다.

이번 글에서는 JUnit, Mockito를 활용해서 Java/Spring Boot 환경에서 Mocking 방식으로 단위 테스트를 작성하는 방법을 자세히 알아보고자 합니다. 또한, 외부 의존성 없이 테스트를 독립적으로 수행할 수 있는 Stubbing(classical testing) 방식의 테스트 작성법도 함께 살펴보고자 합니다.

예제 코드 살펴보기

본문에서는 controller-service-repository의 layered 아키텍처로 구성된 API 서버를 예제로 하여, 이에 대한 단위 테스트 작성법에 대해 알아볼 것이다. 우선 단위 테스트를 작성할 예제를 살펴보자.

이번 예제에서 살펴볼 기능은 게시판 서비스의 CRUD 기능들로서, 다음과 같다.

  • CREATE 신규 게시글 업로드
  • READ 게시글 단건 조회
  • READ 전체 게시글 목록 조회
  • UPDATE 게시글 수정
  • DELETE 게시글 삭제

테스트 케이스를 먼저 설계 및 작성한 후 기능을 구현하는 TDD(Test Driven Development, 테스트 주도 개발) 방법론도 있다. 그러나 이번 글의 목적은 '단위 테스트를 작성하는 방법에 대해 알아보는 것'이므로 이에 집중하여 구현된 코드에 대한 테스트 코드를 작성해보려고 한다.

Domain entity

@AllArgsConstructor(access = AccessLevel.PRIVATE)
@EqualsAndHashCode
@Getter
public class Post {
	private Long id;    // 게시글 id
	private Long writerId;    // 게시글 작성자 id
	private String title;    // 게시글 제목
	private String content;    // 게시글 내용

	public static Post withId(Long id, Long writerId, String title, String content) {
		return new Post(id, writerId, title, content);
	}

	public static Post withoutId(Long writerId, String title, String content) {
		return new Post(null, writerId, title, content);
	}

	public void updateTitle(String title) {
		if (StringUtils.hasText(title)) {
			this.title = title;
		}
	}

	public void updateContent(String content) {
		if (StringUtils.hasText(content)) {
			this.content = content;
		}
	}

	public void verifyUpdateOrDeletePermission(Long userId) {
		if (!this.writerId.equals(userId)) {
			throw new IllegalArgumentException("게시물을 수정/삭제할 수 있는 권한이 없습니다. 게시물은 작성자만 수정/삭제할 수 있습니다.");
		}
	}
}

Repository

public interface UserRepository {
	boolean existsById(Long id);
}
public interface PostRepository {
	Post save(Post post);
	Optional<Post> findById(Long id);
	List<Post> findAll();
	void update(Post post);
	void delete(Post post);
}

이번 글에서 repository는 테스트하지 않는다. Layered 아키텍처에서 repository 계층은 보통 외부 의존성을 갖지 않는 객체이기도 하고, 보통 repository 계층은 단위 테스트를 작성하기보다는 통합 테스트를 작성하여 실제로 DB에 값을 잘 저장하는지, 값을 잘 읽어오는지를 테스트하기 때문이다.

다만, service 계층에서 DB 접근 로직을 사용해야 하기 때문에 필요한 기능들을 interface에 정의만 해두었다.

Service

@RequiredArgsConstructor
@Service
public class PostService {

	private final UserRepository userRepository;
	private final PostRepository postRepository;

	@Transactional
	public Post upload(UploadPostCommand command) {
		if (!userRepository.existsById(uploadPostCommand.writerId())) {
			throw new IllegalArgumentException("유저 정보가 존재하지 않습니다.");
		}
		return postRepository.save(Post.withoutId(
			command.writerId(),
			command.title(),
			command.content()
		));
	}

	@Transactional(readOnly = true)
	public Post getById(Long id) {
		return postRepository.findById(id).orElseThrow();
	}

	@Transactional(readOnly = true)
	public List<Post> findAll() {
		return postRepository.findAll();
	}

	@Transactional
	public void update(UpdatePostCommand command) {
		Post post = getById(command.postId());
		post.verifyUpdateOrDeletePermission(command.requestUserId());
		post.updateTitle(command.title());
		post.updateContent(command.content());
		postRepository.update(post);
	}

	@Transactional
	public void delete(DeletePostCommand command) {
		Post post = getById(command.postId());
		post.verifyUpdateOrDeletePermission(command.requestUserId());
		postRepository.delete(post);
	}
}

Controller

@RequiredArgsConstructor
@RequestMapping("/api/posts")
@RestController
public class PostController {

	private final PostService postService;

	@PostMapping
	public ResponseEntity<UploadPostResponse> uploadPost(
		@RequestBody @Valid UploadPostRequest request
	) {
		Post post = postService.upload(new UploadPostCommand(
			request.writerId(),
			request.title(),
			request.content()
		));
		return ResponseEntity
			.created(URI.create("/api/posts/" + post.getId()))
			.body(UploadPostResponse.from(post));
	}

	@GetMapping("/{postId}")
	public PostInfoResponse getPost(@PathVariable Long postId) {
		Post post = postService.getById(postId);
		return PostInfoResponse.from(post);
	}

	@GetMapping
	public List<PostInfoResponse> findAllPosts() {
		return postService.findAll().stream()
			.map(PostInfoResponse::from)
			.toList();
	}

	@PatchMapping("/{postId}")
	public ResponseEntity<Void> updatePost(
		@PathVariable Long postId,
		@RequestParam Long requestUserId,
		@RequestBody UpdatePostRequest request
	) {
		postService.update(new UpdatePostCommand(
			requestUserId,
			postId,
			request.title(),
			request.content()
		));
		return ResponseEntity.noContent().build();
	}

	@DeleteMapping("/{postId}")
	public ResponseEntity<Void> deletePost(
		@PathVariable Long postId,
		@RequestParam Long requestUserId
	) {
		postService.delete(new DeletePostCommand(requestUserId, postId));
		return ResponseEntity.noContent().build();
	}
}

테스트 코드 작성하기

이제 위에서 구현된 기능들에 대한 단위 테스트 코드를 작성해보자.

Given - When -Then

본문에서 작성하는 모든 테스트 코드는 given-when-then 구조로 작성할 것이다.

각각의 단계가 의미하는 바는 다음과 같다.

  • given: 테스트 시나리오를 수행하기 위해 필요한 것들을 정의하는 단계이다. 테스트의 전제 조건을 설정하는 부분.
  • when: 테스트하려는 기능이 동작하는 단계이다.
  • then: 동작한 기능의 결과를 검증하는 단계이다.

Given-when-then 구조로 테스트를 작성하게 되면 테스트의 가독성과 명확성을 높이고, 유지보수하기 용이하다는 이점이 있다. 각 단계가 명확한 역할을 가지고 있기 때문에, 테스트가 무엇을 검증하려고 하는지 쉽게 이해할 수 있다.

Service Layer 단위 테스트 작성

우선 PostService에 대한 테스트 코드를 먼저 작성해보자. PostServiceTest라는 class를 생성한 후, 다음과 같이 작성한다.

import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import example.unit_test.domain.post.repository.PostRepository;
import example.unit_test.domain.user.repository.UserRepository;

@ExtendWith(MockitoExtension.class)
class PostServiceTest {

	@InjectMocks
	private PostService sut;

	@Mock
	private UserRepository userRepository;
	@Mock
	private PostRepository postRepository;
	
	// ...
}
  • @ExtendWith(MockitoExtension.class): Mockito 라이브러리를 사용하기 위해 관련 설정들을 로드한다. JUnit4의 MockitoJUnitRunner와 동일한 역할을 한다.
  • @InjectMocks: 특정 필드를 가짜 객체(여기서는 mock 객체)를 주입받을 대상으로 지정한다. 가짜 객체는 constructor injection, property injection, setter injection을 통해 주입받을 수 있다.
  • @Mock: 특정 필드를 mock 객체로 지정한다.

이제 mock 객체를 사용하여 외부 의존성들이 우리의 의도대로 동작하도록 행위를 정의(mocking)할 것이다.

Mock 객체와 mocking에 대해 잘 모른다면 이전글을 읽어보길 권한다.

전체 게시글 목록 조회

import static org.assertj.core.api.Assertions.assertThatIterable;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;

@Test
void 전체_게시글_목록을_조회한다() {
	// given
	final Long WRITER_ID = 10L;
	List<Post> expectedResult = List.of(
		Post.withId(1L, WRITER_ID, "Test1", "This is..."),
		Post.withId(2L, WRITER_ID, "Test2", "This is...")
	);
	given(postRepository.findAll())
		.willReturn(expectedResult);

	// when
	List<Post> actualResult = sut.findAll();

	// then
	then(postRepository).should().findAll();
	then(postRepository).shouldHaveNoMoreInteractions();
	then(userRepository).shouldHaveNoInteractions();
	assertThatIterable(actualResult).isEqualTo(expectedResult);
}

전체 게시글 목록 조회(PostService.findAll())의 테스트 코드이다.

Given에서 예상 결과와 mock 객체의 행동에 대한 결과를 정의하고, when에서 테스트하고자 하는 기능을 실행한 후, 마지막으로 테스트 결과를 검증한다.

  • BDDMockito.given(METHOD_CALL): Mock 객체의 특정 메서드 호출을 인자로 받으며, 인자로 전달받은 메서드에 대해 stubbing(행위에 대한 결과를 설정하는 것)할 수 있도록 활성화한다. 이후 willReturn(VALUE)을 사용하여 행위에 대한 결과를 설정할 수 있다. 그렇게 한다면, 우리가 stubbing 한 행위가 동작할 때는 언제나 설정된 결과가 도출된다.
  • BDDMockito.then(MOCK_OBJ): 인자로 mock 객체를 전달받으며 should(), shouldXxx()와 함께 쓰이며 mock 객체에 대한 메서드 호출 여부를 검증한다.
    • should(): 특정 메서드가 실제로 호출되었는지 검증한다.
    • shouldHaveNoMoreInteractions(): Mock 객체에 대해 BDDMockito.given(...)에 의해 정의된 메서드 호출 외에, 추가적인 메서드 호출이 일어나지 않았음을 검증한다.
    • shouldHaveNoInteractions(): Mock 객체에 대해 메서드 호출이 전혀 발생하지 않았음을 검증한다.

Mock 객체의 행위를 설정하고 테스트 결과를 검증하는 데 BDDMockito의 기능들을 사용했다. Mockito 라이브러리 내에 존재하는 Mockito.verify(), Mockito.doReturn(), Mockito.when() 등의 기능을 사용해도 동일한 작업을 수행할 수 있다. 다만, 나는 given-when-then의 의미가 메서드 이름에서 드러나는 BDDMockito의 사용을 선호한다.

게시글 단건 조회

게시글 단건 조회 기능에 대한 테스트 코드를 작성하기 전에 구현 코드를 다시 살펴보자.

@Transactional(readOnly = true)
public Post getById(Long id) {
	return postRepository.findById(id).orElseThrow();
}

getById()에는 두 가지 테스트 시나리오가 존재해야 한다. 첫 번째는 DB에서 id와 일치하는 게시글 정보를 정상적으로 조회했을 때이고, 두 번째는 일치하는 게시글 정보를 찾지 못한 경우(주로 id 값이 잘못된 경우)이다.

이 두 케이스에 대한 테스트 코드를 모두 작성해보자.

@Test
void 주어진_id로_게시글을_단건_조회한다() {
	// given
	final Long POST_ID = 1L;
	Post expectedResult = Post.withId(POST_ID, 2L, "Test", "Contents...");
	given(postRepository.findById(POST_ID))
		.willReturn(Optional.of(expectedResult));

	// when
	Post actualResult = sut.getById(POST_ID);

	// then
	then(postRepository).should().findById(POST_ID);
	then(postRepository).shouldHaveNoMoreInteractions();
	then(userRepository).shouldHaveNoInteractions();
	assertThat(actualResult).isEqualTo(expectedResult);
}

@Test
void 주어진_id로_게시글을_단건_조회한다_만약_존재하지_않는_id라면_예외가_발생한다() {
	// given
	final Long POST_ID = 1L;
	given(postRepository.findById(POST_ID))
		.willReturn(Optional.empty());

	// when
	Throwable ex = catchThrowable(() -> sut.getById(POST_ID));

	// then
	then(postRepository).should().findById(POST_ID);
	then(postRepository).shouldHaveNoMoreInteractions();
	then(userRepository).shouldHaveNoInteractions();
	assertThat(ex).isInstanceOf(NoSuchElementException.class);
}

PostRepository.findById()의 return type이 optional이기 때문에 조회된 데이터가 없는 경우 empty optional 객체가 반환된다. 이 경우, 예외가 발생하도록 비즈니스 로직을 구현했으므로 AssertJ의 catchThrowable() 메서드를 사용해 발생한 예외를 잡고, 예상한 예외가 맞는지 검증해줬다.

신규 게시글 업로드

@Test
void 게시글을_업로드한다() {
	// given
	final Long WRITER_ID = 1L;
	final Long EXPECTED_POST_ID = 2L;
	final String TITLE = "Test";
	final String CONTENT = "This is test...";
	UploadPostCommand uploadPostCommand = new UploadPostCommand(WRITER_ID, TITLE, CONTENT);
	Post expectedResult = Post.withId(EXPECTED_POST_ID, WRITER_ID, TITLE, CONTENT);
	given(userRepository.existsById(uploadPostCommand.writerId()))
		.willReturn(true);
	given(postRepository.save(any(Post.class)))
		.willReturn(expectedResult);

	// when
	Post actualResult = sut.upload(uploadPostCommand);

	// then
	then(userRepository).should().existsById(uploadPostCommand.writerId());
	then(postRepository).should().save(any(Post.class));
	then(userRepository).shouldHaveNoMoreInteractions();
	then(postRepository).shouldHaveNoMoreInteractions();
	assertThat(actualResult).isEqualTo(expectedResult);
}

@Test
void 게시글을_업로드한다_만약_작성자_정보가_존재하지_않는다면_예외가_발생한다() {
	// given
	final Long WRITER_ID = 1L;
	UploadPostCommand uploadPostCommand = new UploadPostCommand(WRITER_ID, "Test", "Contents...");
	given(userRepository.existsById(WRITER_ID))
		.willReturn(false);

	// when
	Throwable ex = catchThrowable(() -> sut.upload(uploadPostCommand));

	// then
	then(userRepository).should().existsById(WRITER_ID);
	then(userRepository).shouldHaveNoMoreInteractions();
	then(postRepository).shouldHaveNoInteractions();
	assertThat(ex).isInstanceOf(IllegalArgumentException.class);
}

코드가 이전에 비해 조금 길긴 하지만 새롭게 추가된 내용은 많지 않다. given()을 통해 메서드 호출을 stubbing 하는 것에 있어 이번에는 any()라는 메서드를 사용하였다.

  • ArgumentMatchers.any(): null을 제외한 모든 객체를 일치(equal)한 것으로 판단하게끔 한다. 만약 메서드의 인자로 type이 주어진 경우, 해당 type의 객체에 대해서만 일치 한것으로 판단한다.

given(userRepository.existsById(1L))처럼 메서드 호출의 인자로 특정 값을 넣어줬을 때에는 실제로 stubbing 할 때와 동일한 값이 전달되어야 우리가 설정한 결과가 도출된다. 즉, existsById()의 인자에 1L을 넣어준 경우에만 우리가 설정한대로 동작한다.

public Post upload(UploadPostCommand uploadPostCommand) {
	if (!userRepository.existsById(uploadPostCommand.writerId())) {
		throw new IllegalArgumentException("유저 정보가 존재하지 않습니다.");
	}
	return postRepository.save(Post.withoutId(
		uploadPostCommand.writerId(),
		uploadPostCommand.title(),
		uploadPostCommand.content()
	));
}

upload()의 코드를 보면, postRepository.save()에 전달할 객체를 upload() 메서드 내부에서 생성하고 있다. 그렇기에 고려해 볼 문제점은 두 가지가 있다.

  1. upload() 메서드가 실행되는 시점에 생성되는 객체를 테스트 코드의 given 단계에서 정의하여 stubbing하는 것도 어색하다. 내용은 같더라도 분명 다른 객체(메모리 주소가 다르기 때문)인데 이것을 같다고 볼 수 있는가?
  2. 현재는 Post domain entity에 @EqualsAndHashCode를 사용하여 equal()hashcode()를 재정의했지만, 만약 그렇지 않았다면 upload()에서 생성된 Post 객체와 stubbing을 할 때 전달해준 Post 객체가 동일한지 확인하기 위해 메모리 주소를 비교할테고, 결국 일치하지 않아 매칭이 되지 않는다. 결국 우리가 stubbing한 결과가 도출되지 않을 것이다.

이러한 이유들로 여기에서는 any()를 사용하여 특정 객체가 아닌, 특정 type의 객체라면 모두 일치하는 것으로 매칭하게끔 설정했다.

게시글 수정

@Test
void 수정할_게시글_정보가_주어지고_게시글을_수정한다() {
	// given
	final Long WRITER_ID = 1L;
	final Long POST_ID = 2L;
	UpdatePostCommand updatePostCommand = new UpdatePostCommand(WRITER_ID, POST_ID, "New title", "New Contents...");
	Post oldPost = Post.withId(POST_ID, WRITER_ID, "Old title", "Old Contents...");
	given(postRepository.findById(POST_ID))
		.willReturn(Optional.of(oldPost));
	willDoNothing()
    	.given(postRepository).update(any(Post.class));

	// when
	sut.update(updatePostCommand);

	// then
	then(postRepository).should().findById(POST_ID);
	then(postRepository).should().update(any(Post.class));
	then(postRepository).shouldHaveNoMoreInteractions();
	then(userRepository).shouldHaveNoInteractions();
}

@Test
void 게시글을_수정한다_만약_수정하려는_유저와_게시글_작성자가_다르다면_예외가_발생한다() {
	// given
	final Long WRITER_ID = 1L;
	final Long REQUEST_USER_ID = 2L;
	final Long POST_ID = 3L;
	UpdatePostCommand updatePostCommand = new UpdatePostCommand(REQUEST_USER_ID, POST_ID, "New", "New Content");
	Post oldPost = Post.withId(POST_ID, WRITER_ID, "Old title", "Old Contents...");
	given(postRepository.findById(POST_ID))
		.willReturn(Optional.of(oldPost));

	// when
	Throwable ex = catchThrowable(() -> sut.update(updatePostCommand));

	// then
	then(postRepository).should().findById(POST_ID);
	then(postRepository).shouldHaveNoMoreInteractions();
	then(userRepository).shouldHaveNoInteractions();
	assertThat(ex).isInstanceOf(IllegalArgumentException.class);
}

게시글 수정 기능을 검증하는 테스트 코드이다. 만약 stubbing 하려는 메서드 호출에 대해, 결과(반환 값)가 없다면 willReturn()이 아닌 willDoNothing()을 사용하면 된다.

게시글 삭제

@Test
void 게시글을_삭제한다() {
	// given
	final Long WRITER_ID = 1L;
	final Long POST_ID = 2L;
	DeletePostCommand deletePostCommand = new DeletePostCommand(WRITER_ID, POST_ID);
	Post post = Post.withId(POST_ID, WRITER_ID, "Title", "Contents...");
	given(postRepository.findById(POST_ID))
		.willReturn(Optional.of(post));
	willDoNothing()
    	.given(postRepository).delete(post);

	// when
	sut.delete(deletePostCommand);

	// then
	then(postRepository).should().findById(POST_ID);
	then(postRepository).should().delete(post);
	then(postRepository).shouldHaveNoMoreInteractions();
	then(userRepository).shouldHaveNoInteractions();
}

@Test
void 게시글을_삭제한다_만약_삭제하려는_유저와_게시글_작성자가_다르다면_예외가_발생한다() {
	// given
	final Long WRITER_ID = 1L;
	final Long REQUEST_USER_ID = 2L;
	final Long POST_ID = 3L;
	DeletePostCommand deletePostCommand = new DeletePostCommand(REQUEST_USER_ID, POST_ID);
	Post post = Post.withId(POST_ID, WRITER_ID, "Title", "Contents...");
	given(postRepository.findById(POST_ID))
		.willReturn(Optional.of(post));

	// when
	Throwable ex = catchThrowable(() -> sut.delete(deletePostCommand));

	// then
	then(postRepository).should().findById(POST_ID);
	then(postRepository).shouldHaveNoMoreInteractions();
	then(userRepository).shouldHaveNoInteractions();
	assertThat(ex).isInstanceOf(IllegalArgumentException.class);
}

이렇게 service layer의 비즈니스 로직에 대한 단위 테스트를 작성하는 방법에 대해 살펴봤다.

이제 controller layer(API) 호출에 대한 단위 테스트 코드를 작성하고 기능을 검증해보자.

Controller Layer 단위 테스트 작성

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;

import com.fasterxml.jackson.databind.ObjectMapper;

@WebMvcTest(controllers = PostController.class)
class PostControllerTest {

	@MockBean
	private PostService postService;

	private final MockMvc mvc;
	private final ObjectMapper mapper;

	@Autowired
	public PostControllerTest(MockMvc mvc, ObjectMapper mapper) {
		this.mvc = mvc;
		this.mapper = mapper;
	}
    
    // ...
}

Controller 테스트를 위해 테스트 클래스에 @WebMvcTest를 선언했다. @WebMvcTest를 사용하면 @SpringBootTest를 사용하는 것과는 다르게 MVC 테스트와 관련된 구성 요소들만 로드한다(@Controller, @ControllerAdvice, @JsonComponent 등). 즉, MVC 테스트와 관련 없는 component들은 Spring Bean으로 등록하지 않는다.

여기에서는 @WebMvcTestcontrollers 속성에 테스트 대상 정보를 넘겨주었는데, 이렇게 하면 다른 불필요한 controller조차 bean으로 등록하지 않고, 테스트 대상만을 Spring bean으로 등록하게 된다. 이렇게 하면 불필요한 component들을 bean으로 등록하지 않아도 되니 테스트 구동 시간이 빨라진다.

다음은 WebMvcTest의 실제 코드 중 일부이다.

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(WebMvcTestContextBootstrapper.class)
@ExtendWith({SpringExtension.class})
@OverrideAutoConfiguration(
    enabled = false
)
@TypeExcludeFilters({WebMvcTypeExcludeFilter.class})
@AutoConfigureCache
@AutoConfigureWebMvc
@AutoConfigureMockMvc
@ImportAutoConfiguration
public @interface WebMvcTest {
	// ...
}

@WebMvcTest가 붙은 테스트에서는 Spring Security(있는 경우)와 MockMvc도 자동으로 구성한다. @WebMvcTest는 MVC 테스트 환경만을 로드하기 때문에 controller에서 필요한 의존성이 있다면 @Import 또는 @MockBean과 함께 사용해야 한다.

여기서 알아두어야 할 개념은 MockMvc@MockBean이다.

  • MockMvc: Web layer의 단위 테스트를 작성하기 위해 만들어진 class로서, API 요청을 보내듯이 controller의 메서드를 호출할 수 있다.
  • @MockBean: Spring ApplicationContext에 mock 객체를 추가하기 위해 사용한다. 즉, mock 객체를 Spring bean으로 등록한다. 이렇게 하면 controller에서는 Spring bean에 등록된 mock 객체를 의존성 주입받아 사용하게 된다.

전체 게시글 목록 조회 API

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void 전체_게시글_목록을_조회한다() throws Exception {
	// given
	final Long WRITER_ID = 10L;
	List<Post> expectedResult = List.of(
		Post.withId(1L, WRITER_ID, "Test1", "This is..."),
		Post.withId(2L, WRITER_ID, "Test2", "This is...")
	);
	given(postService.findAll())
		.willReturn(expectedResult);

	// when & then
	mvc.perform(get("/api/posts"))
		.andExpect(status().isOk())
		.andExpect(jsonPath("$.size()").value(expectedResult.size()));
	then(postService).should().findAll();
	then(postService).shouldHaveNoMoreInteractions();
}

기본적으로 mock 객체에 대해 행위와 그 결과를 정의하는 방법은 service layer를 테스트할 때와 동일하다.

MockMvc.perform()을 통해 controller의 method를 호출할 수 있으며, 결과에 대한 검증도 가능하다. MockMvc.perform()의 인자로는 web.servlet.RequestBuilder의 하위 interface를 구현한 MockMvcRequestBuilders을 넣어주면 된다. MockMvc.RequestBuilders에는 get(), post(), put(), patc(), delete() 등 API 요청을 정의하기 위한 메서드들이 존재한다.

API 호출 결과를 검증하는 방법은 간단하다. MockMvcResultMatchers.status()를 사용하여 status code를 검증할 수 있고, MockMvcResultMatchers.jsonPath를 사용하여 response body의 내용을 검증할 수 있다.

이후에는 service layer의 테스트에서 했던 것과 동일하게, then()을 사용하여 우리가 의도한대로 함수가 동작했는지, 부수적인 함수 호출은 없었는지를 검증하면 된다.

게시글 단건 조회 API

@Test
void 주어진_id로_게시글을_단건_조회한다() throws Exception {
	// given
	final Long POST_ID = 1L;
	Post expectedResult = Post.withId(POST_ID, 2L, "Test", "Contents..");
	given(postService.getById(POST_ID))
		.willReturn(expectedResult);

	// when & then
	mvc.perform(
			get("/api/posts/{postId}", POST_ID)
		).andExpect(status().isOk())
		.andExpect(jsonPath("$.postId").value(expectedResult.getId()));
	then(postService).should().getById(POST_ID);
	then(postService).shouldHaveNoMoreInteractions();
}

신규 게시글 업로드 API

@Test
void 신규_게시글을_업로드한다() throws Exception {
	// given
	final Long WRITER_ID = 1L;
	final Long EXPECTED_POST_ID = 2L;
	final String TITLE = "New Post";
	final String CONTENT = "Contents...";
	UploadPostRequest uploadPostRequest = new UploadPostRequest(WRITER_ID, TITLE, CONTENT);
	Post expectedResult = Post.withId(EXPECTED_POST_ID, WRITER_ID, TITLE, CONTENT);
	given(postService.upload(any(UploadPostCommand.class)))
		.willReturn(expectedResult);

	// when & then
	mvc.perform(
			post("/api/posts")
				.contentType(MediaType.APPLICATION_JSON)
				.content(mapper.writeValueAsString(uploadPostRequest))
		).andExpect(status().isCreated())
		.andExpect(jsonPath("$.postId").value(expectedResult.getId()));
	then(postService).should().upload(any(UploadPostCommand.class));
	then(postService).shouldHaveNoMoreInteractions();
}

API 요청 시 포함할 request body 내용은 MockHttpServletRequestBuilder.content()를 사용하여 설정할 수 있는데, 함수의 인자로는 byte[] or String type만을 허용하기 때문에 request 객체를 문자열로 변환해야 한다. 이를 위해 ObjectMapperwriteValueAsString() 메서드를 사용했다.

게시글 수정 API

@Test
void 수정할_게시글_정보가_주어지고_게시글을_수정한다() throws Exception {
	// given
	final Long WRITER_ID = 1L;
	final Long POST_ID = 2L;
	UpdatePostRequest updatePostRequest = new UpdatePostRequest("Old title", "Old contents");
	willDoNothing()
		.given(postService).update(any(UpdatePostCommand.class));

	// when & then
	mvc.perform(
		patch("/api/posts/{postId}", POST_ID)
			.param("requestUserId", String.valueOf(WRITER_ID))
			.contentType(MediaType.APPLICATION_JSON)
			.content(mapper.writeValueAsString(updatePostRequest))
	).andExpect(status().isNoContent());
	then(postService).should().update(any(UpdatePostCommand.class));
	then(postService).shouldHaveNoMoreInteractions();
}

게시글 삭제 API

@Test
void 게시글을_삭제한다() throws Exception {
	// given
	final Long WRITER_ID = 1L;
	final Long POST_ID = 2L;
	willDoNothing()
		.given(postService).delete(any(DeletePostCommand.class));

	// when & then
	mvc.perform(
		delete("/api/posts/{postId}", POST_ID)
			.param("requestUserId", String.valueOf(WRITER_ID))
	).andExpect(status().isNoContent());
	then(postService).should().delete(any(DeletePostCommand.class));
	then(postService).shouldHaveNoMoreInteractions();
}

Classical Approach: Stub 객체로 테스트하기

지금까지 mock 라이브러리를 사용해서, mocking 방식으로 단위 테스트를 작성해보았다. 이번에는 외부 의존성 없이, 단위 테스트 작성에 필요한 test doubles를 직접 구현하여 테스트하는 방법에 대해 알아보자.

Stubbing은 객체의 입력에 대해 기대되는 출력을 사전에 정의하고 그 결과를 검증하는 방법이다. 이는 classical testing이라고도 불리며, mocking과는 달리 실제로 동작하는 객체를 직접 구현하여 의존성과 결합도를 최소화한다. Stub 객체는 테스트에서 필요한 호출에 대해 미리 준비된 답을 제공하는 객체로, 외부 시스템과의 의존성을 제거하여 테스트를 독립적으로 수행할 수 있게 한다.

Mocking이 객체의 행동을 검증하는 것에 중점을 둔다면, Stubbing은 상태를 검증하는 것에 중점을 둔다. 즉, 어떤 입력에 대해 개발자가 기대하는 결과가 일관되게 나오는지 검증하는 것이 핵심이다. 이 과정에서 외부 의존성 없이 필요한 객체를 직접 구현함으로써, 테스트가 외부 시스템과 테스트 대상의 세부 구현에 의존하지 않게 된다.

이제 앞에서 살펴봤던 게시글 기능을 Stubbing 방식으로 테스트해보자.

public class PostRepositoryStub implements PostRepository {
    private Map<Long, Post> postStore = new HashMap<>();
    private Long currentId = 1L;

    @Override
    public Post save(Post post) {
        Post savedPost = Post.withId(currentId++, post.getWriterId(), post.getTitle(), post.getContent());
        postStore.put(savedPost.getId(), savedPost);
        return savedPost;
    }

    @Override
    public Optional<Post> findById(Long id) {
        return Optional.ofNullable(postStore.get(id));
    }

    @Override
    public List<Post> findAll() {
        return new ArrayList<>(postStore.values());
    }

    @Override
    public void update(Post post) {
        postStore.put(post.getId(), post);
    }

    @Override
    public void delete(Post post) {
        postStore.remove(post.getId());
    }
}

public class UserRepositoryStub implements UserRepository {
	// UserRepository는 예제의 주요 관심사가 아니므로 간략히 구현하였음
    @Override
    public boolean existsById(Long id) {
    	return true;
    }
}

Stubbing test를 위해 PostService에서 사용할 두 개의 Stub 객체를 구현했다. 이제 이 Stub 객체를 사용하여 PostService의 게시글 업로드 기능을 테스트해보자.

@Test
void 게시글을_업로드한다() {
    // Given
    UserRepositoryStub userRepositoryStub = new UserRepositoryStub();
    PostRepositoryStub postRepositoryStub = new PostRepositoryStub();
    PostService postService = new PostService(userRepositoryStub, postRepositoryStub);

    UploadPostCommand command = new UploadPostCommand(1L, "Test Title", "Test Content");

    // When
    Post result = postService.upload(command);

    // Then
    assertNotNull(result.getId());
    assertEquals("Test Title", result.getTitle());
    assertEquals("Test Content", result.getContent());
}

이처럼 Stub 객체를 사용하여 테스트를 작성하고, 기능을 검증할 수 있다.

Stubbing 방식의 테스트는 외부 의존성을 제거하고 테스트를 독립적으로 수행할 수 있다는 장점이 있다. 또한, 비즈니스 로직의 세부 구현에 의존하지 않으며, 필요한 부분만 간단하게 구현하여 변화에 유연하게 대응할 수 있다. 이로 인해 Stubbing 방식은 테스트 코드의 유지보수성을 높이고, 효율적이고 빠른 테스트 환경을 제공하는데 매우 유용한 방법이다.

Mocking과 Stubbing 두 가지 방식 모두 장단점이 존재한다. Mocking은 객체 간 상호작용을 검증할 때 유리하지만, 테스트 대상의 세부 구현이 변경될 때 테스트 코드 역시 변경될 가능성이 크다. 반면에 Stubbing은 외부 의존성을 최소화하고, 테스트 대상의 세부 구현에 종속되지 않으므로 변화에 유연하고 빠르게 대응할 수 있다. 당연하게도 silver bullet은 없으며, 개발자는 상황에 맞게 팀의 테스트 룰을 정할 필요가 있다.

참고 자료

Martin Fowler - Given When Then
Spring 공식 문서 - Testing the Web Layer
WebMvcTest 명세
Spring 공식 문서 - MockMvc
MockBean 명세
당근 테크 블로그 - 효율적인 테스트를 위한 Stub 객체 사용법

0개의 댓글