개발 공부를 하는 주변 지인들을 보면, 종종 테스트 코드를 작성하지 않는 경우를 발견하곤 합니다. 명확한 이유는 모르더라도 주변에서 테스트 코드를 작성해야 한다는 것 쯤은 들어본 경우가 대부분이지만, 테스트 코드를 작성하는 것이 막연하게만 느껴지기도 하고, 테스트 코드를 작성하고 관련 내용을 공부하는데 시간을 쓰기 아까워하는 경우가 많은 것 같습니다.
종종 주위 사람들에게서 자주 듣는 말들이 있습니다. "단위 테스트를 작성하는 게 의미가 있나요?", "차라리 통합 테스트를 작성하는게 기능이 정상 동작하는 것을 검증하는데 더욱 의미있지 않나요?"와 같은 이야기를 듣습니다. 이는 단위 테스트위 목적과 장점을 이해하지 못한 상태에서 테스트 코드를 작성하려고 했기 때문이라고 생각합니다.
이 글을 통해 소프트웨어 테스트와 단위 테스트가 무엇인지, 왜 단위 테스트를 작성해야 하는지, 그리고 이를 통해 기능이 요구사항을 충족하고 있는지를 검증할 수 있는 방법에 대해 알아보고자 합니다.
저 역시 아직 어려움이 많지만, 테스트 코드 작성을 어려워했던 경험이 있기에 이런 경험을 살려 어떤 내용들을 알아야 할지 생각해보며 이야기해보고자 합니다.
💁🏻♂️ 이 글에서 다루는 예제 코드는 Java, Spring Boot, JUnit 환경에서의 백엔드 프로젝트 코드를 다루고 있습니다. 다만, 코드 구현이 주요한 내용은 아니므로 다른 언어를 사용하시는 분들도 문제 없이 읽을 수 있습니다.
개발을 하다보면 주변에서 쉽게 들을 수 있는 말이 있다. “테스트 코드를 작성하는 것은 중요하다”는 것이다. 그럼 왜 테스트 코드를 작성해야 하고, 어떻게 작성하는 것일까?
소프트웨어를 개발하다보면 논리적/기술적 오류로 또는 단순히 개발자의 실수로 문제가 종종 발생하곤 한다. 그 때문에 애플리케이션의 안정성을 확인하고자 어떤 형태로든 테스트를 진행하게 된다. (테스트 코드 작성, 실제로 프로그램 동작시켜 확인해보기 등)
구현된 기능에 대한 안정성을 확인하지 않고 개발 프로세스를 진행하게 되는 경우 버그, 오류의 발생과 요구사항의 변경에 대응하기 위해 많은 노력과 시간이 들어가게 된다. 만약 테스트 코드를 작성해두어 프로그램의 안정성을 확인한다면 현재 구현된, 또는 추후에 변경될 기능들에 대해서 지속적으로 안정성을 확인할 수 있다. 결과적으로 테스트 코드를 잘 작성해두면 안정성 있는 기능을 개발할 수 있으며, 오류와 요구사항 변경 등의 이슈에도 유연하게 대응할 수 있다.
그렇다면 테스트 코드는 어떻게 작성하는 것일까? 단위 테스트, E2E 테스트, 통합 테스트 등 많은 테스트 방법들이 있지만 본문에서는 단위 테스트를 작성보고, 테스트 설계에 대한 개발 방법론인 TDD(Test Driven Development)에 대해 알아보고자 한다.
테스트 피라미드는 소프트웨어 테스트 전략의 대표적인 개념으로, 상단으로 올라갈수록 더 복잡하고 시간이 많이 걸리는 테스트를, 하단으로 내려갈수록 더 작고 빠르게 실행되는 테스트를 의미한다. 위 사진의 테스트 피라미드에는 다음과 같은 세 개의 계층이 나타나고 있다.
단위 테스트(Unit Test): 단위 테스트는 작은 코드 단위(ex. 메서드의 개별적인 동작 등)를 테스트하는 것으로, 빠르고 간단하게 작성할 수 있어 빈번하게 사용하기와 자동화하기 용이하다. 개별적이고 작은 코드 단위를 테스트하는 것이므로 빠르게 피드백을 제공하여 코드 작성과 동시에 버그를 잡을 수 있다.
통합 테스트(Integration Test): 통합 테스트는 여러 모듈이나 시스템 간의 커뮤니케이션을 테스트하는 단계이다. 예를 들어 외부 시스템이나 DB, API와 통신하는 부분을 테스트하며, 그렇기에 단위테스트보다 무겁고 복잡하다. 여러 모듈이 결합되어 실제로 올바르게 동작하는지 확인하고자 할 때 유용하며, 개별 단위는 잘 동작하더라도 여러 단위들을 통합할 시 발생할 수 있는 문제를 발견할 수 있다.
E2E 테스트(End-to-End Test): E2E 테스트는 실제 사용자가 애플리케이션을 사용하는 흐름을 그대로 시뮬레이션 해보는 단계이다. 로그인부터 결제까지의 전 과정을 검증하는 등 전체 시스템이 제대로 동작하는지 확인하는 것이 목표이다. E2E 테스트는 전체 시스템을 테스트해보는 것이므로 가장 많은 리소스가 필요하고, 무겁기 때문에 모든 시나리오를 테스트하기 어려울 수 있다.
테스트 피라미드는 소프트웨어를 개발함에 있어, 효율적이고 체계적인 테스트 전략을 제공하는 모델이다. 개발자는 단위 테스트, 통합 테스트, E2E 테스트를 적절히 사용한다면 더 나은 품질의 소프트웨어를 사용자에게 제공할 수 있을 것이다.
단위 테스트(Unit Test)는 전체 시스템 및 소프트웨어와는 별개로, 특정 구성 요소나 코드 집합 등 작은 단위(unit)를 테스트하는 방법이다. 단위 테스트의 주요 목적은 각 단위가 예상대로 잘 작동하는지 확인하는 것이다.
단위 테스트에서 무언가를 테스트 할 때는 테스트하고자 하는 대상이 온전하게 담당하고 있는, 자기 자신만의 역할을 검증하는 것에 중점을 둔다. 그렇기에 외부 의존성이나 테스트 대상이 아닌 기능들에 대해서는 배제하고 테스트를 진행하게 된다.
public interface PointHistoryRepository {
// 특정 유저에 대한 point history 정보를 전부 조회한다
List<PointHistory> findAllByUser(Long userId);
}
@RequiredArgsConstructor
public class PointService {
private final PointHistoryRepository pointHistoryRepository;
public int getTotalPoint(Long userId) {
return pointHistoryRepository
.findAllByUser(userId).stream()
.map(PointHistory::getPoint)
.sum();
}
}
위 코드에서 getTotalPoint
의 역할은 DB에서 포인트 내역을 전부 조회하여 총 몇 포인트가 쌓였는지 확인하는 것이다(포인트 사용은 하지 않는다고 가정). getTotalPoint
의 기능은 "DB에서 포인트 내역을 전부 조회하기"와 "포인트 내역들을 활용해 총 포인트가 몇인지 구하기"로 나누어 볼 수 있다. getTotalPoint
가 예상대로 잘 동작하려면 DB 연결에 성공해야 하고, DB에서 포인트 내역들을 정상 조회해야 하며, 조회된 포인트 내역들을 빠짐없이 전부 더해야 한다. 이러한 과정들이 모두 성공적으로 진행됐을 때 getTotalPoint
는 "잘 동작한다"고 할 수 있을 것이다.
하지만 단위 테스트에서는 이 과정들을 모두 검증하려고 하지 않는다. 단위 테스트에서는 검증 대상인 PointService
와 getTotalPoint
를 하나의 단위로 보고 이 단위에 대한 정상 동작만을 검증한다. DB에서 데이터를 조회하는 역할을 부여받은 PointHistoryRepository
가 잘 동작할 것이라고 가정하고, 포인트 내역들을 활용해 총 포인트가 몇인지 구하는 getTotalPoint
만이 담당하고 있는 역할을 검증하는 것에 집중하는 것이다.
그렇다면 많고 많은 테스트 방법 중 왜 단위 테스트를 작성해야 하는 것일까? 적은 테스트 코드로 더 많은 기능을 검증할 수 있는 통합 테스트나 E2E(End-to-End) 테스트를 작성하는 편이 효율적이고 좋지 않을까? 이제 단위 테스트의 이점에 대해 알아보자.
다른 테스트 방식보다 테스트 코드 작성에 걸리는 시간이 적다. 단위 테스트는 테스트 대상에 대한 로직 검증만을 하므로 테스트 코드 작성에 시간이 적게 걸린다. 그러므로 테스트 대상의 많은 시나리오를 테스트하며 높은 테스트 커버리지를 가져갈 수 있다. E2E 또는통합 테스트로 예상되는 모든 시나리오를 테스트한다고 상상해보면 정말 아찔하지 않은가?
버그를 조기에 발견하고 수정할 수 있다. 작은 구성요소에 대해 다양한 시나리오를 테스트하게 되므로 각 단위에 대한 요구사항을 제대로 만족하는지 빠르게 검증할 수 있다. 이는 기능이 커질수록, 팀의 규모가 커질수록 큰 이점으로 다가온다. 예를 들어, 장바구니에 담아둔 제품들을 주문하는 기능을 제공한다고 가정해보자. 장바구니에는 여러 제품들이 담겨있을 것이며, 최종 결제 금액을 계산하기까지 많은 정책들이 존재할 것이다. 개발자들은 각자 담당한 기능을 개발하고, 하나로 결합함으로써 사용자들에게 완성된 하나의 주문 기능을 제공할 수 있다. 그런데 막상 테스트를 진행해보니 총 결제액이 예상보다 적게 책정되고 있다. 어느 정책에서 버그가 있는 것일까? 아니면 계산 로직이 잘못된 것일까? 이제 개발자들은 버그를 잡기 위해 많은 시간을 들여야 할 것이다. 만약 기능 개발과 함께 단위 테스트를 작성했다면 이러한 문제를 조기에 발견하고 조치할 수 있었을 것이다.
코드 리팩토링 및 유지보수를 더욱 쉽게 만들어준다. 단위 테스트를 꼼꼼히 잘 수행했다면, 추후 코드를 리팩토링 했을 때 기능의 정상 동작 여부를 쉽게 검증할 수 있다. 수정된 코드가 작성된 테스트 케이스를 통과하는지, 그로 인해 기능이 요구사항을 충족시키고 있는지만 확인하면 되기 때문이다. 이러한 편리함은 리팩토링 하는 것을 쉽게 만들어주고, 리팩토링은 곧 코드 품질 향상으로 이어질 수 있다.
잘 작성된 테스트 코드는 그 자체로 문서로 활용할 수 있다. 테스트 코드를 살펴보면 테스트 대상이 어떤 요구사항들을 갖고 있고, 어떤 역할을 수행해야 하는지 알 수 있다. 더욱이 단위 테스트의 경우, 각 기능에 대한 요구사항을 살펴볼 수 있어 테스트 코드 자체만으로도 다른 개발자들이 살펴볼 수 있는 문서의 역할을 할 수 있다.
단위 테스트에서는 테스트 대상 외의 기능들에 대해서는 전혀 관심을 갖지 않는다. 그렇기에 테스트하려는 대상 객체와 연관된 객체는 실제 객체를 사용하면 안 된다.
앞서 잠깐 살펴봤던 예제 코드를 다시 보자.
public interface PointHistoryRepository {
// 특정 유저에 대한 point history 정보를 전부 조회한다
List<PointHistory> findByUser(Long userId);
}
@RequiredArgsConstructor
public class PointService {
private final PointHistoryRepository pointHistoryRepository;
public int getTotalPoint(Long userId) {
return pointHistoryRepository
.findByUser(userId).stream()
.map(PointHistory::getPoint)
.sum();
}
}
여기서 우리가 테스트하고자 하는 기능은 PointService.getTotalPoint
이다. 그러나 해당 기능은 PointHistoryRepository
의 findByUser
를 사용하고 있기 때문에 테스트하고자 하는 기능의 결과가 findByUser
메서드의 결과에 영향을 받는다.
예를 들어, findByUser
의 결과로 조회된 값이 없다(empty list)거나, 심지어는 DB 연결/통신에 실패할 수도 있을 것이다. 그러나 이런 케이스들은 PointService.getTotalPoint
비즈니스 로직의 관심 대상이 아니다. PointService
는 DB에 실제로 데이터가 얼마나 들어있는지, DB에서 값을 잘 불러올 수 있는지는 궁금해하지 않는다. 단지 PointHistoryRepository.findByUser
가 넘겨준 결과를 받아 총 포인트 액수를 잘 취합하는지만 검증하면 되는 것이다.
이처럼 단위 테스트에서는 테스트 대상이 아닌, 연관된 요소들을 실제 객체를 사용하여 검증해서는 안 된다. 그렇기에 실제 객체 대신 테스트 목적으로 사용되는 가상 객체를 주입해주게 되는데, 이러한 가상 객체들을 test doubles라고 한다.
Test double은 크게 Dummy, Fake, Stub, Spy, Mock으로 나눌 수 있다.
1. Dummy
테스트를 위해 객체를 전달하긴 하지만, 실제로 사용하지는 않는 것을 의미한다. 테스트 대상을 구성하기 위해 값을 채우는 용도로만 사용한다.
@RequiredArgsConstructor
public class PointService {
private final PointRepository pointRepository;
private final PointHistoryRepository pointHistoryRepository;
public int getTotalPoint(Long userId) {
return pointHistoryRepository
.findByUser(userId).stream()
.map(PointHistory::getPoint)
.sum();
}
}
위처럼 PointService
가 여러 기능들을 구현하기 위해 두 개의 의존성(PointRepository
, PointHistoryRepository
)을 받고 있다고 가정해보자. PointService.getTotalPoint
를 테스트하기 위해서는 PointHistoryRepository
만 있으면 된다. 그러나 PointService
객체를 생성하고 구성하려면 PointRepository
객체가 필요하다. 사용은 하지 않지만 테스트 대상의 구성을 위해 필요한 경우, 이럴 때 다음과 같은 dummy 객체를 넣어줄 수 있다.
public class PointRepositoryDummy implements PointRepository {
@Override
public void example() {
// 아무런 기능도 하지 않음
}
}
2. Fake
실제로 동작하는 구현 로직을 갖고 있으나, 실제 프로덕션에서 사용하기에는 적합하지 않은 객체이다. 주로 실제 객체를 단순화하여 구현한 형태가 된다.
public class PointHistoryRepositoryFake implements PointHistoryRepository {
List<PointHistory> pointHistoryTable = new ArrayList<>();
@Override
public void save(PointHistory pointHistory) {
pointHistoryTable.add(pointHistory);
}
@Override
public List<PointHistory> findByUser(Long userId) {
return pointHistoryTable.stream()
.filter(pointHistory -> pointHistory.getUserId().equals(userId))
.toList();
}
}
3. Stub
테스트에서 호출하는 요청에 대해 미리 준비된 결과를 제공하는 객체이다. 일반적으로 테스트 케이스에서 예상되는 특정 응답을 정의하기 위해 사용된다.
public class PointHistoryRepositoryStub implements PointHistoryRepository {
@Override
public List<PointHistory> findByUser(Long userId) {
return List.of(
new PointHistory(1L, userId, CHARGE, 500),
new PointHistory(2L, userId, CHARGE, 1000)
);
}
}
이 코드에서 findByUser
는 전달받은 userId
의 값과는 관계 없이 항상 일정한 결과를 반환한다.
4. Spy
Spy는 Stub의 역할을 수행하면서, 호출에 대한 정보를 기록하는 객체이다. 호출에 대한 정보는 '호출이 이루어졌는지 여부(boolean
)', '호출이 몇 번 이루어졌는지(int
)' 등이 될 수 있다.
public class PointHistoryRepositoryStub implements PointHistoryRepository {
@Getter
private boolean findByUserMethodWasCalled = false;
@Override
public List<PointHistory> findByUser(Long userId) {
this.findByUserMethodCallCount = true;
return List.of(
new PointHistory(1L, userId, CHARGE, 500),
new PointHistory(2L, userId, CHARGE, 1000)
);
}
}
위 코드의 findByUser
에서는 메서드가 호출될 때, 임의의 테스트 데이터를 제공할 뿐만 아니라 자신이 호출되었는지 기록한다.
이 정보를 활용하면 테스트 대상의 세부 기능들이 원하는대로 호출되었는지 검증할 수 있다.
@Test
void findByUserTest() {
// given
PointService sut = new PointService(new PointHistoryRepositoryStub());
long userId = 1L;
// when
int result = sut.getTotalPoint(userId);
// then
assertTrue(sut.getFindByUserMethodWasCalled());
}
5. Mock
Mock은 호출에 대한 예상 결과를 정의하고, 정의된 내용에 따라 동작하는 객체이다. 또한 mock 객체는 수신된 호출을 기록하고 분석하기도 한다.
다음은 mock 객체를 사용한 단위 테스트 작성을 돕는 Mockito 라이브러리를 활용한 테스트 코드이다. 구체적인 작성법보다는 mock 객체를 사용하는 방법을 살펴보자.
@ExtendWith(MockitoExtension.class)
public class PointServiceTest {
@Mock
private PointHistoryRepository pointHistoryRepository;
@Test
void findByUserTest() {
// given
long userId = 1L;
List<PointHistory> expectedResult = List.of(
new PointHistory(1L, userId, CHARGE, 500),
new PointHistory(2L, userId, CHARGE, 1000)
);
given(pointHistoryRepository.findByUserId(userId))
.willReturn(expectedResult)
// when
int actualResult = sut.getTotalPoint(userId);
// then
then(pointHistoryRepository).sholud().findByUserId(userId);
assertThat(actualResult).isEqualTo(500 + 1000);
}
}
PointService
가 필요로 하는 외부 의존성인 PointHistoryRepository
를 mock 객체로 설정했다. 그 후 given(...).willReturn()
으로 mock 객체가 호출되었을 때 제공해야 하는 결과를 정의했다. 이후 검증 단계에서는 PointHistoryRepository.findByUserId
가 실제로 호출되었는지, 테스트 대상의 실행 결과가 예상과 일치하는지를 검증한다.
지금까지 소프트웨어 테스트의 중요성과 다양한 테스트 방법에 대해 살펴보았습니다. 특히, 그중 단위 테스트가 어떻게 소프트웨어의 개별 기능을 빠르고 정확하게 검증하는데 유용한지를 알아보았습니다. 단위 테스트는 초기에 문제를 발견하고 수정할 수 있는 효과적인 방법임을 알 수 있었습니다.
참고 자료
What is Testing Pyramid?
Importance of Unit Testing in Software Development
17 Best Unit Testing Frameworks In 2024
Mock, Stub, Spy and other Test Doubles
Test Double을 알아보자
효율적인 테스트를 위한 Stub 객체 사용법