인프런 워밍업 클럽 백엔드 4주차 후기
후기
드디어 인프런 워밍업 클럽의 마지막 발자국을 쓰게 되었다.
길다면 길고 짧다면 짧은 4주동안 두 개의 강의를 들으면서 많은 것을 느낄 수 있던 시간이었다.
Readable Code에서는 추상을 통해서 메시지를 잘 만들고, 책임과 역할을 잘 분배하여 읽기 좋은 코드를 만들 수 있도록 노력해야 겠음을 느꼈고, Practical Test에서는 좋은 테스트 코드를 만들어서 사람이 노가다 하는 비중을 최대한 줄이면서, 안정적인 코드를
만들어야겠음을 느꼈다.
개발이라는 것은 나혼자만이 아닌 팀 단위로 움직이는 것이기 때문에 이를 팀 레벨로 이끌고 싶은 욕구가 생겼다. 그러기 위해선 내가 그를 증명할 수 있는 탄탄하고 좋은 실력을 갖추도록 노력해야겠다. 이번 인프러너의 좋은 강의들을 재반복하면서 나의 지식으로 습득하고, 이를 다른 사람들에게 전수할 수 있는 그런 사람이 되도록 해야겠다.
즐거운 기회를 마련해준 우빈님과 인프런에게 감사를 표하며 글을 마치겠다! ㅎㅎ
내용 정리
Presentation Layer 테스트(1)
외부 세계의 요청을 가장 먼저 받는 계층
파라미터에 대한 최소한의 검증을 수행한다
Mock (가짜, 대역) 객체
테스트 시 의존관계 주입이 방해될 때, 가짜를 집어넣어 처리함
MockMvc
Mock(가짜) 객체를 사용해 스프링 MVC 동작을 재현할 수 있는 테스트 프레임워크
@Transactional(readOnly = true)
CRUD에서 CUD가 동작 안 함
오직 R만 작동
JPA는 기본적으로 1차 캐시에 스냅샷을 저장하고, 트랜잭션 커밋 플러시 하는 시점에 변경 감지가 작동하여 업데이트 쿼리를 발생시킴
근데 reaOnly = true를 열면 CUD 스냅샷 저장, 변경감지를 안 하여 성능 향상 효과가 있음
CQRS
Command / Query
Read 작업이 거의 80%정도 주를 이룰 때가 많음
그래서 command와 query 작업을 분리하여 서로 연관되지 않게끔 분리하는 CQRS 패턴을 활용
조회 하는 서비스만 만들면 @Transactional(readOnly=true)를 써서 관리할 수 있음
DB 엔드포인트도 구분하여 쓰기는 마스터, 읽기는 슬레이브로 보내버릴 수 있음
어노테이션 보고 마스터나 슬레이브로 구분해줄 수 잇다함
클래스 단위에은 readOnly를, 변경 메서드엔 @Transactional을 달자
아님 객체 단위로 나눠서 관리
Presentation Layer 테스트(2)
Validation을 활욜
String
@NotBlank: 전부 다 허용 안됨
@NotNull: “”, “ “는 통과됨
@NotEmpty: “ “ 공백은 통과 “” 빈문자열 실패
정책을 validation에서 거르는게 맞을까? 라는 고민을 해야함
EX) 상품 이름은 20자 → 이런건 서비스 레이어나 프러턱트 코드 같은 안쪽에서 해도 됨
상위 레이어는 하위 레이어를 알아도 되지만
하위 레이어는 상위 레이어를 모르게 하는게 좋음
Layered Architecture 단점
DB랑 연결하기 위해 JPA 도메인 객체를 만들었는데, 너무 강결합 되는 구조가 됨
깊어질 수록 바꾸기가 어려워짐
Hexagonal Architecture
포트와 어댑터 형태
포트를 통해 외부와 통신을 함
도메인 정책이 가장 안 쪽에 있음
모노레포라면 레이어드 아키텍쳐도 괜찮지만
점점 커질 거 같으면 헥사고날을 고려해보자
QueryDSL
JPA랑 함께 많이 쓰이는 동적 쿼리 빌더
타입체크를 지원해주서 컴파일단에서 체크해 안전해짐
섹션7: Mock을 마주하는 자세
Mockito로 Stubbing 하기
이메일 등 외부 네트워크에 전송하는 서비스 로직에는 Transactional 안 걸어두는게 좋음
트랜잭션으로 DB 조회할때 커넥션을 가지고 있는데, 외부 소통 하면서 갖고 있으면 다른데서 못 가져감
Test Double
Stunt Double: 스턴트 배우를 쓰는 것을 차용 → 대역을 사용
Dummy: 아무 것도 하지 않는 깡통 객체
Fake: 단순한 형태로 동일한 기능을 수행하나, 프로덕션에서 쓰기에는 부족한 객체 (ex. FakeRepository - 메모리 Map)
Stub: 테스트에서 요청한 것에 대해 미리 준비한 결과를 제공하는 객체. 그 외에는 응답하지 안흠
Spy: Stub 이면서 호출된 내용을 기록하여 보여줄 수 있는 객체. 일부는 실제 객체처럼 동작시키고 일부만 Stubbing할 수 있다.
Mock: 행위에 대한 기대를 명세하고, 그에 따라 동작하도록 만들어진 객체
Stub과 Mock이 좀 헷갈림
Stub은 상태 검증 (State Verfication)
Mock은 행위 검증(Behavior Verication): 메서드가 무엇을 했을 때(행위) 어떤 값을 돌려주는 느낌~
public interface MailService {
void send(Message msg);
}
public class MailServiceStub implements MailService {
private List<Message> messages = new ArrayList<>();
public void send(Message msg) {
messages.add(msg);
}
public int numberSent() {
return messages.size();
}
}
// Stub 검증
class OrderStateTest {
@Test
void testOrderSendsMailIfUnfilled() {
Order order = new Order(TALISKER, 51);
MailServiceStub mailer = new MailServiceStub();
order.setMailer(mailer);
order.fill(wraehouse);
assertEquals(1, mailer.numberSent()); // 상태에 대한 검증
}
}
// Mock 검증
class OrderInteractionTester {
@Test
void testOrderSendsMailIfUnfilled() {
Order order = new Order(TALISKER, 51);
Mock warehouse = mock(Warehouse.class);
Mock mailer = mock(MailService.class);
order.setMailer((MailService) mailer.proxy());
mailer.expects(once()).method("send"); // 메서드가 한 번 불림. 행위 확인
warehouse.expects(once()).method("hasInventory")
.withAnyArguments()
.will(returnValue(false));
order.fill((Warehouse) warehouse.proxy());
}
}
@Mock, @Spy, @InjectMocks
순수 mockito로 검증하기
BDDMockito
Classicist VS Mockist
Mockist: 모든 걸 Mocking 처리해서 하자
Classicist: 모킹만 해서 실 프로덕션 환경을 다 커버하긴 어렵다
필요한 경우엔 실제를 쓰다가 필요하면 Mockito를 쓰자
강사님 (Classcisit)
Presentaion은 외부에서 오는 값만 검증하고, 하위 레벨은 Mocking

