
약 한 달간의 인프런 워밍업 클럽 백엔드 코드 3기 여정이 끝났다.
올해는 유난히 바빠서 그런지, 시간이 유독 더 빨리 가는 것 같다.
벌써 1분기가 끝났다는 게… 믿기지 않는다. 😂
우빈님께서는 온라인 세션 때 시간이 빨리간다는 농담을 해주시곤 한다.
우빈님의 지인이 ‘시간이 너무 빨리 가서 곧 크리스마스 트리를 설치해야겠다’고 하셨던 말씀이 기억에 남는다. ㅋㅋ 🤣
(맞나? 이게? 자세히 기억은 아나지만..)
아무튼! 마지막 주차 최종 점검 온라인 라이브 세션을 마지막으로 스터디를 완주하였다. 👏
기대하고 기다리던 코드 리뷰를 다시 받게 되었다!
이번 코드 리뷰는 작성한 단위 테스트 코드에 대한 리뷰를 받았다.
중간점검 때 받았던 리팩토링 코드 리뷰보다는 과제가 다소 정형화(?) 되어 있어서 공통 피드백이 많긴 했다.
다시 한번 우빈님의 세심한 리뷰에 놀랐다. 😮
🔗 Github PR 링크
단위테스트 작성 PR

이건 내가 PR에 궁금했던 질문 중 하나였다. 🧐
프로덕션 코드를 수정하면 안된다는 제약을 걸고, 테스트 코드를 작성하려고 했기 때문에..!
사용자 입력을 받는 Scanner에 대한 테스트는 어떻게 하는지 궁금했다.

