들어가기 앞서
산업기능요원으로 현재 회사에 입사한 지가 벌써 2년이 다 되어가고 있다. 하지만 회사 내 스택은 아직 낮은 수준에 있기 때문에, 내가 나가기 전에 회사에 내가 아는 것들을 좀 전파를 많이 해야겠다는 생각을 요새 참 많이 하고 있다.
그래서 세미나를 할 계획을 몇 개 세워놓았는데, 그 첫 번째 세미나는 테스트 코드에 대해 진행하게 되었다.
웹 개발은 프론트엔드와 백엔드로 나누어지며, 특히 우리 회사는 기괴하게도 프론트엔드에 많은 부분이 몰빵되어 있는 부분이 많다. (프론트엔드 쪽에 비즈니스 로직이 많다). 하지만 프론트엔드의 테스트 코드 작성에 대해 얘기하자니, 그에 필요한 사전 지식들이 너무나도 많았다. 그렇기 때문에 차라리 더 입문하기도 쉽고 설명하기도 좋을 것 같은 백엔드 테스트 전략에 대해 이야기 해보려고 한다.
테스트 코드를 이제 막 처음 입문하는 사람들을 위한 세미나이기 때문에 JUnit의 다양한 어노테이션과 기능들, 테스트 최적화 기법들 같은 어려운 지식은 다루지 않으려고 한다.
테스트 코드란 무엇인가?
테스트 코드의 정의
테스트 코드란, _어떤 상황에서, 어떤 코드가 실행되었을 때, 무슨 결과를 발생시키는 지_를 코드로 작성한 것이다.
예를 들면 다음과 같다.
x=2
,y=3
인 상황에서add(x, y)
라는 함수가 실행되었을 때,5
라는 결과가 반환된다.- 장바구니가 빈 고객이 상품 주문을 실행했을 때, 예외가 발생한다.
코드로 확인해보자
이를 코드로 직접 확인해보자.
@Test
@DisplayName("두 숫자를 더한다")
void add() {
// given
int x = 6;
int y = 3;
Calculator calc = new Calculator();
// when
int result = calc.add(x, y);
// then
assertEquals(9, result);
}
상당히 간단하다.
왜 해야 하는가?
🤔: 테스트는 그냥 직접 실행해보면서 하면 되는거 아니야?
많은 사람들이 이렇게 생각하느라 테스트 코드를 작성하지 않는다.
그냥 위의 움짤처럼, 그냥 직접 해보면서 그냥 테스트를 해보면 되는거 아닐까?
아쉽지만 그렇지 않다.
테스트를 짜야 하는 이유
- 거대한 프로젝트에서 개발자가 직접 어플리케이션을 켜서 조작해가면서 기능을 테스트하는 건 너무 귀찮다.
- 새로운 기능을 추가했는데 원래 있던 기능이 갑자기 돌아가지 않는 사례를 겪어본 적 있을 것이다. 이런 경우 나는 분명 테스트를 했음에도 오류가 발생하는 것을 놓치고 넘어갈 수 있다.
- 내가 코드를 커밋할 때, 내 코드가 정상적으로 동작하는 것을 확인을 했다는 일종의 보증서 역할을 하여 팀원에게 신뢰감을 줄 수 있다.
- 코드를 만약 수정해야 할 때 (리팩토링, 라이브러리 교체 등) 다시 직접 해당 코드가 쓰이는 모든 것을 테스트해보지 않아도 변경으로 인한 영향을 쉽게 알 수 있다.
등등.. 테스트 코드를 작성해야 할 이유는 너무 많다 😵💫
어떻게 해야 하는가?
테스트 코드를 짜야 하는 이유에 대해서는 이제 알겠다. 그렇다면 테스트 코드를 어떻게 짜야 할까?
JUnit5 사용법
gradle 프로젝트 기준, /src/test
폴더에 테스트 코드를 작성하며, 메인 코드와 폴더 구조가 똑같은 곳에 테스트를 작성한다. (/src/main/controller
경로의 파일이라면 /src/test/controller
에 테스트를 작성해야 한다는 뜻)
하지만 인텔리제이에서 제공하는 기능 (윈도우 기준 alt + insert
, macOS 기준 cmd + n
)을 사용하면 쉽고 빠르게 테스트 코드를 추가할 수 있다.
class CalculatorTest {
@Test
void add() {
// 주어진 상황에서 (given)
int a = 1;
int b = 2;
Calculator calculator = new Calculator();
// 어떤 코드가 실행되었을 때 (when)
int result = calculator.add(a, b);
// 어떤 결과가 나온다 (then)
Assertions.assertEquals(3, result);
}
}
다음과 같이 @Test
어노테이션을 붙여서 테스트 코드임을 명시할 수 있으며, 테스트의 결과는 System.out.println
과 같은 함수로 확인하는 것이 아니라, JUnit에서 제공하는 Assertions.assertXXX
함수들을 사용해서 결과를 확인한다. (println으로 하면 모든 테스트를 사람이 직접 눈으로 확인해야 하는데, 이건 어렵다)
테스트의 구성은 크게 이렇게 given-when-then 패턴으로 구성된다.
Spring Boot와 JUnit 사용법
하지만 우리는 순수 자바 개발자가 아니라, 스프링 부트를 사용하는 개발자다.
Mybatis를 사용한다던가, API 테스트, application properties 읽어오기 등의 기능을 위해 순수 자바가 아닌, Spring의 힘을 빌려야 한다면 @SpringBootTest
어노테이션을 붙여서 스프링부트 환경에서 테스트를 작동할 수 있다.
@SpringBootTest
class MemberControllerTest {
@Autowired
MemberController controller;
@Test
void getValue() {
// when
String result = controller.getValue();
// then
Assertions.assertEquals("setting-for-test", result);
}
}
@RestController
public class MemberController {
@Autowired
ApplicationPropertyChecker checker;
@GetMapping("/test")
public String getValue() {
return checker.getTestValue();
}
}
@Component
public class ApplicationPropertyChecker {
@Value("${test.value}")
private String testValue;
public String getTestValue() {
return this.testValue;
}
}
대강 이런 느낌. 위의 코드에서는 application properties를 잘 읽어오는 모습을 볼 수 있다.
그리고 추가로, 운영과 테스트 코드 상의 application.properties를 분리해줄 수 있다. /src/test/resources
안에 application.properties를 명시해주면 테스트 코드를 실행할 때에는 해당 폴더 안의 어플리케이션 프로퍼티를 가져온다.
무엇을 해야 하는가?
크게 여기까지가 테스트 코드에 입문하기 위한 최소한의 지식이라고 생각이 든다.
그렇다면, 우리는 무엇을 테스트해야 하는 것일까?
계층형 아키텍처 (Layered Architecture)
우리는 스프링으로 서버를 구성할 때, 계층형 아키텍처를 이용해서 어플리케이션을 구성한다.
계층형 아키텍처란 보통 Controller - Service - Repository(Mapper)를 통해 어플리케이션을 관심사 별로 각 계층으로 분할한 아키텍처를 의미하는데, 이렇듯이 우리가 테스트 코드를 작성할 때에도 테스트를 관심사 별로 진행해야 한다.
- Controller에서는 데이터의 유효성을 제대로 검증하는지, Service 계층에서 일어난 결과로 HTTP Status Code를 제대로 리턴 하는지 체크한다.
- Service, Domain에서는 비즈니스 로직이 정상적으로 작동하는지 체크한다.
- Repository / Mapper에서는 내가 작성한 쿼리가 정상적으로 작동하는지, JPA라면 연관관계가 정상적으로 작동하는지 등을 체크한다.
(이에 대한 더 자세하고 깊은 내용은 요 링크를 참조하면 좋다: https://github.com/cheese10yun/spring-guide/blob/master/docs/test-guide.md )
인수 테스트
우리가 지금까지 앞에서 보여주었던 테스트 코드는 어찌보면 개발자 관점에서 작성한 테스트 코드이다.
우리의 어플리케이션을 사용하는 사용자는, 과연 MemberController
가 application.properties
의 값을 잘 읽어오는지를 알 필요가 없다. 그들에게 중요한 것은, 자신들이 해결하기를 원하는 문제의 시나리오가 잘 작동하는지가 중요하다.
그렇기에 우리는 _인수 테스트_를 작성할 수 있다.
인수 테스트란, 개발된 시스템이 요구사항과 일치하는 지 확인하기 위해 고객의 입장에서 수행하는 테스트를 의미한다.
예를 들어 우리가 로또 시스템을 구현한다고 하면..
- 사용자가 서로 다른 6개의 번호를 고르고 로또를 구매하면 구매에 성공한다.
- 사용자가 6개 미만의 번호를 고르고 구매를 하면 구매에 실패한다.
- 사용자는 같은 번호를 여러번 고를 수 없다
등등..
이런 사용자의 시나리오에 따른 테스트 코드를 작성하는 것이 인수 테스트이다.
이런 인수 테스트를 작성하면, 결과적으로 우리가 만드는 프로그램이 고객의 요구사항과 맞게 잘 구현하였는지 알기도 쉽고, 추후에 새로 사람이 들어오더라도 프로그램의 비즈니스 로직에 대해 테스트 코드만 보더라도 시나리오를 이해하기가 쉽다는 점이 장점이다.
테스트하기 쉬운 코드
테스트를 하려니 api가 진짜로 쏴져요 😱
이런 코드가 있다고 하자.
회원 가입을 구현한 로직의 코드이다.
@Service
public class MemberJoinService {
@Autowired
private MemberDao memberDao;
@Transactional
public void join(JoinRequest joinRequest) {
checkDuplicateMemberByEmail(joinRequest.getEmail());
Member newMember = new Member(
joinRequest.getEmail(),
joinRequest.getPassword(),
joinRequest.getName(),
LocalDateTime.now()
);
memberDao.insert(newMember);
// 회원 가입 축하 이메일 발송
WebClient.create("https://email-service.com")
.post()
.uri("/send")
.body(BodyInserters.fromValue(newMember.getEmail()))
.retrieve()
.subscribe();
}
private void checkDuplicateMemberByEmail(String email) {
if (memberDao.selectByEmail(email) != null) {
throw new DuplicateMemberException("중복 이메일입니다");
}
}
}
나름 어엿해보이는 잘 짜여진 비즈니스 로직이다.
이 코드의 문제가 뭘까?
뭐 여러가지 생각이 들 수 있겠지만, 지금 내 눈에 들어오는 부분은 테스트가 어렵다는 점이다.
이 코드를 테스트하기 위해서 다음과 같이 테스트 코드를 작성하고 실행했다고 하자.
@SpringBootTest
public class MemberJoinServiceTest {
@Autowired
MemberJoinService memberJoinService;
@Autowired
MemberDao memberDao;
@Test
public void joinTest() {
// given
JoinRequest joinRequest = new JoinRequest(
"myc1365@entropykorea.com",
"1234",
"문영채"
);
// when
memberJoinService.join(joinRequest);
// then
Member member = memberDao.selectByEmail(joinRequest.getEmail());
assertNotNull(member);
}
}
아쉽게도.. 회원 가입을 체크하는 로직은 잘 테스트가 되었다고 하더라도, 실제로 이메일이 테스트할 때마다 날아가게 된다.
이러면 이메일을 보내는 비용이 발생하기 때문에 테스트를 진행하는 데에 무리가 생기게 된다. 소위 말해, "테스트가 어렵다".
그렇다면 이건 뭐가 문제일까? 테스트 코드를 짜라고 한 나의 포스팅?
문제는 바로 '회원가입을 하는 비즈니스 로직과, 이메일을 보내는 비즈니스 로직이 강결합 되어있다'는 점이다.
어떻게 고칠 수 있을까? 정답은 바로 객체지향의 기초, SOLID 원칙 중 DIP에 있다.
테스트하기 쉬운 코드가 좋은 코드다
@Service
public class MemberJoinService {
private final MemberDao memberDao;
private final EmailService emailService;
public MemberJoinService(MemberDao memberDao, EmailService emailService) {
this.memberDao = memberDao;
this.emailService = emailService;
}
@Transactional
public void join(JoinRequest joinRequest) {
checkDuplicateMemberByEmail(joinRequest.getEmail());
Member newMember = new Member(
joinRequest.getEmail(),
joinRequest.getPassword(),
joinRequest.getName(),
LocalDateTime.now()
);
memberDao.insert(newMember);
// 회원 가입 축하 이메일 발송
emailService.send(newMember.getEmail(), "회원 가입 축하 메일", "회원 가입을 축하합니다");
}
private void checkDuplicateMemberByEmail(String email) {
if (memberDao.selectByEmail(email) != null) {
throw new DuplicateMemberException("중복 이메일입니다");
}
}
}
interface EmailService {
void send(String email, String title, String content);
}
이메일을 발송하는 로직을, join
이라는 함수에서 직접 Webclient를 통해 이메일을 발송하는 것이 아니라, EmailService
라는 다른 인터페이스로 위임하였다.
다시 말해, 이메일을 발송하는 로직을 interface를 사용해서 MemberJoinService로부터 추상화한 것이다.
거기에, @Autowired
를 통해 스프링 컨테이너의 도움을 받아 의존성을 주입 받던 기존 로직에서 생성자 주입을 통해 의존성을 주입 받도록 변경하여, 스프링 컨테이너의 도움 없이도 내가 언제든 의존성을 원하는 녀석으로 주입할 수 있도록 수정하였다.
이렇게 될 경우, 테스트 코드를 작성하기가 매우 쉬워진다.
인터페이스를 통해 이메일을 보내는 로직을 추상화한 EmailService
는, 그 어떤 녀석이든 될 수 있다.
아까 전에 우리가 이메일을 보낼 때 사용했던, API를 쏴서 이메일을 보내는 EmailApiService
가 될 수도 있고, 가짜로 이메일을 보내는 척만 하는 EmailMockService
가 될 수도 있으며, SMTP를 사용해서 이메일을 보내는 EmailJavaMailSenderService
가 될 수도 있다.
public class MemberJoinServiceTest {
@Test
public void joinTest() {
// make Fake Services
EmailService emailService = new FakeEmailService();
MemberDao memberDao = new MemoryMemberDao();
// given
MemberJoinService memberJoinService = new MemberJoinService(memberDao, emailService);
JoinRequest joinRequest = new JoinRequest(
"myc1365@entropykorea.com",
"1234",
"문영채"
);
// when
memberJoinService.join(joinRequest);
// then
Member member = memberDao.selectByEmail(joinRequest.getEmail());
assertNotNull(member);
}
}
결과적으로 수정된 테스트 코드는 다음과 같다. MemberDao
와 EmailService
를 가짜로 퉁쳐서, 이제 더 이상 회원 가입 로직을 테스트할 때 이메일도 날아가지 않고, 실제 DB에 데이터를 저장하지도 않는다.
@Autowired를 사용할 필요가 없어졌기 때문에 @SpringBootTest
도 사라졌으니 테스트 코드를 실행하는 속도가 사라진 것은 덤이다.
변경에 유연한 코드
우리는 테스트를 쉽게 하기 위해서 이렇게 코드를 수정함으로써, 더욱 변경에 유연한 코드를 만들어냈다.
회원가입을 할 때 이메일을 보낸다고 하는 비즈니스 로직은 변하지 않는다. 하지만 이메일을 보내는 세부 기술은 언제든 변경될 수 있다.
우리는 위와 같이 수정함으로써, 언제든 이메일을 보내는 세부 기술을 기존 코드에 큰 영향 없이 변경할 수 있게 되었다.
결국 결론은 다음과 같다.
- 좋은 디자인으로 구현된 코드는 대부분 테스트하기 쉽다.
- 만약 테스트하기 어렵게 코드가 구현되어 있다면, 코드의 확장성과 의존성 등의 설계가 잘못 되었을 확률이 높다.
- 테스트 코드는 우리가 코드의 악취를 맡게 해주는 좋은 수단이다.
이렇게 좋은 테스트 코드, 이래도 안 짜실건가요?
앞서 테스트 코드에 대한 찬가를 마구 늘여놓았다.
하지만 테스트 코드를 왜 짜야하는지 몰라서 안 짜는 사람도 있고, 어떻게 짜는지 몰라서 안 짜는 사람도 있겠지만, 사실은 이런 사람들이 가장 많을 것이라 생각이 든다.
테스트 작성할 시간이 없어요
항상 하.. 시간이 부족해. 나중에 짜자.. 나중에 짜자.. 한다.
하지만 유명한 격언이 있다.
나중은 절대 오지 않는다.
르블랑의 법칙
또, 테스트 주도 개발의 저자로 유명한 Kent Beck님은 이런 말을 남기신 적이 있다.
테스트를 처음 작성할 때에는 귀찮고 개발을 느리게 한다는 느낌을 받을 수 있지만,
장기적으로 보면 반드시 개발 비용을 아껴줄 것이다.
테스트 코드를 작성하면 분명 귀찮고 개발에만 집중하기에도 시간이 부족하다 생각이 들 수도 있다.
하지만 설계의 변경이나 요구 사항의 변경, 코드의 유지 보수 등으로 코드가 수정되어야 할 때 개발 비용을 크게 아껴줄 수 있다.
마무리
이렇게, 테스트 코드를 왜 짜야 하는지, 어떻게 짜야 하는지도 알려드렸고, 마지막으로 테스트 코드를 짤 시간이 없다는 의견도 논파해드렸다.
앞으로는 많은 테스트 코드를 짜며 즐거운 프로그래밍을 하실 수 있기를 기원합니다.
'개발 일반 > 테스트 코드' 카테고리의 다른 글
[번역] 단위 테스트는 과대평가되었습니다: 테스트 전략 다시 생각하기 (1) | 2023.10.30 |
---|---|
Jest로 Frontend 테스팅 할 때 힘들었던 점들 정리 (0) | 2023.04.13 |