외부 시스템은 우리에게 제어권이 없기에 이런 경우 (외부 계를 나누자)

비용이 조금 더 들더라도 실제 객체를 갖고와 테스트를 하는게 안전하지 않을까~
키워드 정리

Bean이 들어가면 스프링 환경에서이니, 단위 테스트로만 Mocking하고 싶으면 Bean이 안 들어간걸 쓰자
섹션8: 더 나은 테스트를 작성하기 위한 구체적 조언
한 문단에 한 주제!
글쓰기에서 요지는 한 문단엔 한 주제만을 넣음
테스트도 문서로써의 기능을 함
테스트 코드를 글 쓰기의 관점에서 하나의 테스트도 하나의 문단으로 보고, 그에 맞게 처리하자
void containsStockTypeEx() {
// given
ProductType[] productTypes = ProductType.values();
for (ProductType productType: productTypes) {
if (productType == ProductType.HANNDMADE) {
// when
boolean result = ProductType.containsStockType(productType);
// then
assertThat(result).isFalse();
}
if (productType == ProductType.BAKERY || productType == ProductType.BOTTLE) {
// when
boolean result = ProductType.containsStockType(productType);
// then
assertThat(result).isTrue();
}
}
위와 같이 if 같은 분기문이 있으면 두 가지 이상의 케이스를 구분하겠단 의미
반복문의 경우에도 테스트 코드를 읽는 사람이 생각을 해야함
DisplayName을 한 문장으로 만들게끔 해서 하나의 케이스만 처리하게끔 하자
완벽하게 제어하기
아래와 같이 시간, 랜덤값 같이 내가 제어할 수 없는 값은 외부에서 주입할 수 있도록 해야함
public Order createOrder() {
LocalDateTime currentDateTime = LocalDateTime.now();
final LocalTime currentTime = currentDateTime.toLocalTime();
if (currentTime.isBefore(SHOP_OPEN_TIME) || currentTime.isAfter(SHOP_CLOSE_TIME)) {
throw new IllegalArgumentException("주문 시간이 아닙니다. 관리자에게 문의하세요.");
}
return new Order(LocalDateTime.now(), beverages);
}
// 외부에서 주입
public Order createOrder(LocalDateTime currentDateTime) {
final LocalTime currentTime = currentDateTime.toLocalTime();
if (currentTime.isBefore(SHOP_OPEN_TIME) || currentTime.isAfter(SHOP_CLOSE_TIME)) {
throw new IllegalArgumentException("주문 시간이 아닙니다. 관리자에게 문의하세요.");
}
return new Order(LocalDateTime.now(), beverages);
}
그렇다고
LocalDateTime registeredDateTime = LocalDateTime.now()테스트 코드에서 현재 시각을 주는 메서드를 막 쓰지는 말자 (환경마다 다를 수 있으니)시각을 LocalDateTime.of() 같은 시각을 지정해줄 수 있도록 원칙을 만드는 것도 좋음
테스트 환경의 독립성을 보장하자
@DisplayName("재고가 부족한 상품으로 주문을 생성하려는 경우 예외가 발생한다.")
@Test
void createOrderWithNoStock() {
// given
final LocalDateTime registeredDateTime = LocalDateTime.now();
Product product1 = createProduct(BOTTLE, "001", 1000);
Product product2 = createProduct(BAKERY, "002", 3000);
Product product3 = createProduct(HANDMADE, "003", 5000);
productRepository.saveAll(List.of(product1, product2, product3));
Stock stock1 = Stock.create("001", 2);
Stock stock2 = Stock.create("002", 2);
stock1.deductQuantity(1); // todo
stockRepository.saveAll(List.of(stock1, stock2));
OrderCreateRequest request = OrderCreateRequest.builder()
.productNumbers(List.of("001", "001", "002", "003"))
.build();
// when // then
assertThatThrownBy(() -> orderService.createOrder(request.toServiceRequest(), registeredDateTime))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("재고가 부족한 상품이 있습니다.");
}
deductQuantity 부분으로 인해 논리적으로 한 번 더 생각해서 테스트를 짜게 된다
테스트는 when, then에 집중해야는데, given도 보면서 혼란스러워짐
given은 순수 생성자 기반의 값을 주는게 좋음 or Builder
팩토리 메서드도 지양하는게 좋음 (팩토리 메서드 내에 의도를 가지고 뭔가 로직이 있을 수 있기에)
테스트 간 독립성을 보장하자
class StockTest {
private static final Stock stock = Stock.create("001", 1);
@DisplayName("재고의 수량이 제공된 수량보다 작은지 확인한다.")
@Test
void isQuantityLessThanEx() {
// given
int quantity = 2;
// when
boolean result = stock.isQuantityLessThan(quantity);
// then
assertThat(result).isTrue();
}
@DisplayName("재고를 주어진 개수만큼 차감할 수 있다.")
@Test
void deductQuantityEx() {
// given
int quantity = 1;
// when
stock.deductQuanaity(quantity);
// then
assertThat(stock.getQuanaity()).isZero();
}
}
두 가지 테스트가 static 변수 같은 공유 자원을 활용하고 있음
공유 자원의 값이 변경되면 다른 테스트 결과에 영향이 생김
테스트 수행 순서는 랜덤하기에 독립적으로 항상 올바를 테스트가 되도록 해줘야함
DynamicTest를 쓰면 지정해줄 수 있나봄
한 눈에 들어오는 Test Fixture 구성하기
Fixture: 고정물, 고정되어 있는 물체
테스트를 위해 원하는 상태로 고정시킨 일련의 객체
활용 어노테이션
@BeforeAll
@BeforeEach
@AfterAll
@AfterEach
픽스처를 통해 공통의 테스트 객체를 만들면 하나의 테스트를 바꿀 때 모두 영향이 끼치기에 지양하는 것이 좋음
테스트클래스가 엄청 길어진 경우 given 절을 보는데 문맥을 기억하기 어려워짐 (→ 문서 파악이 어려워짐)
사용해도 괜찮을 때
각 테스트 입장에서 봤을 때 : 아예 몰라도 테스트 내용을 이해하는 데에 문제가 없는 경우에
수정해도 모든 테스트에 영향을 주지 않는가
data.sql에서 given 데이터를 만들 수 있겠지만, 데이터 파편화가 될 가능성이 큼 (비추)
테이블도 많아지고, 사이즈가 커질 수록 관리가 어려워짐
생성 메서드를 만들 때 테스트에 필요한 파라미터만 받아서 쓰자
name 필드가 필요없으면 파라미터에서 빼버림
테스트 패키지 전체에서 사용하는 추상 클래스를 만들어 픽스처 빌더들을 모아 쓸 순 있겠지만 NO 추천
파라미터가 엄청 많아질 때 마다 내가 필요한 빌더들이 생기고, 관리가 더 안 될 수도 있음
코틀린을 사용하면 롬복도 필요없고, 빌더도 필요없음. 기본값을 지정해줄 수 있음
Text Fixture 클렌징
deleteAll: select 해서 건건이 where id 조회해서 지움
연관 관계 걸려있는 것도 찾아서 같이 지어줌
전체 찾아서 순회해서 지워주고 있음
삭제도 일종의 비용이기 때문에 테스트에서 오래걸리면 좀 그럴 수 있음
@Transactional
public void deleteAll() {
Iterator var2 = this.findAll().iterator();
while(var2.hasNext()) {
T element = (Object)var2.next();
this.delete(element);
}
}
deleteAllInBatch
전체 삭제 쿼리를 만들어서 지어줌 → 벌크성으로 지워줌
@Transactional
public void deleteAllInBatch() {
Query query = this.entityManager.createQuery(this.getDeleteAllQueryString());
this.applyQueryHints(query);
query.executeUpdate();
}
private String getDeleteAllQueryString() {
return QueryUtils.getQueryString("delete from %s x", this.entityInformation.getEntityName());
}
@Transactional을 쓰면 되는데, 사이드 이펙트를 잘 고려해서 진행해야함
@ParameterizedTest
반복되는 given에 대해서 위 어노테이션을 활용해서 반복해서 할당할 수 있다.
@DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
@CsvSource({
"HANDMADE, false",
"BOTTLE, true",
"BAKERY, true"
})
@ParameterizedTest
void containsStockType4(ProductType productType, boolean expected) {
// when
boolean result = ProductType.containsStockType(productType);
// then
assertThat(result).isEqualTo(expected);
}
private static Stream<Arguments> provideProductTypesForCheckingStockType() {
return Stream.of(
Arguments.of(ProductType.HANDMADE, false),
Arguments.of(ProductType.BOTTLE, true),
Arguments.of(ProductType.BAKERY, true)
);
}
@DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
@MethodSource("provideProductTypesForCheckingStockType")
@ParameterizedTest
void containsStockType5(ProductType productType, boolean expected) {
// when
boolean result = ProductType.containsStockType(productType);
// then
assertThat(result).isEqualTo(expected);
}
@DynamicTest
공통의 값으로 단계 별로 테스트를 돌리고 싶으면 사용한다
@DisplayName("")
@TestFactory
Collection<DynamicTest> dynamicTest() {
return List.of(
DynamicTest.dynamicTest("", () -> {}),
DynamicTest.dynamicTest("", () -> {})
);
}
@DisplayName("재고 차감 시나리오")
@TestFactory
Collection<DynamicTest> stockDeductionDynamicTest() {
// given
Stock stock = Stock.create("001", 1);
return List.of(
DynamicTest.dynamicTest("재고를 주어진 개수만큼 차감할 수 있다.", () -> {
// given
int quantity = 1;
// when
stock.deductQuantity(quantity);
// then
assertThat(stock.getQuantity()).isZero();
}),
DynamicTest.dynamicTest("재고보다 많은 수의 수량으로 차감 시도하는 경우 예외가 발생한다", () -> {
// given
int quantity = 1;
// when // then
assertThatThrownBy(() -> stock.deductQuantity(quantity))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("차감할 재고 수량이 없습니다.");
})
);
}
테스트 수행도 비용이다. 환경 통합하기
테스트를 작성하는 이유는 사람이 수동으로 돌리는 비용보다 기계에 맡겨서 피드백을 빨리 받게 하기 위함
테스트의 속도가 빨라야 유의미하기에 비용관리를 해야함
서버 띄우는 경우가 많아지면 서버가 오래 걸리게 됨
이런 비용을 관리해야함
@MockBean 의 경우에도 서버를 새로 띄어야됨
@WebMvcTest(controllers = {
OrderController.class,
ProductController.class
})
public abstract class ControllerTestSupport {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@MockBean
protected OrderService orderService;
@MockBean // Mock 객체 만들어줌
protected ProductService productService;
}
@ActiveProfiles("test")
@SpringBootTest
public abstract class IntegrationTestSupport {
@MockBean
protected MailSendClient mailSendClient;
}
Q. private 메서드의 테스트는 어떻게 하나요?
하려고 해서도 안 되고 할 필요가 없다.
클라이언트(외부)는 공개된 API만 알면 되기에, private을 알 필요 없다
만약 private 메서드를 단독으로 빼서 테스트하고 싶다면, 객체를 분리할 시점인가를 고민해봐야 한다
필요하다면 새로운 객체를 만들어 책임을 위임하면 된다
Q. 테스트에서만 필요한 메서드가 생겼는데 프로덕션 코드에서는 필요 없다면?
만들어도 됨. 하지만 보수적으로 접근하기
테스트에서 조회성을 위해 만드는 거 정돈 괜찮지만, 테스트에서만 사용되는 메서드를 막 만드는 것은 지양해야함
getter, 생성자, 생성자 빌더, 사이즈 등 객체가 마땅히 가져도 되는 행위라 생각되고 미래에 충분히 사용되는 것들은 만들어도 괜찮다~
학습 테스트
잘 모르는 기능, 라이브러리, 프레임워크를 학습하기 위해 작성하는 테스트
여러 테스트 케이스를 스스로 정의하고 검증하는 과정을 통해 보다 구체적인 동작과 기능을 학습할 수 있다.
관련 문서만 읽는 것보다 훨씬 재미있게 학습할 수 있다.
Spring REST Docs
테스트 코드를 통한 API 문서 자동화 도구
API 명세를 문서로 만들고 외부에 제공함으로써 협업을 원할하게 한다
기본적으로 AsciiDoc을 사용하여 문서를 작성한다
REST Docs VS SWAGGER
REST DOCS
장점
테스트를 통과해야 문서가 만들어진다 (신뢰도가 높음)
프로덕션 코드에 비침투적임
단점
코드 양이 많다.
설정이 어렵다
SWAGGER
장점
적용이 쉬움
문서에서 바로 API 호출을 수행해볼 수 있다.
단점
프로덕션 코드에 침투적이다
테스트와 무관하기 때문에 신뢰도가 떨어질 수 있음
댓글을 작성해보세요.