코딩하는 오징어
우리는 테스트 코드를 왜 짜야할까?? 본문
TDD가 유행하면서 테스트에 대한 관심이 많아졌다고 생각한다. 본인도 테스트는 반드시 작성해야한다고 주장하면서 다녔지만 정작 테스트는 어떻게 짜는게 좋고 테스트를 짰을 때 어떤 점이 좋아지는 것에 대한 고민을 깊게 해본것은 최근이었다.
테스트는 왜 짜야 할까?
- 우리가 만든 함수에 대해 신뢰할 수 있다. (단, 그 함수의 입력 값, 출력 값 명세에 대한 범위에 한해서)
- 리팩토링이 필요할 때 테스트 코드가 있다면 안심하고 코드를 고칠 수 있다. (리팩토링을 진행하면서 테스트 프레임워크로부터 빠르게 피드백을 받아 볼 수 있기 때문.)
- 장애(예외)가 발생했을 때 해당 시점의 입력 값을 테스트 코드의 입력으로 주었을 때 어떤 부분이 잘못 됐는지 알 수 있다.(논리 오류로 인한 장애일 경우) 이때 방어 로직이 추가되고 테스트 코드도 보강 됨에 따라 어플리케이션은 더욱 단단해진다.
- 개발자가 모든 경우의 수를 생각해서 테스트를 작성할 수 없다. 다만, 우리가 작성한 경우의 수 만큼은 안전하다는 것이 보장이 되고 예외가 발생했을 경우 빠르게 피드백을 받고 예외에대한 테스트를 보강해 나가면서 테스트는 더욱 견고해진다.
- 빠른 피드백을 통해 좋은 코드(아키텍쳐)가 산출된다. (빠른 실패를 통해 코드를 개선할 수 있음)
- "테스트가 어려운 코드는 좋지 못한 코드다." 라는 말을 많이 들어왔다. 하지만 왜? → 하나의 메서드를 테스트하기 위해 수 많은 의존성 주입이 필요하고 생성해야될 객체 OR mocking할 것들이 많다면 테스트 대상의 책임이 너무 많은 건 아닐지 의심해보자.
- 테스트는 개발자에게 좋은 문서다. 잘 짜여진 테스트를 통해 해당 API의 input과 output을 코드를 통해 확인할 수 있고 실제로 실행시켜 보면서 눈으로 동작을 확인해 볼 수 있다.
테스트 코드를 어떻게 짜야 할까??
- 테스트 코드는 테스트 종류에 따라 테스트 범위가 달라진다.
- unit test < service test < integrate test
- 테스트 범위가 큰 테스트가 많아 질수록 테스트 실행 속도는 느려지고 약한 테스트가 된다.
- 범위가 넓어 질수록 테스트의 수는 작아져야한다.
- 최대한 많은 case를 생각해야한다. sonarqube같은 코드분석 툴은 해당 함수가 몇번의 테스트 수행을 거쳐 통과됐는지 보여준다. 해당 함수가 겨우 한 번의 테스트를 거친 함수라면 안전하지 않다.
- 개발자가 모든 경우의 수를 고려한 입력 값들을 준비하여 테스트를 만들 수 없다. 다만, 개발자가 고려한 입력 값 들에 대한 출력 값은 반드시 보장되어야한다.
- 테스트를 쉽게 작성할 수 있을 때 작성한다. 시기를 놓치지말자 (테스트 코드부터 작성하는 TDD는 의견 충돌이 다분하다.. 적어도 테스트를 작성해야할 시기만은 놓치지 않으면 된다고 생각한다.)
- 테스트는 기본적으로 빠르게 실행되고 빠르게 성공 OR 실패 되어야 한다.
- 우리는 주로 Spring Framework를 사용한다. 테스트를 위해 Spring Context를 띄우는 건 테스트 시간이 길어지는 안좋은 요소 중 하나다. 여기에 MockBean까지 적용한다면 테스트마다 Spring Context를 새로 띄우게 된다. → 테스트 실행속도가 굉장히 느려진다.
- 위의 문제를 해결하기위해 의존성 주입시 Construct Dependency Injection(생성자 주입)을 사용하자. (final 멤버 변수 + 생성자 주입)
- https://reflectoring.io/constructor-injection/
- https://yaboong.github.io/spring/2019/08/29/why-field-injection-is-bad/
- 위의 글들을 보면 생성자 주입의 장점들을 소개한다. 그리고 spring document에도 생성자 주입을 권고하고 있기도하다.
- 장점 : bean null check
- 위의 장점들외에 테스트시에도 생성자 주입을 통해 얻는 이점이 많다. 바로 mock bean을 쓰지 않아도 된다. 그럼 spring context를 띄우지 않아도 테스트를 작성할 수 있다.
- https://reflectoring.io/constructor-injection/
Sample Test
// User.java
package com.example.test.entity;
import javax.persistence.*;
import java.util.Objects;
@Entity
@Table(name = "user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
private boolean useYn;
public User(Long id, String name, String email, boolean useYn) {
this.id = id;
this.name = name;
this.email = email;
this.useYn = useYn;
}
public User() { }
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
public boolean isUseYn() {
return useYn;
}
public void deactivate() {
this.useYn = false;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(id, user.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
// UserRepository.java
package com.example.test.respository;
import com.example.test.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
User findByEmailAndUseYn(String email, boolean useYn);
}
// UserService.java
package com.example.test.service;
import com.example.test.entity.User;
import com.example.test.respository.UserRepository;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User getActivateUserByEmail(String email) {
return userRepository.findByEmailAndUseYn(email, true);
}
@Transactional
public User deactivateUserByEmail(String email) {
User user = getActivateUserByEmail(email);
user.deactivate();
return userRepository.save(user);
}
}
위의 코드를 설명하면 다음과 같다.
- User → 사용자 정보를 담고 있는 Entity
- UserRepository → Application과 DB를 연결 해주는 Class
- UserService → 사용자에 필요한 비즈니스 로직을 책임지는 Class
위의 코드들 중 UserService의 deactivateUserByEmail(..) 함수를 다음과 같이 두 가지 방법으로 테스트 할 수 있다. (Repository는 외부 환경에 의존됨으로 mocking해야한다. h2 database를 사용하면 해결되지만 본글은 h2 database를 사용할 수 없는 환경이라고 가정하겠다.)
spring context를 띄워서 테스트
// SpringTestSupport.java
package com.example.test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@ExtendWith(SpringExtension.class)
@ActiveProfiles("test")
@SpringBootTest
abstract class SpringTestSupport {
}
// TestWithSpringContext.java
package com.example.test;
import com.example.test.entity.User;
import com.example.test.respository.UserRepository;
import com.example.test.service.UserService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import static org.mockito.Mockito.*;
public class TestWithSpringContext extends SpringTestSupport {
@MockBean
private UserRepository mockRepository;
@Autowired
private UserService sut;
@Test
void deleteTest() {
//given
String email = "taewoong.han.squid@navercorp.com";
User dummy = new User(1L, "한태웅", email, true);
User expect = new User(1L, "한태웅", email, false);
when(mockRepository.findByEmailAndUseYn(any(), anyBoolean())).thenReturn(dummy);
when(mockRepository.save(any())).thenReturn(expect);
//when
User dut = sut.deactivateUserByEmail(email);
//then
Assertions.assertFalse(dut.isUseYn());
}
}
spring context를 띄우지 않고 테스트
// TestWithoutSpringContext.java
package com.example.test;
import com.example.test.entity.User;
import com.example.test.respository.UserRepository;
import com.example.test.service.UserService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
public class TestWithoutSpringContext {
private final UserRepository mockRepository = mock(UserRepository.class);
private final UserService sut = new UserService(mockRepository);
@Test
void deleteTest() {
//given
String email = "taewoong.han.squid@navercorp.com";
User dummy = new User(1L, "한태웅", email, true);
User expect = new User(1L, "한태웅", email, false);
when(mockRepository.findByEmailAndUseYn(any(), anyBoolean())).thenReturn(dummy);
when(mockRepository.save(any())).thenReturn(expect);
//when
User dut = sut.deactivateUserByEmail(email);
//then
Assertions.assertFalse(dut.isUseYn());
}
}
위의 두 테스트의 차이는 Spring Context를 띄우는 차이이다. 실행속도의 차이를 보면 어마어마하다.
- spring context를 띄우는 테스트 : 106ms
- spring context를 띄우지 않고 테스트 : 29ms
무려 3배 이상 차이가 난다. spring context의 bean이 반드시 필요한 테스트는 어쩔수 없지만 그렇지 않은 경우에는 테스트 해야할 대상을 직접 생성하여 테스트하면 테스트 실행 속도를 개선할 수 있다.
spring context없이 테스트를 하려면 의존성 주입을 생성자 주입으로 해야한다. 그렇지않고 필드 주입을 하게 된다면 mock bean을 사용할 수 밖에 없고 테스트 실행 속도는 현저하게 떨어진다.
+ 위의 테스트에는 어떠한 주석도 없지만 UserService의 deactivateUserByEmail이라는 메서드가 어떻게 동작하는지 쉽게 확인할 수 있다.
'알쓸신잡' 카테고리의 다른 글
Gradle Dependency Configuration (0) | 2021.09.26 |
---|---|
JVM의 종료와 Graceful Shutdown (2) | 2021.05.19 |
Redirect 와 Forward (2) | 2020.04.27 |
OLTP와 OLAP (1) | 2020.01.05 |
데이터 모델링에서 정규화의 의의와 성능 논쟁 (0) | 2020.01.05 |