테스트 코드 작성 스타일. Classist. Mockist. and Hybrid.
테스트 코드를 작성하다 보면 각자의 스타일에 따라 작성 방식이 달라지게 된다.
@Sql 어노테이션을 이용해서 작성한 SQL 스크립트를 테스트 코드 실행에 사용하는 Classist 스타일과
Mock 클래스를 inject 해서 사용하게 되는 Mockist 스타일로 나눌 수 있다.
1. Classist.
Class를 있는 그대로 사용해 테스트 하는 스타일을 classist라고 한다.
Sql 스크립트를 이용해서 하는 테스트의 경우에는 DB 쿼리 수행 부터 서비스 계층까지
한 번에 테스트 할 수 있다는 장점이 있다.
@SpringBootTest
class TestClass {
@Autowired
CalculateService calculateService;
@DisplayName("철수의 구매 대금을 모두 합하면 50,000원 이다.")
@Sql("/data/purchase-test-data.sql")
@Test
void addTest() {
List<Purchase> purchaseAmountList =
calculateService.searchPurchaseByEmail("cheolsoo@mail.com");
BigDecimal allAmount = BigDecimal.ZERO;
for (Purchase purchase : purchaseAmountList) {
allAmount = allAmount.add(purchase.getAmount());
}
assertEquals(BigDecimal.valueOf(50000L), allAmount);
}
}
그러나 테이블 구조가 변경되면 각각의 @Test에 작성된 SQL 스크립트를 일일히 수정해 줘야 하는 번거로움이 있다.
또 하나 불편한 점은 DDL, DML을 직접 작성해야 하는데서 오는 번거로움 이다.
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`email` varchar(255) DEFAULT NULL,
`nickname` varchar(255) DEFAULT NULL,
`password` varchar(255) DEFAULT NULL,
`join_at` datetime DEFAULT NULL,
`exit_yn` bit(1) NOT NULL,
`exit_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `purchase` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`amount` decimal(20,4) NOT NULL DEFAULT 0,
`title` varchar(255) DEFAULT NULL,
`contents` longtext,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`creator_id` int(11) DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`is_private` bit(1) NOT NULL,
`update_private_at` datetime DEFAULT NULL,
`is_deleted` bit(1) NOT NULL,
`deleted_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `create_user_fk_idx` (`creator_id`),
CONSTRAINT `create_user_fk` FOREIGN KEY (`creator_id`)
REFERENCES `member` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION
);
INSERT INTO `pwiki`.`member`
(`id`, `email`, `nickname`, `password`, `join_at`, `exit_yn`) VALUES
(1, 'cheolsoo@mail.com', '박사님', 'difend82jhdadsd', now(), 0),
(2, 'minsoo@mail.com', '석사님', 'ejfa8e892jd', now(), 0),
(3, 'hyunsoo@mail.com', '학사님', '39djsfkekda', now(), 0),
(4, 'jinsoo@mail.com', '변호사님', '93jfisdjfedf', now(), 0);
INSERT INTO `pwiki`.`wiki`
(`amount`, `title`, `contents`, `created_at`, `creator_id`, `is_private`, `is_deleted`)
VALUES
(5000, '생수', '생수를 구매 합니다.', now(), 1, 1, 0),
(15000, '치약', '치약 구매 합니다~', now(), 2, 1, 0),
(1000, '칫솔', '치카치카.', now(), 1, 1, 0),
(9000, '휴지', '두루마리', now(), 3, 1, 0),
(20000, '그릇', '머그컵 2개', now(), 1, 1, 0),
(4000, '손소독제', '에탄올 손 소독제. 냄새 굿.', now(), 1, 1, 0),
(15000, '스킨', '차앤박 스킨.', now(), 1, 1, 0),
(5000, '라면', '라면엔 김치', now(), 1, 1, 0);
FK가 복잡하게 얽혀 있는 경우, 모든 테이블에 데이터를 순서대로 insert 해줘야 하는데 휴먼 리소스가 많이 든다.
테스트 코드 작성하는 시간보다 테이블 구조 파악하고, DML 작성 시간이 배로 드는 수가 있다.
TDD 방식으로 테스트를 먼저 작성하는 경우, production 코드 작성에 쏟을 수 있는 시간은 그만큼 크게 줄어들 수 있다.
(꼬리가 몸통을 흔드는 Wag the dog)
비즈니스 요구에 긴급하게 대응해야 하는 상황에서 DML 스크립트 작성에 1~2MD 씩 소모하고 있다면?
안된다 안되..
그렇다고 테스트를 작성 하지 않는다면, 요구사항이 변경 되거나 버그가 발생해 수정사항이 생길 경우,
두고두고 불안한 마음으로 수정 해야 하고 (기도메타), 수제 테스트에 소모되는 시간과 부담이 계속 누적될 것이다.
2. Mockist.
그렇다면 Mocking은 어떠한가?
DML, DDL을 작성하지 않고도 독립적으로 return 해주는 결과값을 java 코드로 작성할 수 있다!
주로 BDD 수행, given-when-then 순서로 테스트를 수행 할 때, 사용한다.
DML, DDL을 작성하지 않아도 된다는 점, 따라서 SQL Script를 유지보수 하지 않아도 된다는 점에서 간편하다는 점이 장점이다.
그리고 Mocking은 slice 테스트를 위해 작성하기 때문에 테스트 구동 시간도 빠르다.
그러나 Mocking에도 단점이 있었으니.. 외부 클래스 의존이 높은 로직의 메서드를 테스트 할 때는 Mocking 작업하는 것이 복잡하고 오래 걸린다.
@ExtendsWith(SpringExtension.class)
class TestClass {
@InjectMock
CalculateService calculateService;
@Mock
CalculateRepository calculateRepository;
@DisplayName("철수의 구매 대금을 모두 합하면 50,000원 이다.")
@Test
void addTest() {
// Mocking items.
Purchase item1 = Purchase.builder().email("cheolsoo@mail.com")
.nickname("철수의 닉네임")
.amount(BigDecimal.valueOf(5000L)
.build();
Purchase item2 = Purchase.builder().email("cheolsoo@mail.com")
.nickname("철수의 닉네임")
.amount(BigDecimal.valueOf(15000L)
.build();
Purchase item3 = Purchase.builder().email("cheolsoo@mail.com")
.nickname("철수의 닉네임")
.amount(BigDecimal.valueOf(20000L)
.build();
Purchase item4 = Purchase.builder().email("cheolsoo@mail.com")
.nickname("철수의 닉네임")
.amount(BigDecimal.valueOf(10000L)
.build();
List<Purchase> purchaseMockList =
new ArrayList(Arrays.asList(item1, item2, item3, item4));
// Mocking list.
when(calculateRepository.searchPurchaseListByEmail("철수"))
.willReturn(purchaseMockList);
List<Purchase> purchaseList =
calculateService.searchPurchaseListByEmail("cheolsoo@mail.com");
BigDecimal allAmount = BigDecimal.ZERO;
for (Purchase purchase : purchaseList) {
allAmount = allAmount.add(purchase.getAmount());
}
assertEquals(BigDecimal.valueOf(50000L), allAmount);
}
}
아는 개발자 동생이랑 밥 먹으면서 '개발 할 때 Mock으로 테스트 작성해 놓으면 나중에 편하다' 라고 했더니,
넙치 입을 하더니 'Mocking 하다가 죽는다. :-( '고 했다.
무슨 말인지 예를 들어 보자.
테스트 대상 메서드 안에서 외부 패키지의 메서드를 10개를 호출하고 있는 높은 의존성이 있다고 해보자.
그러면 Mocking을 10개를 해야 하는데 이게 고통 스러운 작업이라는 거다.
...
@Mock
UserService userService;
@Mock
PurchaseService purchaseService;
@Mock
AuthService authService;
@Mock
ProductService productService;
@Mock
OptionService optionService;
@Mock
ProductRepository productRepository;
@Mock
MessageSource messageSource;
@InjectMock
ProductService productService;
.
.
.
@Test
void purchaseTest() {
User user1 = User.builder().name("철수").age(23).nickname("hulk").build();
User user2 = User.builder().name("현수").age(25).nickname("A").build();
User user3 = User.builder().name("메시").age(35).nickname("B").build();
User user4 = User.builder().name("호나우두").age(50).nickname("C").build();
User user5 = User.builder().name("바이든").age(78).nickname("D").build();
User user6 = User.builder().name("기시다").age(67).nickname("E").build();
List<User> userList =
new ArrayList(Arrays.asList(user1, user2, user3, user4, user5, user6));
when(userService.getPurchaseUserList(1L)).willReturn(userList);
// Mocking items.
Purchase item1 = Purchase.builder().email("cheolsoo@mail.com")
.nickname("철수의 닉네임")
.amount(BigDecimal.valueOf(5000L)
.build();
Purchase item2 = Purchase.builder().email("cheolsoo@mail.com")
.nickname("철수의 닉네임")
.amount(BigDecimal.valueOf(15000L)
.build();
Purchase item3 = Purchase.builder().email("cheolsoo@mail.com")
.nickname("철수의 닉네임")
.amount(BigDecimal.valueOf(20000L)
.build();
Purchase item4 = Purchase.builder().email("cheolsoo@mail.com")
.nickname("철수의 닉네임")
.amount(BigDecimal.valueOf(10000L)
.build();
List<Purchase> purchaseList =
new ArrayList(Arrays.asList(item1, item2, item3, item4));
when(purchaseService.getPurchaseProductList("cheolsoo@mail.com"))
.willReturn(purchaseList));
Product product1 = Product.builder().....
.
.
.
.
여러 다른 클래스의 서비스 메서드를 호출해야 하는 로직을 테스트 해야 하는 경우,
위 예제 코드에서 보는 바와 같이 Mocking 해야 되는 객체가 너무 많다. (예제 코드 만드는 것도 힘들 정도)
Mocking 대상 class 안에 또 다시 mocking 해야 하는 메서드가 있을 경우, mocking 작업은 끝나지 않고 줄줄이 이어질 수 있다.
때에 따라서는, 매출 계산과 같은 복잡한 로직을 수행하는 메서드의 경우, 의존 메서드가 20개 이상이 될 수도 있다.
거기에 더해서, Repository 계층을 별도 테스트로 작성해야 하다보니 테스트를 두개를 작성해야 한다.
또한 service와 repository 테스트를 slice로 작성 했는데,
slice 테스트로 작성했기에 개별 테스트 작성중에 둘 중 하나의 테스트에 논리적 오류가 있었다면 (그러나 테스트는 성공),
나머지 하나의 테스트가 옳다고 하더라도 실제 output은 잘못 생성될 수 있다.
3. Hybrid.
그렇다면 각각의 장점을 합치고 단점을 보완하는 식으로 테스트 코드를 작성할 수는 없을까?
그래서 현재 본인이 작성하고 있는 테스트 코드의 형식은 아래와 같다.
테스트는 통합 테스트로 작성하되,
Mocking해야 하는 대상은 Spring에서 생성해주는 @MockBean을 이용한다.
이렇게 하면 전체 로직을 @Sql을 이용하지 않아도 되고 필요한 부분만 @MockBean으로
스프링에서 만들어주는 Mock 객체로 만들어서 사용하면 된다.
여기에 테스트 수행은 BDD 방식으로 한다. (given - when - then)
@SpringBootTest
class void TestExample {
@Autowired
MessageSource messageSource;
대신 양쪽의 단점도 조금씩은 가지고 있다.
일단 mocking은 필요 하다.
그리고 스프링의 자원을 이용하기 위해 통합 테스트를 하는 경우에는 스프링 컨테이너 자원을 로딩하는 속도만큼 테스트가 느려진다.
테스트 구동 속도가 중요하다면 @Import나 @SpringBootTest(classes = { ... }) 에 필요한 클래스만 Bean 생성 지정해서 구동하면 된다.
4. 그외 팁.
테스트 코드는 짧고 단순하게 작성하는게 좋다.
경험적으로, 하나의 테스트 코드 안에서 되도록 조건문을 쓰지 않는게 좋은것 같다.
예를 들어,
@DisplayName("ALL 검색시는 like 검색하고, 계정 키워드 검색시엔 equals 검색하고, 다른 키워드 검색시 검색안됨")
이렇게 3가지 케이스를 하나의 테스트에서 구동 하려고 하면 테스트 코드가 복잡해지고 유지 보수도 어려워 진다.
assert가 어떤 케이스에서 실패 했는지 알려주겠지만, 상황에 따라서는 조건문들을 따라가며 어떤 상황에서 실패 한 건지 추적해야 하는 경우가 빈번하게 발생 할 수 있다.
테스트 코드를 작성하는 시간을 줄이려고 하나의 테스트에서 여러 경우를 소화 하려고 했겠지만,
결과적으로는 더 많은 자원을 소모하게 만든다.
@ParameterizedTest를 하는 경우엔, 받아야 하는 파라미터의 종류와 갯수도 늘어난다.
그러면 경우의 수에 따른 분기처리에 대해 많이 신경써야 한다.
테스트 코드가 복잡해짐에 따라, 테스트 하려고 했던 실제 케이스를 놓치고 테스트가 통과되는 오류가 발생할 수도 있다.
다른 사람이 이해 하기에 어려운 테스트 코드가 된다.
위 3가지 상황을 3개의 테스트로 나눠서 작성 했다면 어떨까.
@DisplayName("계정을 ALL 검색시는 like 검색한다.")
.
.
@DisplayName("계정을 계정 키워드 검색시엔 equals 검색한다.")
.
.
@DisplayName("계정을 다른 키워드 검색시 검색되지 않는다.")
이렇게 3가지 케이스로 나눠서 작성하면,
경우의 수에 따른 조건문을 생각하지 않아도 되서 빠르게 작성 할 수 있다.
여기서 아낀 자원으로 product 코드 개선이나 설계에 더 신경을 투자할 수 있다.
테스트 코드 유지보수도 간단 해진다.
또한, 테스트 케이스 실패시, 어떤 케이스에서 실패 했는지 바로 알 수 있다.
5. 결론.
돈 계산과 같은 중요한 로직을 다루는 클래스는 전체 과정을 통합 테스트로 수행 한다.
일반적인 경우 위와 같은 조합으로 하면 테스트 코드 작성에 사용하는 시간을 합리적인 수준으로 낮출 수 있다.
BDD 방식으로 테스트 코드를 작성하는데 익숙해 지면 테스트 코드 생산 속도가 점점 빨라진다.
추가해야할 기능의 스펙을 먼저 BDD 테스트 코드로 작성해 두고 production 코드를 작성하는 TDD 방식으로 개발을 한다.
또한 테스트는 작고 간단하게, 여러개로 나눠서 작성한다.
위와 같은 방식으로 테스트 코드를 구성하니,
테스트 작성 부담은 줄이면서, 이후 추가 요구사항이 들어오거나 더 좋은 구조가 생각나 리펙토링 할 때
걱정없이 즐길 수(?) 있었다.
'☕️Java > Test' 카테고리의 다른 글
MockitoExtension.class에서 JUnit5의 NoSuchMethodError. (0) | 2021.05.25 |
---|---|
No tests found for given includes. (0) | 2021.03.01 |