테스트의 필요성
1. Regression
잘 돌아가던 코드가 이번 배포로 인해서 동작하지 않는 상황을 Regression이라 부릅니다.
이런 것을 한 두 번 경험하다 보면, 전체적으로 수정과 배포가 무서워지게 됩니다.
2. 좋은 아키텍처를 유도
SOLID
S( 단일 책임 원칙 ) : 테스트는 명료하고 간단하게 작성해야 하기 때문에, 단일 책임 원칙을 지키게 됨. 테스트가 너무 많아져서 이게 무슨 목적의 클래스인지 눈에 안 들어오는 지점이 생김. 이때가 클래스를 분할해야 하는 시섬, 그러면서 책임이 자연스럽게 분배됨.
O ( 개방 폐쇄 원칙 ) : 테스트 컴포넌트와 프로덕션 컴포넌트를 나눠 작업하게 되고 필요에 따라 이 컴포넌트를 자유자재로 탈부착이 가능하게 개발하게 됨
L ( 리스코프 치환 원칙 ) : 이상적으로 테스트는 모든 케이스에 대해 커버하고 있으므로, 서브 클래스에 대한 치환 여부를 테스트가 알아서 판단해 줌
I ( 인터페이스 분리 원칙 ) : 테스트는 그 자체로 인터페이스를 직접 사용해 볼 수 있는 환경. 불필요한 의존성을 실제로 확인할 수 있는 샌드박스
D ( 의존성 역전 원칙 ) : 가짜 객체를 이용하여 테스트를 작성하려면, 의존성이 역전되어 있어야 하는 경우가 생김
3. 결론 : 둘 다 놓치지 말자
TEST와 SOLID는 생각보다 굉장히 긴밀한 상관관계를 갖습니다.
서로가 서로에게 상호보완적입니다.
SOLID 원칙이 지켜지면 경계가 만들어지고,
이로 인해 회귀버그가 생기는 것을 막을 수 있습니다.
➔ 좋은 설계도 놓쳐선 안 되고, 회귀 버그도 놓쳐선 안된다
테스트의 3 분류
전통적인 테스트 3 분류
단위 테스트, 통합 테스트, API 테스트의 정의가 모호합니다.
따라서 저희 프로젝트에서는 구글의 테스트 3 분류를 사용합니다
구글의 테스트 3분류
소형테스트 ( = 단위테스트 )
단일 서버, 단일 프로세스, 단일 스레드, 디스크 I/O 사용해선 안됨, Blocking call 허용 안됨
➔ 항상 결과가 빠르고 , 결정적 ( Deterministic )
중형테스트
단일 서버, 멀티 프로세스, 멀티 스레드 ( h2 같은 테스트 DB를 사용할 수 있습니다.)
➔ 소형테스트 보고 느리고, 멀티 스레드 환경에서 어떻게 동작할지 모르기 때문에 결과가 항상 같다는 보장 X
➔ 중형 테스트를 너무 많이 만드는 것 ( 모든 테스트가 h2 사용하는 것)이 개발자가 많이 저지르는 실수입니다.
대형테스트
멀티 서버, End to end 테스트
프로젝트에 적용
저희 프로젝트에서는 아래와 같이 테스트를 적용하기로 결정했습니다.
- controller - 중형테스트
- service - 중형 테스트, 소형테스트
- domain - 소형테스트
현재 service는 추상화하지 않았기 때문에 controller는 중형 테스트만 작성하였고,
repository 테스트는 JPA가 충분히 해주고 있다고 생각하여 작성하지 않았습니다.
이 글에서는 중형 테스트를 소형 테스트로 바꾸는 것에 초점을 맞춰 service 테스트 코드를 설명하려고 합니다.
전체 코드는 아래 PR에서 확인하실 수 있습니다
https://github.com/Sum-nail/sum-nail-server/pull/55/files
H2와 이용한 Service 중형 테스트
주의할 점
H2 데이터베이스 2.1.212 버전에서 user 키워드가 예약어로 지정되어 있어 user 테이블 생성이 불가능합니다.
이 사실을 모르고 테스트 실행이 안 돼서 삽질하다가
@Tabel 어노테이션을 이용해 users로 테이블 이름을 바꾸니 잘 실행되었습니다
1. 테스트용 application.yml 작성
spring:
h2:
console:
enabled: true // h2 콘솔을 활성화
datasource:
driverClassName: org.h2.Driver
url: jdbc:h2:mem:testdb;mode=MYSQL
username: sa // 데이터베이스에 연결할 때 사용할 사용자 이름
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
'mem:testdb'는 인메모리 모드를 사용한다는 의미로
H2 DB 데이터를 로컬에 저장하지 않고 메모리에만 가지고 있게 합니다.
'mode=MYSQL'를 설정해 주어야 MYSQL 모드로 동작합니다.
'create-drop'은 Hibernate가 애플리케이션을 시작할 때 데이터베이스 테이블을 생성하고
애플리케이션이 종료될 때 테이블을 삭제하도록 지정합니다.
이는 개발 및 테스트 환경에서 주로 사용됩니다.
2. h2 더미데이터 sql 파일 생성
데이터 삽입용 sql
userService 테스트에 필요한 데이터들을 삽입하는 코드를 미리 작성합니다.
저희 프로젝트에서는 userController 테스트에 필요한 더미데이터와 userService 테스트에 필요한 더미데이터가 같아
UserController 테스트를 작성할 때 사용한 sql 재사용해주었습니다.
/*
src/test/resources/sql/user-controller-data.sql
*/
insert into `users` (`user_id`, `email`, `name`, `profile_image`)
values (1, 'sed@yahoo.edu', '썸네일', 'https://guardian.co.uk/one');
insert into `users` (`user_id`, `email`, `name`, `profile_image`)
values (2, 'abc@yahoo.edu', '썸네일2', 'https://guardian.co.uk/one');
insert into nail_shop (nail_shop_id, map_lat, map_lng, employee_num, monthly_nail_maximum_price,
monthly_nail_minimum_price, business_hour, detail_images, location, monthly_nail_instagram_link,
nail_shop_name, naver_map_link, reservation_table, street_address, title_image)
values (1, 41.4019163136, 172.669129728, 9, 14428, 28026, '1:30 PM', 'http://facebook.com/sub?client=g', '서울시 중구',
'http://cnn.com/fr?q=test', '썸네일네일샵', 'https://pinterest.com/en-ca?client=g', 'ligula. Nullam enim.',
'328-2245 Tincidunt. Ave', 'http://zoom.us?g=1');
insert into user_nail_shop (user_id, nail_shop_id)
values (1, 1);
insert into hashtag (hashtag_id, hashtag_name)
values (1, '심플한');
insert into hashtag (hashtag_id, hashtag_name)
values (2, '화려한');
insert into nail_shop_hashtag (hashtag_id, nail_shop_id)
values (1, 1);
insert into nail_shop_hashtag (hashtag_id, nail_shop_id)
values (2, 1);
insert into recent_search(date_time, user_id, station)
values ('2018-10-08 16:38:00.000000', 1, '배방');
insert into recent_search(date_time, user_id, station)
values ('2018-10-09 16:38:00.000000', 1, '외대앞');
데이터 삭제용 sql
삽입했던 데이터들을 모두 삭제해 주어야 에러가 나지 않습니다.
/*
src/test/resources/sql/delete-all-data.sql
*/
delete from user_nail_shop where 1;
delete from nail_shop_hashtag where 1;
delete from nail_shop_hashtag where 2;
delete from recent_search where 1;
delete from recent_search where 2;
delete from `users` where 1;
delete from `users` where 2;
delete from nail_shop where 1;
delete from hashtag where 1;
delete from hashtag where 2;
delete from refresh_token where 1;
3. UserServiceTest 작성
전체 코드
// src/test/java/backend/sumnail/medium/UserServiceTest.java
@SpringBootTest
@SqlGroup({
@Sql(value = "/sql/user-controller-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD),
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
})
class UserServiceTest {
@Autowired
private UserService userService;
@Test
@DisplayName("findUser는 특정 유저를 찾아올 수 있다.")
void findUserTest() {
//given
//when
UserFindResponse result = userService.findUser(1L);
//then
assertThat(result.getName()).isEqualTo("썸네일");
assertThat(result.getEmail()).isEqualTo("sed@yahoo.edu");
assertThat(result.getProfileImage()).isEqualTo("https://guardian.co.uk/one");
}
@Test
@DisplayName("findAllNAilShopUser는 유저가 저장한 네일샵 전체를 찾아올 수 있다.")
void findAllNailShopUserTest() {
//given
//when
List<UserFindNailShopResponse> result = userService.findAllNailShopsUser(1L);
//then
assertThat(result.get(0).getNailShopId()).isEqualTo(1L);
assertThat(result.get(0).getNailShopName()).isEqualTo("썸네일네일샵");
assertThat(result.get(0).getLocation()).isEqualTo("서울시 중구");
assertThat(result.get(0).getTitleImage()).isEqualTo("http://zoom.us?g=1");
assertThat(result.get(0).getHashtags()).isEqualTo(List.of("심플한","화려한"));
}
@Test
@DisplayName("saveNailShopUser를 이용해서 네일샵을 저장할 수 있다.")
void saveNailShopUserTest() {
//given
//when
userService.saveNailShopUser(2L, 1L);
//then
List<UserFindNailShopResponse> result = userService.findAllNailShopsUser(2L);
assertThat(result.get(0).getNailShopId()).isEqualTo(1L);
assertThat(result.get(0).getNailShopName()).isEqualTo("썸네일네일샵");
assertThat(result.get(0).getLocation()).isEqualTo("서울시 중구");
assertThat(result.get(0).getTitleImage()).isEqualTo("http://zoom.us?g=1");
assertThat(result.get(0).getHashtags()).isEqualTo(List.of("심플한","화려한"));
}
@Test
@DisplayName("이미 저장한 네일샵을 다시 저장하면 에러를 던진다.")
void saveNailShopUserErrorTest() {
//given
//when
//then
assertThatThrownBy(() -> {
userService.saveNailShopUser(1L, 1L);
}).isInstanceOf(CustomException.class).hasMessage("이미 저장한 네일샵입니다.");
}
@Test
@DisplayName("deleteNailShopUser를 이용해서 저장한 네일샵을 삭제할 수 있다.")
void deleteNailShopUserTest() {
//given
//when
userService.deleteNailShopUser(1L, 1L);
//then
List<UserFindNailShopResponse> result = userService.findAllNailShopsUser(2L);
assertThat(result.size()).isEqualTo(0);
}
@Test
@DisplayName("저장하지 않은 네일샵을 삭제하면 에러를 던진다.")
void deleteNailShopUserErrorTest() {
//given
//when
//then
assertThatThrownBy(() -> {
userService.deleteNailShopUser(2L, 1L);
}).isInstanceOf(CustomException.class).hasMessage("저장한 적 없는 네일샵입니다.");
}
@Test
@DisplayName("findSearchStationUser를 이용해서 지하철 검색 내역을 조회 할 수 있다.")
void findSearchStationUserTest() {
//given
//when
UserFindSearchStationsResponse result = userService.findSearchStationsUser(1L);
//then
assertThat(result.getStations()).isEqualTo(List.of("외대앞", "배방"));
}
@Test
@DisplayName("deleteSearchStationUser를 이용해서 지하철 검색 내역 전체를 삭제할 수 있다.")
void deleteSearchStationUserTest() {
//given
//when
userService.deleteSearchStationsUser(1L);
//then
UserFindSearchStationsResponse result = userService.findSearchStationsUser(1L);
assertThat(result.getStations().size()).isEqualTo(0);
}
}
@SpringBootTest
현재 클래스가 Spring Boot 테스트 클래스임을 나타냅니다.
이 어노테이션을 사용하면 Spring Boot 애플리케이션을 테스트하기 위한 콘텍스트가 설정되고 필요한 빈들이 로드됩니다.
@SqlGroup, @Sql
@SqlGroup({
@Sql(value = "/sql/user-controller-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD),
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
})
- SqlGroup : sql 파일을 여러 개 지정해서 실행시킬 수 있습니다.
- ExecutionPhase.BEFORE_TEST_METHOD : 테스트 메서드가 실행되기 전 실행
- ExecutionPhase.AFTER_TEST_METHOD : 테스트 메서드가 종료될 때 실행
레이어드 아키텍처의 문제점과 개선
현재 문제점
현재 모든 테스트가 h2를 필요로 합니다.
즉, 중형 테스트만 있으므로 테스트 여러 번 돌리기가 굉장히 부담스러운 상황입니다.
또한 설계가 잘못되었을 확률이 높습니다.
RDB에 강결합 되어있으므로 RDB는 h2 가 있어 테스트가 가능하지만,
테스트용 embedded server가 없는 DB (ex. elastic search)는 테스트할 방법이 없습니다.
지금 작성한 테스트가 실제로 테스트가 필요한 본질이 아닐 확률이 높습니다.
만약, 레이어드 아키텍처를 사용한다면
테스트를 중형 테스트로 짤 수밖에 없게 됩니다.
레이어드 아키텍처?
레이어드 아키텍처는 유사한 기능들을 같은 계층으로 묶어 관리하는 방식의 아키텍처 구조입니다.
의존성 역전이나 추상화 없이 바로 구현체를 사용하는 구조입니다.
장점
기능 개발을 할 때 가시적인 무언가를 만들기에 가장 쉬운 방법
단점
1. DB 주도 설계가 된다
- 레이어드 아키텍처를 사용하면 개발자들은 제일 아랫단인 영속성 레이어(ex.JPA Entity)를 어떻게 만들지 고민하게 됩니다.
- 하지만 실은 도메인과 도메인들의 관계를 생각하는 게 먼저가 되어야 합니다.
2. 동시 작업이 힘들다.
- 아랫단이 나와야 윗단이 개발 가능한 구조이므로 특정 기능 개발은 한 명만 수행 가능합니다
3. 도메인이 죽는다.
- 객체가 수동적이고 모든 코드가 함수 위주로 돌아갈 확률, 즉 절차지향적인 코드가 될 확률이 높아집니다.
- service가 사실상 모든 일을 다 처리하는 존재가 됩니다.
즉, 낮은 Testability와 Bad SOLID를 가지게 됩니다.
개선된 아키텍처
이런 문제점을 해결하여 아키텍처를 개선할 수 있습니다.
아키텍처 개선 과정은 다음 글에서 확인하실 수 있습니다.
https://seowoolog.tistory.com/67
Service를 소형 테스트로 만들기
1. 의존성과 테스트
문제점
@Entity
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class RecentSearch {
@Id
@Column(name = "recent_search_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
private String station;
@Column(updatable = false)
@CreatedDate
private LocalDateTime dateTime;
...
}
기존 RecentSearch 도메인의 dateTime 필드는 @CreatedDate를 이용해,
해당 엔티티가 저장될 때 자동으로 현재 일시가 기록되게 하였습니다.
저희 프로젝트에는 최근 검색어를 최신순으로 dateTime 기준으로 정렬해서 들고오는 로직이 있기 때문에
이런 경우 테스트를 작성하기가 매우 힘듭니다.
이를 의존성 역전을 통해 해결할 수 있습니다.
ClockHoder interface
// src/main/java/backend/sumnail/domain/common/service/port/ClockHolder.java
public interface ClockHolder {
LocalDateTime millis();
}
SystemClockHolder class
// src/main/java/backend/sumnail/domain/common/infrastructure/SystemClockHolder.java
@Component
public class SystemClockHolder implements ClockHolder {
@Override
public LocalDateTime millis() {
return LocalDateTime.now();
}
}
실제 배포 환경에서 사용할 ClockHolder로
이 컴포넌트는 현재 시간을 요청하면 LocalDateTime을 이용해 현재 시간을 내려줍니다.
@Component를 사용해 스프링 빈으로 등록이 되어있어,
실제 배포환경에서 Spring이 UserService를 만들 때 알아서 잘 주입해줍니다.
TestClockHolder class
// src/test/java/backend/sumnail/mock/TestClockHolder.java
public class TestClockHolder implements ClockHolder {
private final LocalDateTime mills;
public TestClockHolder(String dateTimeString) {
this.mills = LocalDateTime
.parse(dateTimeString, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"));
}
@Override
public LocalDateTime millis() {
return mills;
}
}
테스트 환경에서 사용할 ClockHolder로 건네받은 LocalDateTime을 그대로 반환합니다.
실제 테스트에서 사용
fakeRecentSearchRepository.save(
RecentSearch.createRecentSearch(user1, "배방", new TestClockHolder("2002-01-29 16:19:00.000000"))
);
fakeRecentSearchRepository.save(
RecentSearch.createRecentSearch(user1, "외대앞", new TestClockHolder("2002-02-29 16:19:00.000000"))
);
RecentSearch를 생성할 때 TestClockHolder를 넣어주어
우리가 지정한 dateTime 만 내려주도록 합니다.
항상 같은 결과만 내려주는 일관된 테스트가 됩니다.
또한 더 쉽게 input을 변경하고 output을 검증할 수 있는 구조가 되었으므로 Testability가 높아집니다.
➔ 의존성 주입과 의존성 역전을 사용해 배포 환경과 테스트 환경을 분리할 수 있게 되었습니다.
2. FakeRepository 만들기
전체코드
// src/test/java/backend/sumnail/medium/AuthServiceTest.java
public class FakeUserRepository implements UserRepository {
private final AtomicLong autoGeneratedId = new AtomicLong(0);
private final List<User> data = new ArrayList<>(); // 데이터를 저장할 배열
@Override
public User getById(Long id) {
return data.stream()
.filter(item -> item.getId().equals(id))
.findAny()
.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER));
}
@Override
public void save(User user) {
if (user.getId() == null || user.getId() == 0) {
User newUser = User.builder()
.id(autoGeneratedId.incrementAndGet())
.name(user.getName())
.email(user.getEmail())
.profileImage(user.getProfileImage())
.build();
data.add(newUser);
} else {
data.removeIf(item -> Objects.equals(item.getId(), user.getId()));
data.add(user);
}
}
@Override
public Optional<User> findByEmail(String email) {
return data.stream()
.filter(item -> item.getEmail().equals(email))
.findAny();
}
}
AtomicLong
private final AtomicLong autoGeneratedId = new AtomicLong(0);
데이터베이스처럼 자동으로 증가하는 id값이 필요하므로 AtomicLong을 이용해 count 관리를 합니다.
save
@Override
public void save(User user) {
if (user.getId() == null || user.getId() == 0) {
User newUser = User.builder()
.id(autoGeneratedId.incrementAndGet())
.name(user.getName())
.email(user.getEmail())
.profileImage(user.getProfileImage())
.build();
data.add(newUser);
} else {
data.removeIf(item -> Objects.equals(item.getId(), user.getId()));
data.add(user);
}
}
JPA 동작 원리 중 하나가 Id 값이 null 이거나 0이면 insert 하고 그렇지 않으면 update 하는 방식이므로
그것을 fake에 유사하게 구현해고자 위와 같이 작성해줍니다.
autoGeneratedId.incrementAndGet() 이 호출될 때마다 id가 자동으로 증가합니다.
같은 방식으로 fakeUserNailShopRepository, fakeNailShopRepository, fakeRecentSearchRepository, fakeHashtagRepository, fakeNailShopHashtagRepository를 생성해주었습니다
전체 코드는 pr 에서 확인하실 수 있습니다 :)
3. UserServiceTest 작성 ( with Fake )
전체 코드
// src/test/java/backend/sumnail/domain/user/service/UserServiceTest.java
class UserServiceTest {
private UserService userService;
@BeforeEach
void init() {
FakeUserRepository fakeUserRepository = new FakeUserRepository();
FakeUserNailShopRepository fakeUserNailShopRepository = new FakeUserNailShopRepository();
FakeNailShopRepository fakeNailShopRepository = new FakeNailShopRepository();
FakeRecentSearchRepository fakeRecentSearchRepository = new FakeRecentSearchRepository();
FakeHashtagRepository fakeHashtagRepository = new FakeHashtagRepository();
FakeNailShopHashtagRepository fakeNailShopHashtagRepository = new FakeNailShopHashtagRepository();
this.userService = UserService.builder()
.userRepository(fakeUserRepository)
.userNailShopService(new UserNailShopService(fakeUserNailShopRepository))
.nailShopService(new NailShopService(
fakeNailShopRepository,
new HashtagService(fakeHashtagRepository,
new NailShopHashtagService(fakeNailShopHashtagRepository))
))
.recentSearchService(new RecentSearchService(fakeRecentSearchRepository, fakeUserRepository))
.build();
User user1 = User.builder()
.id(1L)
.name("썸네일")
.email("sed@yahoo.edu")
.profileImage("https://guardian.co.uk/one")
.build();
fakeUserRepository.save(user1);
User user2 = User.builder()
.id(2L)
.name("썸네일2")
.email("abc@yahoo.edu")
.profileImage("https://guardian.co.uk/one")
.build();
fakeUserRepository.save(user2);
NailShop nailShop = NailShop.builder()
.id(1L)
.location("서울시 중구")
.name("썸네일네일샵")
.titleImage("http://zoom.us?g=1")
.build();
fakeNailShopRepository.save(nailShop);
fakeUserNailShopRepository.save(UserNailShop.builder()
.nailShop(nailShop)
.user(user1)
.build());
Hashtag hashtag = Hashtag.builder()
.id(1L)
.hashtagName("심플한")
.build();
fakeHashtagRepository.save(hashtag);
fakeNailShopHashtagRepository.save(NailShopHashtag.builder()
.nailShop(nailShop)
.hashtag(hashtag)
.build());
fakeRecentSearchRepository.save(
RecentSearch.createRecentSearch(user1, "배방", new TestClockHolder("2002-01-29 16:19:00.000000"))
);
fakeRecentSearchRepository.save(
RecentSearch.createRecentSearch(user1, "외대앞", new TestClockHolder("2002-02-29 16:19:00.000000"))
);
}
@Test
@DisplayName("findUser는 특정 유저를 찾아올 수 있다.")
void findUserTest() {
//given
//when
UserFindResponse result = userService.findUser(1L);
//then
assertThat(result.getName()).isEqualTo("썸네일");
assertThat(result.getEmail()).isEqualTo("sed@yahoo.edu");
assertThat(result.getProfileImage()).isEqualTo("https://guardian.co.uk/one");
}
... 중략
}
기존에 있던 중형 서비스 테스트를 복사해와 springBoot 어노테이션은 모두 삭제해줍니다.
@BeforeEach를 이용해 초기화 메서드인 init()을 작성해줍니다.
테스트 전에 userService를 미리 만들어서 사용하기 위해,
생성한 fakeRepository를 이용해 userService를 생성합니다.
그리고나서, 테스트용 더미 데이터를 fakeRepository에 save 해줍니다.
테스트 메서드의 구현 부분은 userService 중형 테스트와 모두 같습니다.
4. 테스트 속도 평균 7배 개선
왼쪽은 모두 중형 테스트, 오른쪽은 모두 소형 테스트입니다.
소형 테스트로 바꾸며 테스트 속도를 6-7배 개선할 수 있었습니다.
UserService와 마찬가지로
AuthService를 소형 테스트로 만들어 준 후 테스트 속도가 4배 개선되었고,
RefreshTokenService를 소형 테스트로 만들어준 후 테스트 속도가 9배 이상 개선된 것을 볼 수 있습니다.