TDD는 테스트의 중요성을 강조한 개발 방법론으로, 테스트 코드를 먼저 설계하고 작성한 뒤 그 테스트를 통과시키는 방향으로 실제 기능을 개발해 나간다. 즉 “구현 먼저”가 아니라 “검증 기준(테스트) 먼저”를 세우는 방식이다.
테스트를 먼저 작성하면 개발자는 원하는 기능과 동작 방식을 더 명확하게 정의하게 된다. 또한 결함을 초기에 발견할 가능성이 커져, 장기적으로 유지보수 비용과 리스크를 줄이고 개발 효율성을 높이는 데 도움이 된다.
스프링 기반 프로젝트에서 테스트 프레임워크로 JUnit이 가장 흔하게 사용된다. 단위 테스트부터 통합 테스트까지 폭넓게 적용할 수 있고, 스프링 테스트 지원(@SpringBootTest 등)과 함께 사용할 때 워크플로우가 자연스럽기 때문이다.
이 글에서는 테스트를 “단위 테스트”와 “통합 테스트”로 나누어 정리하고, 마지막에 실습 코드를 통해 Repository/Service 테스트가 어떻게 작성되는지까지 같이 본다.
단위 테스트는 애플리케이션의 가장 작은 단위(메서드, 클래스)를 검증한다. 핵심은 테스트 대상 외의 의존성을 줄이고, 빠르고 자주 실행 가능한 형태로 만드는 것이다.
컨트롤러 테스트는 외부 의존성을 직접 붙이지 않고 목킹(Mocking) 으로 가상화하는 방식이 많이 쓰인다.
컨트롤러는 “요청을 어떤 서비스로 전달하고, 어떤 응답을 만드는지”가 핵심이므로, 서비스 계층을 mock 처리하고 컨트롤러의 책임만 검증하는 방식이 효율적이다.
서비스 계층 테스트에서는 실제 서비스 로직을 검증한다. 이때 트랜잭션을 걸어 테스트가 끝난 뒤 롤백되도록 구성하는 경우가 많다.
즉, 서비스 테스트에서는 “로직이 맞게 동작하는지”에 집중하면서도, DB 변경이 테스트 간 간섭을 만들지 않도록 관리하는 것이 포인트다.
리포지토리 테스트는 JPA 동작을 검증하는 경우가 많아, JPA 관련 설정만 가볍게 로드하는 어노테이션을 사용한다.
리포지토리는 “DB와의 상호작용이 기대대로 되는지”를 확인하는 계층이므로, 애플리케이션 전체를 띄우지 않고도 필요한 설정만 로딩해 테스트 속도와 안정성을 확보한다.
통합 테스트는 애플리케이션의 여러 컴포넌트가 함께 동작할 때 전체 흐름이 올바르게 작동하는지 확인한다.
여기서 주의할 점은 클래스 단위 실행 시 메서드 간 의존성이 생기지 않게 해야 한다는 것이다. 예를 들어 특정 테스트가 데이터를 추가하고, 다음 테스트가 그 데이터에 기대면 테스트 순서에 따라 결과가 달라질 수 있다.
이를 방지하기 위해 다음과 같은 정리 작업을 한다.
테스트는 가독성이 중요하고, 의도를 명확히 드러내야 한다. 그럴 때 Given-When-Then 패턴(준비-실행-검증)을 많이 사용한다.
예를 들어 은행 계좌에서 출금을 테스트한다면 다음처럼 정리할 수 있다.
예시 코드(개념 예시):
// Given
account = new BankAccount(1000);
// When
account.withdraw(500);
// Then
assert account.balance == 500;
이 구조를 유지하면 테스트가 “무엇을 준비했고, 무엇을 실행했고, 무엇을 기대하는지” 한눈에 들어오며, 실패했을 때 원인도 빨리 추적할 수 있다.
아래 코드는 지금까지 정리한 개념(통합 테스트 환경에서의 스프링 컨텍스트 로딩, 트랜잭션 롤백, Given-When-Then)을 그대로 적용한 예시다. 특히 @SpringBootTest + @Transactional 조합은 테스트가 끝나면 트랜잭션이 롤백되도록 구성하는 데 자주 사용되며, 롤백 여부는 @Rollback(또는 @Commit)으로 제어할 수 있다.
@SpringBootTest 대신 @DataJpaTest를 써서 더 가볍게 가져갈 수도 있다 package com.beyond.basic.author;
import com.beyond.basic.author.domain.Author;
import com.beyond.basic.author.repository.AuthorRepository;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
@SpringBootTest
@Transactional // Transactional 어노테이션 + Test = DB자동 롤백처리
//@DataJpaTest // jpa관련된 의존성들만 사용. 주로 repository테스트에사용
public class AuthorRepositoryTest {
private final AuthorRepository authorRepository;
@Autowired
public AuthorRepositoryTest(AuthorRepository authorRepository) {
this.authorRepository = authorRepository;
}
@Test
public void authorSaveTest() {
// 테스트 검증 로직: 객체 생성(저장 전) -> save -> 저장된 객체(DB 조회)를 조회하여 저장전의 객체와 비교
// - given when then 패턴(prepare execute then 패턴)
// (1) 준비(prepare, given)
Author author = Author.builder()
.name("abc")
.email("abc@naver.com")
.password("12341234")
.build();
// (2) 실행(execute, when)
authorRepository.save(author);
// (3)검증(then)
Author authorDb = authorRepository.findByEmail("abc@naver.com").orElse(null);
Assertions.assertEquals(author.getEmail(), authorDb.getEmail());
}
}
여기서 Assertions.assertEquals()는 기대값(expected)과 실제값(actual)이 같은지 검증할 때 쓰는 대표적인 JUnit Assertion이다.
findAll 개수 테스트처럼 “기존 데이터 개수에 의존”하는 테스트는 실행 환경에 따라 초기 데이터가 달라질 수 있으니, 테스트 데이터 셋업/정리 전략을 같이 설계하는 편이 안전하다package com.beyond.basic.author;
import com.beyond.basic.author.domain.Author;
import com.beyond.basic.author.dto.AuthorCreateDto;
import com.beyond.basic.author.repository.AuthorRepository;
import com.beyond.basic.author.service.AuthorService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
@SpringBootTest
@Transactional
public class AuthorServiceTest {
private final AuthorService authorService;
private final AuthorRepository authorRepository;
@Autowired
public AuthorServiceTest(AuthorService authorService, AuthorRepository authorRepository) {
this.authorService = authorService;
this.authorRepository = authorRepository;
}
@Test
public void authorSaveTest() {
// given
AuthorCreateDto dto = AuthorCreateDto.builder()
.name("abc")
.email("abc@naver.com")
.password("12341234")
.build();
// when
authorService.save(dto);
// then
Author authorDb=authorRepository.findByEmail("abc@naver.com").orElse(null);
Assertions.assertEquals(authorDb.getEmail(), dto.getEmail());
}
@Test
public void authorFindAllTest() {
// findAll을 통한 결과값의 개수가 맞는지를 검증
// 최초개수 구하고
int beforeSize = authorService.findAll().size();
// 데이터 추가(3개)
authorRepository.save(Author.builder().name("h1").email("h1@naver.com").password("12341234").build());
authorRepository.save(Author.builder().name("h2").email("h2@naver.com").password("12341234").build());
authorRepository.save(Author.builder().name("h3").email("h3@naver.com").password("12341234").build());
// db에서 findAll을 통해 총 개수를 구하여 최초개수 +3하고 일치하는지 검증
int afterSize = authorService.findAll().size();
Assertions.assertEquals(beforeSize+3, afterSize);
}
}
@Transactional을 테스트에 적용했을 때 “테스트 메서드 수행 후 롤백”이 기본 동작인지, 또는 특정 테스트만 커밋되게 할지(@Rollback(false) 또는 @Commit) 같은 운영 방식은 프로젝트 규칙에 맞춰 정하면 된다.
IntelliJ IDEA에서는 테스트 클래스 또는 테스트 메서드 왼쪽 여백(gutter)에 보이는 실행 아이콘(초록색 재생 버튼)을 통해 바로 실행할 수 있다. 테스트 클래스 이름 옆 아이콘을 누르면 해당 클래스의 테스트가 전체 실행된다.
특정 테스트만 돌리고 싶다면, 실행하려는 @Test 메서드 옆 아이콘을 눌러 메서드 단위 실행을 하면 된다. 또한 테스트 코드 화면에서 클래스/메서드에 커서를 둔 상태로 실행 메뉴를 사용하면 “현재 컨텍스트(선택 위치)” 기준으로 클래스/메서드 단위 실행이 가능하다.
추가로, 이전에 실행했던 테스트는 상단의 Run 버튼을 누르면 “마지막 실행 구성”으로 다시 실행되며, 필요하면 Run/Debug Configuration에서 JUnit 실행 대상을 클래스/패턴 등으로 지정해 관리할 수도 있다.