🧪️ 테스트 하려고 했던 코드
public class InputHandler {
private static final Scanner SCANNER = new Scanner(System.in);
...(중략)...
}
✏️ 우빈님 리뷰
Q. 프로덕션 코드 수정 없이 사용자 입력 테스트가 가능할까요..? 🤔
(Scanner 클래스를 외부세계로 분리하면 가능할 것 같긴합니다..)
입력에 대한 테스트도 가능하면 전체 통합테스트도 가능할 것 같습니다!!!
A. Scanner 때문에 어렵긴 하죠.
InputStream을 생성자로 받는 형태로 변경하고, Scanner를 생성해주는 방식이라면 가능할 겁니다.
🤔 돌아보기
역시, 프로덕션 코드를 수정하지 않으면 테스트가 어렵다는 말씀을 주셨다.
우빈님 리뷰를 반영하여 프로덕션 코드 부분을 InputStream을 생성자로 받는 형태로 리팩토링해봐야겠다.
리뷰 신청 시에, "테스트 커버리지의 집착"에 대해 언급을 했었는데..
세심한 우빈님께서 포인트를 짚어주셨다...!! 🥹
✏️ 우빈님 리뷰
A. 연습 시에 커버리지를 극한까지 올리는 데에 집중해보는 것 -> 👍
그러나 실무에서는 '주어진 시간 안에' 중요도가 높은 순으로 테스트를 할지 말지를 결정해야 합니다.
물론 전부 다 할 수 있으면 best 겠죠 :)
🤔 돌아보기
실무에서의 테스트 커버리지에 대한 관점을 말씀주셨다...
테스트 커버리지를 높이는 것도 중요하지만, 비즈니스 우선이라는 점을 반드시 인지하자.
테스트 코드 작성 시, 중요도를 따져보는 연습을 해봐야겠다.
그리고 실무에서의 커버리지에 대한 집착은 지양하도록 하자.
대신, 사이드 프로젝트에서는 커버리지를 극한까지 끌어올리는 연습에 집중해보는 것도 좋겠다.
다음 코드는 given 절에 선언한 컬렉션 변수가 너무 길어서 private 메서드로 분리한 형태이다.
@DisplayName("좌석 패스로 기간과 타입이 동일한 사물함 패스를 찾는다.")
@Test
void findLockerPassBy() {
// given
List<StudyCafeLockerPass> list = lockerPassList();
StudyCafeLockerPasses lockerPasses = StudyCafeLockerPasses.of(list);
...(중략)...
}
private List<StudyCafeLockerPass> lockerPassList() {
return List.of(
StudyCafeLockerPass.of(StudyCafePassType.FIXED, 4, 11000),
StudyCafeLockerPass.of(StudyCafePassType.WEEKLY, 4, 17000),
StudyCafeLockerPass.of(StudyCafePassType.FIXED, 12, 11000),
StudyCafeLockerPass.of(StudyCafePassType.FIXED, 4, 18000),
StudyCafeLockerPass.of(StudyCafePassType.HOURLY, 8, 11000),
StudyCafeLockerPass.of(StudyCafePassType.FIXED, 10, 11000)
);
}
✏️ 우빈님 리뷰
A. lockerPassList()가 private 메서드라 list가 무엇인지 한 눈에 잘 들어오지 않는다.
어차피 정해진 리스트라면, 상단에 상수로 관리하면 어떨까?
네이밍도 list -> allLockerPasses로 "모든" 사물함 패스 라는 의미를 주면
모든 사물함 패스가 존재할 때, 내 좌석권에 맞는 사물함 패스를 찾는다는 내용으로 변수명을 변경하면 좀 더 이해하기 쉬울 것 같다.
🤔 돌아보기
당시에 완전 뜨끔했던 리뷰였다.. 💯
내가 작성한 코드를 보니 메서드도 메서드인데 왜 변수명을 저렇게 작성했을까?라는 의문이 든다. 🤦♂️
테스트 코드의 given 절은 중복 제거보다도 '한눈에 들어오는 것'이 더 중요하다고 하셨다.
읽는 사람의 '뇌 메모리'를 덜 쓰게끔 given 절을 설계하는 연습이 필요해 보인다.
리뷰를 바로 반영하여 아래의 코드로 리팩토링 했다. ♻️
private static final List<StudyCafeLockerPass> ALL_LOCKER_PASSES = List.of( // 👍 상수로 추출 및 네이밍 변경
StudyCafeLockerPass.of(StudyCafePassType.FIXED, 4, 11000),
StudyCafeLockerPass.of(StudyCafePassType.WEEKLY, 4, 17000),
StudyCafeLockerPass.of(StudyCafePassType.FIXED, 12, 11000),
StudyCafeLockerPass.of(StudyCafePassType.FIXED, 4, 18000),
StudyCafeLockerPass.of(StudyCafePassType.HOURLY, 8, 11000),
StudyCafeLockerPass.of(StudyCafePassType.FIXED, 10, 11000)
);
@DisplayName("좌석 패스로 기간과 타입이 동일한 사물함 패스를 찾는다.")
@Test
void findLockerPassBy() {
// given
StudyCafeLockerPasses lockerPasses = StudyCafeLockerPasses.of(ALL_LOCKER_PASSES);
다음은, 엑셀에 있는 패스권 목록을 가져오는 부분을 'mocking'한 부분이다.
@DisplayName("파일을 읽어서 좌석 패스를 가져온다.")
@Test
void getSeatPasses() {
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// given
mockedFiles.when(() -> Files.readAllLines(any()))
.thenReturn(List.of(
"WEEKLY,2,4000,0.0",
"WEEKLY,12,120000,0.3",
"HOURLY,4,6500,0.1"
));
}
}
✏️ 우빈님 리뷰
A. mockStatic으로 Files mocking 👍
다만, Files.readAllLines()를 stubbing하는 등의 전역적인 기능을 조작하는 것은 멀티스레드로 병렬 테스트를 수행할 때
문제가 될 수 있으므로 주의 필요
🤔 돌아보기
mockStatic을 이용해서 작성한 코드가 병렬 테스트 수행 시, 테스트 코드가 깨질 수 있다는 사실을 처음 알게 되었다.
간단한 테스트라면 괜찮을 수 있지만, 실무에서는 반드시 지양해야겠다.
이번 코드 리뷰는 실무에서의 주의할 점에 대해 많이 언급해주셨다. ⭐️
mockStatic의 병렬 테스트 시, 사이드 이펙트 발생단순히, 테스트 코드를 많이 작성하는 것보다 중요도 높은 혹은 의미있는 테스트 코드를 작성하려고 노력해야 겠다. ✨
"이 글이 우빈님께 닿지는 않겠지만..😅 우빈님! 감사드립니다.🙇♂️"
Test Double, Stubbing
1️⃣ Dummy
class DummyUser implements User {
@Override
public String getName() {
return null; // 의미 없는 값
}
}
2️⃣ Fake
class FakeUserRepository implements UserRepository {
private Map<Long, User> users = new HashMap<>();
@Override
public User findById(Long id) {
return users.get(id);
}
public void save(User user) {
users.put(user.getId(), user);
}
}
3️⃣ Stub
class StubUserRepository implements UserRepository {
@Override
public User findById(Long id) {
return new User(id, "stub_user");
}
}
4️⃣ Spy
class SpyEmailSender implements EmailSender {
private int sendCount = 0;
@Override
public void sendEmail(String message) {
sendCount++;
}
public int getSendCount() {
return sendCount;
}
}
5️⃣ Mock
@Test
void testMockExample() {
EmailSender emailSender = mock(EmailSender.class);
emailSender.sendEmail("test@example.com");
verify(emailSender).sendEmail("test@example.com"); // 호출 검증
}
Stub과 Mock차이
Stubbing이란?
@Mock, @MockBean, @Spy, @SpyBean, @InjectMocks
BDDMockito
Classicist vs. Mockist

테스트 하나 당 목적은 하나!
완벽한 제어
테스트 환경의 독립성, 테스트 간 독립성
Test Fixture
deleteAll(), deleteAllInBatch()
@ParameterizedTest, @DynamicTest
@DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
@ParameterizedTest
@CsvSource({"HANDMADE, false", "BOTTLE, true", "BAKERY, true"})
void containsStockType3(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(HANDMADE, false),
Arguments.of(BOTTLE, true),
Arguments.of(BAKERY, true)
);
}
@DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
@ParameterizedTest
@MethodSource("provideProductTypesForCheckingStockType")
void containsStockType4(ProductType productType, boolean expected) {
// when
boolean result = ProductType.containsStockType(productType);
// then
assertThat(result).isEqualTo(expected);
}
@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("차감할 재고 수량이 없습니다.");
})
);
}
수행 환경 통합하기
private method test
테스트에서만 필요한 코드

학습 테스트
Spring Rest Docs
[미션 PR]
https://github.com/discphy/warm-up-backend-code/pull/8
1️⃣ 레이어드 아키텍처 특징 및 테스트 작성법
Test Fixture와 데이터 클렌징 개념을 함께 학습하며, 이를 예제 코드에 적용하였다.[미션 PR]
https://github.com/discphy/warm-up-backend-code/pull/9
2️⃣ Mock 어노테이션 종류 및 차이점 & BDD 패턴 적용
📌 Mock 어노테이션 종류 및 차이점
📌 BDD 패턴 적용
짧다면 짧고, 길다면 긴 인프런 스터디 여정을 드디어 완주했다. 👏👏👏
(잠시나마, 한숨을 돌릴 수 있게 되었다. 😮💨)

한 줄 평을 해보자면, "정말 너무 좋기만 했다."
생각보단 업무와 병행하며 쉽지 않은 일정이긴 하지만!?
내가 듣고 싶었던 강의의 강의료만 내고..
강사님과의 네트워킹을 하며.. 스터디에 참여하고..
단기간에 성장까지 경험할 수 있다면, "참여하지 않을 이유가 있을까?" 🤔
다음 기수 때도 기회가 된다면 지원을 해 볼 생각이다!!
그리고! 주변에서 참여를 고민한다면!? 바로 적극 지지 해줄 것 같다.
인프런 워밍업 클럽 스터디 만만세!! 🙌
강사진과 운영진분들, 진심으로 고생 많으셨습니다. 감사합니다! 🙇♂️
[출처]