Day 46 — JUnit과 단위 테스트
목차 17
지난 시간엔 프로그램이 자기 자신을 들여다보는 거울, 리플렉션을 직접 다뤄봤어요. @AutoFill 이라는 표시를 만들고, 그 표시가 붙은 필드만 골라 값을 자동으로 채워 주는 MiniInjector 도 완성했죠. 그러면서 끝에 약속을 하나 남겼어요. "@Test 라고 적힌 메서드만 골라 자동으로 실행해 주는 JUnit, 그 도구도 사실은 어노테이션과 리플렉션으로 돌아간다" 고요.
오늘은 그 JUnit 을 정면으로 만나요. 그동안 우리가 코드를 짜고 "잘 도나?" 확인할 때, 보이지 않는 곳에서 JUnit 이 일하고 있었어요. 단위 테스트(unit test) — 코드의 작은 한 조각(보통 메서드 하나)이 기대한 대로 동작하는지 자동으로 확인하는 코드예요. 사람이 매번 눈으로 결과를 확인하는 대신, 기대값과 실제값을 비교하는 코드를 미리 적어두면, 컴퓨터가 알아서 채점해 줘요.
흐름은 이래요. 먼저 "왜 손으로 확인하면 모자란지" 를 짚고 첫 테스트를 실행해 봐요. 그다음 값이 맞는지(assertEquals), 예외가 터지는지(assertThrows) 확인하는 도구를 하나씩 익히고, 매 테스트를 깨끗하게 준비하는 장치(@BeforeEach)와 입력만 바꿔 여러 번 검사하는 방법(@ParameterizedTest)까지 가요. 끝으로 좋은 테스트가 갖춰야 할 다섯 가지 성질을 정리하고, 지금까지 만든 인스타그램 서비스를 직접 테스트하며 마무리해요.
🎯 학습 목표
- 단위 테스트가 왜 필요한지 이해하고,
@Test가 붙은 메서드를 JUnit 이 리플렉션으로 골라 실행한다는 원리를 설명할 수 있어요. assertEquals·assertTrue·assertThrows·assertAll로 값과 예외를 검증할 수 있어요.@BeforeEach·@AfterEach로 테스트마다 깨끗한 출발선을 만들고, 테스트가 서로 간섭하지 않게 할 수 있어요.@DisplayName으로 테스트에 읽기 좋은 이름을 붙이고,@ParameterizedTest로 입력만 바꿔 같은 검사를 여러 번 돌릴 수 있어요.- 좋은 테스트의 다섯 가지 성질(F.I.R.S.T)을 알고, 인스타그램 서비스에 직접 단위 테스트를 작성할 수 있어요.
Step 1: 손으로 확인하던 걸 컴퓨터에게 맡기자
지금까지 우리는 코드를 짜고 나면 어떻게 확인했나요? main 메서드 안에서 메서드를 불러 보고, System.out.println 으로 결과를 찍어 눈으로 읽었죠. Day 9 에서 만든 회원의 추천 점수가 80 점이 맞는지도, 화면에 찍힌 80 을 우리 눈으로 보고 "맞네" 하고 넘어갔어요.
이 방식엔 빈틈이 많아요. 첫째, 사람이 매번 눈으로 숫자를 비교해야 해요. 화면에 80 이 찍혀도 우리가 기대한 값이 80 인지 81 인지 헷갈리면 그냥 지나쳐요. 둘째, 메서드가 수십 개로 늘면 매번 전부 손으로 확인할 수 없어요. 셋째, 코드를 고친 뒤 "다른 데가 망가지지 않았나" 를 확인하려면, 예전에 했던 확인을 처음부터 다시 해야 해요.
그래서 나온 게 단위 테스트예요. "이 메서드를 이렇게 부르면 결과는 이게 나와야 한다" 를 코드로 미리 적어두는 거예요. 그러면 사람이 눈으로 비교하는 대신, 컴퓨터가 기대값과 실제값을 직접 맞춰보고 다르면 빨간 불로 알려줘요. 이 채점을 대신해 주는 도구가 JUnit 이에요. 자바에서 가장 널리 쓰는 테스트 도구죠.
[ 손으로 확인 ] [ 단위 테스트 ]
main 에서 메서드 호출 테스트 메서드에 기대값을 적어둠
│ │
println 으로 결과 출력 JUnit 이 실제로 실행
│ │
사람이 눈으로 80 인지 비교 컴퓨터가 기대값 80 == 실제값 비교
│ │
헷갈리면 놓침, 매번 다시 다르면 빨간 불, 맞으면 초록 불 (언제든 다시)
🙋 학생 질문 — "튜터님, JUnit 은 제 메서드 중에서 어떤 게 테스트인지 어떻게 알아요?"
좋은 질문이에요. 우리가 메서드 위에 @Test 라는 표시를 붙여 두면, JUnit 이 실행될 때 그 클래스 속을 리플렉션으로 훑어서 @Test 가 붙은 메서드만 골라 실행해요. 지난 시간에 만든 MiniInjector 가 @AutoFill 붙은 필드만 골라 값을 채운 것과 똑같은 뼈대예요. 그때 배운 "표시(어노테이션)와, 그 표시를 읽어 행동하는 코드(리플렉션)는 한 쌍" 이라는 원리가 여기서 그대로 쓰여요. @Test 는 표시일 뿐이고, 그걸 읽어 메서드를 실제로 실행해 주는 쪽이 JUnit 이에요.
말로만 들으면 멀게 느껴지니 첫 테스트를 직접 써 봐요. Day 8 에서 만든 Member 가 사용자 이름을 제대로 보관하는지 확인하는 아주 작은 테스트예요.
// (테스트) com/instagram/javabasic/testing/MemberFirstTest.java
package com.instagram.javabasic.testing;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import com.instagram.javabasic.domain.member.Member;
class MemberFirstTest {
@Test
@DisplayName("회원을 만들면 사용자 이름이 그대로 들어간다")
void memberKeepsItsUsername() {
Member jaehoon = new Member("jaehoon", "jaehoon@instagram.com");
assertEquals("jaehoon", jaehoon.getUsername());
}
}
새 표시가 두 개 보이죠. @Test 는 "이 메서드는 테스트예요" 라는 표시예요. 이게 붙은 메서드를 JUnit 이 찾아 실행해요. @DisplayName 은 그 테스트에 사람이 읽기 좋은 한글 이름을 달아주는 표시예요. 테스트가 많아지면 결과 목록에서 메서드 이름보다 이 한글 설명이 훨씬 눈에 잘 들어와요.
핵심은 마지막 줄 assertEquals("jaehoon", jaehoon.getUsername()) 이에요. assertEquals(기대값, 실제값) 은 "이 둘이 같아야 한다" 고 컴퓨터에게 맡기는 문장이에요. assert 는 우리말로 "단언한다 · 분명히 그렇다고 못 박는다" 는 뜻이에요. 둘이 같으면 조용히 통과하고, 다르면 그 자리에서 테스트를 실패로 처리해요.
실행은 IntelliJ 에서 메서드 왼쪽에 뜨는 초록색 ▶ 버튼을 누르면 돼요. 통과하면 초록색 체크가, 실패하면 빨간색 X 가 떠요. 일부러 기대값을 "jaehoa" 처럼 틀리게 바꿔 실행하면, JUnit 이 expected: <jaehoa> but was: <jaehoon> 이라고 어디가 어긋났는지 콕 집어 알려줘요. 무엇이 틀렸는지 한눈에 보이니, 더는 우리가 화면을 노려보며 숫자를 맞춰볼 필요가 없어요.
💡 오늘 기억할 한 줄: 단위 테스트는 기대값과 실제값을 컴퓨터가 대신 비교해 주는 코드예요.
@Test표시를 JUnit 이 리플렉션으로 골라 실행해 주는 것 — 지난 시간 만든MiniInjector와 같은 뼈대예요.
Step 2: 값이 맞는지 확인하기 (assertEquals · assertTrue · assertAll)
첫 테스트에서 assertEquals 를 살짝 맛봤어요. 이번엔 값을 확인하는 도구들을 제대로 익혀볼게요. 대상은 Day 9 에서 만든 Member.calculateRecommendScore() 예요. 팔로워·게시물·함께 아는 친구·활동 일수를 더해 추천 점수를 내는 메서드였죠.
팔로워 1000, 게시물 50, 함께 아는 친구 5, 활동 300일인 회원의 점수를 손으로 계산하면 10 + 10 + 50 + 10 = 80 점이에요. 이게 정말 80 이 나오는지, 그리고 그 점수가 "보통" 등급으로 이어지는지 컴퓨터에게 확인시켜 볼게요.
// (테스트) com/instagram/javabasic/testing/MemberScoreTest.java
@Test
@DisplayName("추천 점수는 네 가지 기준을 더해 80점이 된다")
void recommendScoreSumsToEighty() {
// 팔로워 1000(=10) + 게시물 50(=10) + 함께 아는 친구 5(=50) + 활동 300일(=10) = 80
Member jaehoon = new Member("jaehoon", 1000, 50, 5, 300);
assertEquals(80, jaehoon.calculateRecommendScore());
}
assertEquals 가 숫자·문자열이 같은지 본다면, 참인지 거짓인지를 보는 도구도 있어요. assertTrue(조건) 은 조건이 참이어야 통과하고, assertFalse(조건) 은 거짓이어야 통과해요. "80 점은 70 점 이상이다(참)" 와 "80 점은 300 점 이상이 아니다(거짓)" 를 이렇게 확인해요.
assertTrue(jaehoon.calculateRecommendScore() >= 70);
assertFalse(jaehoon.calculateRecommendScore() >= 300);
assertEquals("보통", jaehoon.grade());
그런데 한 회원에 대해 점수·등급·경계 조건을 한 메서드에서 줄줄이 확인하다 보면 문제가 하나 생겨요. assertEquals 가 줄줄이 있을 때 첫 줄이 실패하면, 그 자리에서 테스트가 멈춰서 아래 줄들은 확인조차 못 해요. 첫 번째 문제만 보이고 나머지는 가려지는 거예요.
[ 따로따로 assert ] [ assertAll 로 묶기 ]
assert 1 ← 여기서 실패하면 assertAll(
assert 2 ← 실행 안 됨 (가려짐) assert 1, ← 실패해도
assert 3 ← 실행 안 됨 (가려짐) assert 2, ← 계속 실행
assert 3 ← 계속 실행
→ 한 번에 문제 하나만 보임 ) → 셋 다 확인해 한꺼번에 보고
이럴 때 assertAll 로 여러 검증을 하나로 묶으면, 중간에 하나가 틀려도 멈추지 않고 전부 실행한 뒤 어긋난 것들을 한꺼번에 모아 보여줘요. 문제를 하나씩 고치고 다시 돌리는 수고를 덜어주죠.
@Test
@DisplayName("한 회원의 점수·등급·경계 조건을 assertAll 로 한꺼번에 확인한다")
void verifyMemberAllAtOnce() {
Member jaehoon = new Member("jaehoon", 1000, 50, 5, 300);
assertAll(
() -> assertEquals(80, jaehoon.calculateRecommendScore()),
() -> assertEquals("보통", jaehoon.grade()),
() -> assertTrue(jaehoon.calculateRecommendScore() >= 70)
);
}
각 검증을 () -> ... 람다로 감싼 게 보이죠? Day 25 에서 배운 그 람다예요. "지금 실행하지 말고, 검증할 코드 조각을 통째로 assertAll 에게 건네줘" 라는 뜻이에요. assertAll 이 그 조각들을 하나씩 차례로 실행하면서 어느 게 틀렸는지 모아 줘요.
💡 오늘 기억할 한 줄:
assertEquals는 값이 같은지,assertTrue/assertFalse는 참인지 거짓인지를 확인해요. 여러 검증을assertAll로 묶으면 하나가 틀려도 멈추지 않고 전부 확인해 한꺼번에 알려줘요.
Step 3: 예외가 제대로 터지는지 확인하기 (assertThrows)
지금까지는 "값이 맞는지" 를 확인했어요. 그런데 어떤 상황에선 정답이 값이 아니라 예외예요. Day 21~23 에서 배웠죠 — 잘못된 입력이 들어오면 예외를 던져서 막는 게 올바른 동작이라고요. 그렇다면 "이 상황에선 예외가 제대로 터져야 한다" 는 것도 테스트로 확인할 수 있어야 해요.
대상은 Day 24 에서 만든 EmailChangeService.changeEmail() 이에요. 이메일 형식이 틀리면 IllegalArgumentException, 다른 회원이 쓰는 이메일이면 DuplicateEmailException, 없는 회원이면 MemberNotFoundException 을 던지도록 만들어 뒀어요.
이걸 확인하는 도구가 assertThrows 예요. "이 코드를 실행하면 이런 예외가 터져야 한다" 를 검증해요.
// (테스트) com/instagram/javabasic/testing/EmailChangeServiceTest.java
@Test
@DisplayName("형식이 틀린 이메일로 바꾸려 하면 IllegalArgumentException 이 터진다")
void invalidFormatThrows() {
MemberRepository repository = new MemberRepository();
Member jaehoon = new Member("jaehoon", "jaehoon@old.com");
Long id = repository.save(jaehoon);
EmailChangeService service = new EmailChangeService(repository);
// assertThrows 는 터진 예외 객체를 돌려줘요 — 메시지까지 들여다볼 수 있어요.
IllegalArgumentException thrown = assertThrows(
IllegalArgumentException.class,
() -> service.changeEmail(id, "골뱅이가-없는-주소"));
assertTrue(thrown.getMessage().contains("올바르지 않아요"));
}
여기도 () -> service.changeEmail(...) 람다가 보여요. 왜 그냥 메서드를 부르지 않고 람다로 감쌀까요? 만약 그냥 부르면, 예외가 그 자리에서 터져서 테스트 자체가 멈춰버려요. 그래서 "터질 수도 있는 코드 조각" 을 람다로 싸서 assertThrows 에게 건네요. JUnit 이 그 조각을 대신 실행하면서 "정말 IllegalArgumentException 이 터지나?" 를 지켜봐요. 첫 번째 인자(IllegalArgumentException.class)가 바로 "이런 예외가 터져야 한다" 는 기대예요.
assertThrows 는 한 가지 선물을 더 줘요 — 실제로 터진 예외 객체를 돌려줘요. 그래서 위처럼 thrown.getMessage() 로 예외 메시지까지 확인할 수 있어요. 반대로 "여긴 예외가 나면 안 된다" 를 확인하고 싶을 땐 assertDoesNotThrow 를 써요.
@Test
@DisplayName("올바른 새 이메일이면 예외 없이 값이 바뀐다")
void validEmailChangesWithoutThrowing() {
MemberRepository repository = new MemberRepository();
Member jaehoon = new Member("jaehoon", "jaehoon@old.com");
Long id = repository.save(jaehoon);
EmailChangeService service = new EmailChangeService(repository);
assertDoesNotThrow(() -> service.changeEmail(id, "jaehoon@new.com"));
assertEquals("jaehoon@new.com", jaehoon.getEmail());
}
이렇게 하면 한 서비스의 네 갈래 길 — 형식 오류·중복·없는 회원·정상 변경 — 을 모두 테스트로 못 박을 수 있어요. 코드베이스 EmailChangeServiceTest 에는 DuplicateEmailException·MemberNotFoundException 이 터지는 경우까지 네 가지가 다 들어 있어요.
💡 오늘 기억할 한 줄:
assertThrows(예외타입.class, () -> 코드)는 그 코드가 정말 그 예외를 던지는지 확인해요. 터질 코드는 람다로 감싸 넘기고, 돌려받은 예외 객체로 메시지까지 검증할 수 있어요.
Step 4: 매번 깨끗한 출발선 만들기 (@BeforeEach · @AfterEach)
Step 3 의 테스트들을 다시 보면, 메서드마다 new MemberRepository() 로 저장소를 만들고 회원을 저장하는 준비 코드가 똑같이 반복됐어요. 테스트가 늘어날수록 이 준비 코드도 계속 복사돼요. 게다가 더 중요한 문제가 있어요 — 테스트끼리 같은 객체를 나눠 쓰면, 앞 테스트가 바꿔 놓은 상태가 뒤 테스트에 새어 들어가 엉뚱한 결과가 나와요.
그래서 JUnit 은 @BeforeEach 라는 표시를 줘요. 이 표시가 붙은 메서드는 테스트 하나하나가 시작되기 직전마다 자동으로 실행돼요. 여기서 깨끗한 객체를 새로 만들어 두면, 모든 테스트가 늘 같은 출발선에서 시작해요. 짝꿍인 @AfterEach 는 테스트가 끝난 직후마다 실행돼서 뒷정리를 맡아요.
대상은 Post.addLike() 예요. 좋아요를 누르면 좋아요 수가 1 씩 오르는 메서드죠.
// (테스트) com/instagram/javabasic/testing/PostLikeTest.java
private Post post;
@BeforeEach
void setUp() {
// 매 테스트 직전에 좋아요 0 인 새 게시물을 새로 만들어 둬요.
post = new Post("오늘의 점심 사진", "jaehoon", 0);
}
@AfterEach
void tearDown() {
// 매 테스트가 끝난 뒤 정리하는 자리예요. 다음 테스트는 위 setUp 이 다시 깨끗하게 채워줘요.
post = null;
}
@Test
@DisplayName("좋아요를 한 번 누르면 좋아요 수가 1 이 된다")
void likeOnce() {
post.addLike();
assertEquals(1, post.getLikeCount());
}
@Test
@DisplayName("좋아요를 세 번 눌러도 매번 0 에서 시작하므로 3 이 된다")
void likeThreeTimes() {
post.addLike();
post.addLike();
post.addLike();
assertEquals(3, post.getLikeCount());
}
두 번째 테스트가 중요해요. 앞 테스트(likeOnce)가 이미 좋아요를 한 번 눌렀지만, likeThreeTimes 는 다시 0 에서 시작해 3 이 돼요. @BeforeEach 가 매번 새 게시물을 만들어 주니까요. 만약 두 테스트가 같은 게시물을 나눠 썼다면, 좋아요가 1 + 3 = 4 로 새어 엉켰을 거예요.
@BeforeEach → likeOnce() → @AfterEach (새 게시물 0 → 1)
@BeforeEach → likeThreeTimes() → @AfterEach (또 새 게시물 0 → 3)
└ 매 테스트 앞에서 새로 만들어 주니 서로 간섭하지 않아요
테스트가 서로 영향을 주지 않고 따로 노는 이 성질을, 잠시 뒤 Step 6 에서 좋은 테스트의 한 성질로 다시 만나요.
💡 오늘 기억할 한 줄:
@BeforeEach는 테스트마다 시작 직전에 실행돼 깨끗한 출발선을 만들어 줘요. 덕분에 테스트끼리 상태가 새지 않고 서로 간섭하지 않아요.
Step 5: 입력만 바꿔 여러 번 검사하기 (@ParameterizedTest)
Member.grade() 는 점수에 따라 등급을 네 가지로 나눠요 — 300 점 이상 "강력 추천", 150 점 이상 "추천", 70 점 이상 "보통", 그 아래 "관심 낮음". 이 네 구간을 모두 확인하려면 거의 똑같은 테스트를 네 번 복사해야 할까요? 입력과 기대값만 다르고 나머지는 판박이인데 말이에요.
이럴 때 쓰는 게 @ParameterizedTest 예요. @Test 자리에 이걸 붙이고 입력 목록을 따로 주면, JUnit 이 입력 하나마다 테스트를 한 번씩 돌려줘요. 입력을 주는 방법 중 가장 단순한 둘이 @ValueSource 와 @CsvSource 예요.
@ValueSource 는 값을 하나씩 넘겨요. "함께 아는 친구 수만 있으면 점수는 친구 수의 10 배" 라는 규칙을, 친구 수 1·5·10·30 네 가지로 한 번에 확인해 볼게요.
// (테스트) com/instagram/javabasic/testing/MemberGradeParameterizedTest.java
@ParameterizedTest
@ValueSource(ints = {1, 5, 10, 30})
@DisplayName("함께 아는 친구 수만 있으면 점수는 친구 수의 10 배다")
void scoreIsTenTimesMutualFriends(int mutualFriends) {
// 팔로워·게시물·활동일을 0 으로 두면 점수는 오롯이 '함께 아는 친구 × 10' 이에요.
Member member = new Member("user", 0, 0, mutualFriends, 0);
assertEquals(mutualFriends * 10, member.calculateRecommendScore());
}
@ValueSource(ints = {1, 5, 10, 30}) 의 값이 하나씩 메서드의 int mutualFriends 자리로 들어가요. 그래서 이 테스트 하나가 실제로는 네 번 실행돼요.
값을 여러 개 묶어서 넘기고 싶을 땐 @CsvSource 예요. 한 줄에 콤마로 값을 나눠 적으면, 그게 메서드의 매개변수들로 차례차례 들어가요. 등급 네 구간을 한 표로 확인해 볼게요.
@ParameterizedTest
@CsvSource({
"0, 0, 30, 0, 강력 추천",
"500, 100, 14, 60, 추천",
"1000, 50, 5, 300, 보통",
"100, 10, 6, 30, 관심 낮음"
})
@DisplayName("점수 구간마다 등급이 강력 추천·추천·보통·관심 낮음으로 갈린다")
void gradeMatchesScoreBand(int followers, int posts, int mutualFriends,
int daysActive, String expectedGrade) {
Member member = new Member("user", followers, posts, mutualFriends, daysActive);
assertEquals(expectedGrade, member.grade());
}
한 줄이 곧 한 번의 테스트예요. 첫 줄은 점수 300 점 → "강력 추천", 마지막 줄은 64 점 → "관심 낮음" 을 확인해요. 네 구간을 네 줄로 깔끔하게 못 박았죠. 나중에 다섯 번째 구간이 생겨도 표에 한 줄만 더하면 돼요.
💡 오늘 기억할 한 줄:
@ParameterizedTest는 입력만 바꿔 같은 검사를 여러 번 돌려요.@ValueSource는 값 하나씩,@CsvSource는 한 줄에 여러 값을 콤마로 나눠 넘겨요.
Step 6: 좋은 테스트의 다섯 가지 성질 (F.I.R.S.T)
여기까지 도구를 익혔으니, 이제 한 걸음 물러나서 "어떤 테스트가 좋은 테스트인가" 를 정리해 볼게요. 흔히 다섯 글자 F.I.R.S.T 로 기억해요. 놀랍게도 우리가 앞에서 쓴 테스트들이 이미 이 다섯 성질을 거의 다 지키고 있었어요.
F Fast 빠르다 — DB·네트워크 없이 메모리에서 순식간에
I Independent 독립적이다 — 테스트끼리 간섭하지 않는다 (@BeforeEach)
R Repeatable 반복 가능하다 — 언제 어디서 돌려도 같은 결과
S Self-validating 자가 검증한다 — 사람 눈 없이 통과/실패가 자동 (assert)
T Timely 제때 작성한다 — 코드와 같은 시점에, 되도록 곧바로
- F (Fast, 빠르다): 단위 테스트는 진짜 데이터베이스나 네트워크에 기대지 않고 메모리에서만 돌아야 빨라요. 우리가 쓴 인메모리 저장소(
MemberRepository)가 딱 그랬죠. 빨라야 자주 돌리고, 자주 돌려야 문제를 빨리 잡아요. - I (Independent, 독립적이다): 테스트끼리 순서나 상태에 얽히면 안 돼요. Step 4 에서
@BeforeEach로 매번 새 출발선을 만든 게 바로 이 성질을 지킨 거예요. - R (Repeatable, 반복 가능하다): 오늘 돌려도, 내 컴퓨터에서 돌려도, 친구 컴퓨터에서 돌려도 같은 결과가 나와야 해요. 그래서 좋은 테스트는 "오늘 날짜" 나 "무작위 값" 처럼 돌릴 때마다 달라지는 것에 기대지 않아요.
- S (Self-validating, 스스로 검증한다): 사람이 출력을 눈으로 읽어 판단하는 게 아니라,
assert로 통과·실패가 저절로 갈려야 해요. Step 1 에서 손 확인을 컴퓨터에게 맡긴 그 정신이에요. - T (Timely, 제때 작성한다): 테스트는 코드를 한참 묵힌 뒤가 아니라 같은 시점에, 되도록 곧바로 함께 써 두는 게 좋아요. 그래야 기억이 생생할 때 빈틈을 메워요.
이 다섯 글자는 외우기 위한 것이라기보다, "내 테스트가 혹시 느리진 않나? 다른 테스트에 기대고 있진 않나?" 를 스스로 점검하는 체크리스트예요.
💡 오늘 기억할 한 줄: 좋은 테스트는 빠르고(F), 서로 독립적이고(I), 반복 가능하고(R), 스스로 검증하고(S), 제때 작성돼요(T). 우리가 쓴 테스트들이 이미 이 성질을 지키고 있었어요.
Step 7: 인스타그램 서비스를 직접 테스트하기
마지막은 오늘 배운 도구를 총동원해 진짜 서비스를 검증하는 시간이에요. 대상은 Day 24 에서 만든 FeedService.buildFeed() — 내가 팔로우한 사람들의 게시물을 한데 모아 피드를 만드는 서비스죠. 회원 저장소·게시물 저장소·팔로우 관계가 한꺼번에 엮이는, 지금까지 중 가장 큰 테스트예요.
먼저 @BeforeEach 로 작은 세상을 차려요. 재훈이가 민지와 승우를 팔로우하고, stranger 는 팔로우하지 않아요. 그리고 각자 게시물을 올려요.
// (테스트) com/instagram/javabasic/testing/FeedServiceTest.java
// (전체 코드: src/test/java/com/instagram/javabasic/testing/FeedServiceTest.java)
private FeedService feedService;
private Long myId;
@BeforeEach
void setUp() {
MemberRepository memberRepository = new MemberRepository();
PostRepository postRepository = new PostRepository();
Member me = new Member("jaehoon", "jaehoon@instagram.com");
Member minji = new Member("minji", "minji@instagram.com");
Member seungwoo = new Member("seungwoo", "seungwoo@instagram.com");
Member stranger = new Member("stranger", "stranger@instagram.com");
myId = memberRepository.save(me);
memberRepository.save(minji);
memberRepository.save(seungwoo);
memberRepository.save(stranger);
// 내가 팔로우하는 사람은 민지와 승우 둘뿐 — stranger 는 팔로우하지 않아요.
me.follow(minji);
me.follow(seungwoo);
postRepository.save(new Post("민지의 점심", minji, 0));
postRepository.save(new Post("민지의 노을", minji, 0));
postRepository.save(new Post("승우의 운동", seungwoo, 0));
postRepository.save(new Post("모르는 사람의 글", stranger, 0));
feedService = new FeedService(memberRepository, postRepository);
}
이제 피드를 만들어 확인해요. 내가 팔로우한 민지(2 개)와 승우(1 개)의 글만 모여서 3 개여야 하고, 팔로우하지 않은 stranger 의 글은 빠져야 해요. 여러 조건을 assertAll 로 한꺼번에 확인할게요.
@Test
@DisplayName("피드에는 팔로우한 사람의 글만 모이고 모르는 사람 글은 빠진다")
void feedCollectsOnlyFollowedMembersPosts() {
List<Post> feed = feedService.buildFeed(myId);
assertAll(
() -> assertEquals(3, feed.size()),
() -> assertTrue(feed.stream().anyMatch(p -> p.getAuthorName().equals("minji"))),
() -> assertTrue(feed.stream().anyMatch(p -> p.getAuthorName().equals("seungwoo"))),
() -> assertFalse(feed.stream().anyMatch(p -> p.getAuthorName().equals("stranger")))
);
}
@BeforeEach(출발선) · assertEquals(개수) · assertTrue/assertFalse(포함 여부) · assertAll(묶음) — 오늘 익힌 도구가 한 테스트 안에서 톱니바퀴처럼 맞물려요. 이렇게 여러 객체를 엮어 서비스 전체의 흐름을 확인하는 테스트는, 작은 메서드 하나를 보는 테스트보다 더 든든한 안전망이 돼요. 실제로 이 동작은 코드베이스 FeedServiceTest 에서 통과하도록 검증되어 있어요.
💡 오늘 기억할 한 줄: 여러 객체를 엮은 서비스도
@BeforeEach로 상황을 차리고assertAll로 결과를 한꺼번에 확인하면 든든하게 검증돼요.
마무리
오늘은 그동안 우리 코드를 뒤에서 지켜봐 주던 JUnit 을 정면으로 만난 하루였어요. 지난 시간 남긴 약속 — "@Test 도 어노테이션이고, JUnit 이 리플렉션으로 그걸 골라 실행한다" — 도 첫 Step 에서 풀었죠.
- 왜 테스트인가 — 손으로 눈으로 확인하던 걸 컴퓨터에게 맡겨, 기대값과 실제값을 자동으로 비교해요.
@Test표시를 JUnit 이 리플렉션으로 골라 실행해요. - 값 검증 —
assertEquals(같은지)·assertTrue/assertFalse(참·거짓)·assertAll(여러 검증을 한 번에). - 예외 검증 —
assertThrows로 "이 상황엔 예외가 터져야 한다" 를 확인하고, 람다로 터질 코드를 감싸 넘겨요. - 깨끗한 출발선 —
@BeforeEach/@AfterEach로 테스트마다 새로 준비해, 테스트끼리 간섭하지 않게 해요. - 여러 입력 —
@ParameterizedTest에@ValueSource/@CsvSource로 입력만 바꿔 같은 검사를 여러 번 돌려요. - 좋은 테스트 — 빠르고·독립적이고·반복 가능하고·스스로 검증하고·제때 쓰는 다섯 성질(F.I.R.S.T).
이제 우리는 코드를 짜기만 하는 게 아니라, 그 코드가 정말 제대로 도는지 스스로 증명할 수 있게 됐어요. 다음 시간엔 드디어 이 모든 걸 한자리에 모아요. 지금까지 배운 디자인 패턴·Stream/Optional·그리고 오늘의 JUnit 을 총동원해서, 흩어져 있던 인스타그램 도메인을 더 유연한 구조로 다시 다듬고 테스트로 단단히 감싸요. 마지막엔 앞으로의 학습 길까지 함께 그려볼 거예요. 수고 많으셨어요!
과제
오늘 배운 테스트 도구를 직접 익혀보는 과제예요. 모두 코드베이스에 이미 있는 클래스를 대상으로 테스트만 작성하면 돼요. 한 단계씩 쌓아 올리도록 준비했어요.
과제 1: 게시물 상태별 공유 가능 여부 테스트하기 [기초]
Post 에는 지금 공유할 수 있는 상태인지 알려주는 canBeShared() 메서드가 있어요. 게시물 상태(PostStatus)는 공개(PUBLIC)·비공개(PRIVATE)·보관됨(ARCHIVED) 세 가지인데, 공개일 때만 공유가 가능해요. 세 가지 상태에 대해 setStatus(...) 로 상태를 바꾼 뒤, 공개는 assertTrue, 비공개·보관됨은 assertFalse 로 canBeShared() 결과를 확인하는 테스트를 작성해 보세요. (힌트: 상태는 post.setStatus(PostStatus.PRIVATE) 처럼 바꿀 수 있어요.)
과제 2: 저장소 동작을 픽스처와 함께 테스트하기 [응용]
MemberRepository 가 약속대로 동작하는지 확인하는 테스트를 작성해 보세요. @BeforeEach 로 매 테스트마다 빈 저장소를 새로 만들어 두고, 세 가지를 확인하면 돼요 — ① 저장하면 1, 2 처럼 번호표(id)가 1 씩 커지는지(assertEquals), ② 없는 id 로 찾으면 MemberNotFoundException 이 터지는지(assertThrows), ③ 저장한 회원의 이메일은 existsByEmail 이 true, 없는 이메일은 false 인지(assertTrue/assertFalse). (힌트: save 는 새 번호표를 돌려줘요.)
과제 3: 추천 점수 공식을 표로 검증하기 [심화]
Member.calculateRecommendScore() 가 여러 입력 조합에서 정확한 점수를 내는지 @ParameterizedTest 와 @CsvSource 로 한 표에 모아 검증해 보세요. 한 줄에 팔로워·게시물·함께 아는 친구·활동 일수·기대 점수를 적고, 적어도 네 줄(서로 다른 조합)을 넣어 보세요. 점수 공식은 팔로워/100 + 게시물/5 + 함께 아는 친구×10 + 활동일/30 이에요 (정수 나눗셈이라 소수점은 버려요). (힌트: "100, 5, 1, 30, 13" 한 줄은 점수 13 을 뜻해요.)
생각해볼 주제
1. 테스트가 모두 통과하면 버그가 없다고 확신해도 될까?
오늘 우리는 테스트를 초록 불로 통과시키며 뿌듯했어요. 그런데 테스트가 전부 통과한다는 건 정확히 무엇을 뜻할까요? 테스트는 우리가 "확인하기로 적어둔 경우" 만 검사해요. 적어두지 않은 경우 — 예를 들어 점수가 음수로 들어오거나, 이름이 빈 문자열인 경우 — 는 테스트가 통과해도 여전히 모르는 채로 남아요. "테스트 통과" 와 "버그 없음" 사이의 거리가 얼마나 되는지, 그리고 그 거리를 어떻게 좁힐 수 있을지 생각해 보세요.
2. 테스트 코드도 결국 유지보수해야 하는 짐일까?
테스트를 많이 짜둘수록 좋아 보이지만, 프로덕션 코드가 바뀌면 그에 묶인 테스트도 함께 고쳐야 해요. 만약 테스트가 내부 구현에 너무 시시콜콜 매여 있으면, 동작은 그대로인데 구조만 조금 바꿔도 테스트가 우수수 깨져 리팩토링의 발목을 잡아요. "잘 깨지지 않으면서도 제 역할을 하는 테스트" 는 무엇을 검증하고 무엇을 검증하지 않아야 할지, F.I.R.S.T 와 엮어 생각해 보세요.
3. private 메서드도 따로 테스트해야 할까?
테스트는 보통 public 메서드를 통해 동작을 확인해요. 그런데 클래스 안에 숨은 private 메서드가 복잡하다면, 그것도 직접 테스트하고 싶어질 수 있어요. 지난 시간 배운 리플렉션의 setAccessible(true) 을 쓰면 private 도 열어서 부를 수 있긴 해요. 하지만 그게 정말 좋은 선택일까요? private 을 직접 테스트하는 것과, public 동작을 통해 간접적으로 검증하는 것 사이에서 무엇을 기준으로 골라야 할지 생각해 보세요.
✅ 예시 답안정답 보기
아래는 과제와 생각해볼 주제의 예시답안이에요. 정답이 하나만 있는 건 아니니, 내가 짠 테스트·내가 떠올린 답과 비교하며 "왜 이렇게 했는지" 를 짚어보세요.
과제 예시답안
과제 1 예시답안 — 게시물 상태별 공유 가능 여부 테스트하기
핵심 접근
이 과제의 핵심은 "상태가 바뀌면 기대 결과도 바뀐다" 를 테스트로 못 박는 거예요. Post.canBeShared() 는 게시물 상태가 공개일 때만 true 를 돌려줘요. 그래서 setStatus(...) 로 세 상태를 각각 만들어 두고, 공개는 assertTrue, 비공개·보관됨은 assertFalse 로 결과를 확인하면 돼요.
예시 구현
// (테스트) com/instagram/javabasic/testing/solution/day46/PostShareTest.java
class PostShareTest {
@Test
@DisplayName("공개 게시물은 공유할 수 있다")
void publicPostCanBeShared() {
Post post = new Post("오늘의 점심 사진", "jaehoon", 0);
post.setStatus(PostStatus.PUBLIC);
assertTrue(post.canBeShared());
}
@Test
@DisplayName("비공개 게시물은 공유할 수 없다")
void privatePostCannotBeShared() {
Post post = new Post("오늘의 점심 사진", "jaehoon", 0);
post.setStatus(PostStatus.PRIVATE);
assertFalse(post.canBeShared());
}
@Test
@DisplayName("보관된 게시물은 공유할 수 없다")
void archivedPostCannotBeShared() {
Post post = new Post("오늘의 점심 사진", "jaehoon", 0);
post.setStatus(PostStatus.ARCHIVED);
assertFalse(post.canBeShared());
}
}
미션별 답
- 공개일 때:
setStatus(PostStatus.PUBLIC)으로 두면canBeShared()가true라assertTrue가 통과해요. - 비공개·보관됨일 때: 두 상태 모두 공유가 막혀
false가 나오니assertFalse로 확인해요. 이렇게 세 갈래를 각각 테스트로 적어두면, 나중에 누가 실수로 비공개도 공유되게 바꿔버리면 빨간 불이 바로 알려줘요.
채점 포인트
| 포인트 | 무엇을 봐야 하는가 | 배점 가중 |
|---|---|---|
| 세 상태 분기 | 공개·비공개·보관됨 세 상태를 모두 확인했는가 | 상 |
| 참·거짓 도구 | 공개는 assertTrue, 나머지는 assertFalse 로 맞게 골랐는가 |
상 |
| 상태 바꾸기 | setStatus(...) 로 상태를 정확히 지정했는가 |
중 |
| 읽기 좋은 이름 | @DisplayName 으로 무엇을 확인하는지 드러냈는가 |
중 |
흔한 실수
- 상태를 안 바꾸고 테스트하는 경우 →
new Post(...)는 기본이 공개(PUBLIC)라,setStatus를 빼먹으면 세 테스트가 다 공개를 검사하게 돼요. 비공개·보관됨은 꼭setStatus로 바꿔줘야 해요. assertTrue와assertFalse를 바꿔 쓰는 경우 → "공유 가능한가?" 라는 질문에 공개는 참, 나머지는 거짓이에요. 질문의 방향을 잡고 도구를 골라야 헷갈리지 않아요.
실무 개선 포인트 (심화)
세 테스트가 게시물을 만드는 첫 줄(new Post(...))을 똑같이 반복하고 있죠? 이런 중복은 Step 4 에서 배운 @BeforeEach 로 한 번에 모아둘 수 있어요. 더 나아가면, 상태와 기대 결과를 표로 묶어 @ParameterizedTest 한 메서드로 세 줄을 압축할 수도 있고요. 지금은 세 테스트로 또박또박 나눠 쓰는 게 읽기 좋지만, "반복되는 준비는 픽스처로, 입력만 다른 검사는 표로" 라는 방향만 기억해두면 좋아요.
과제 2 예시답안 — 저장소 동작을 픽스처와 함께 테스트하기
핵심 접근
이 과제의 마침표는 @BeforeEach 로 매 테스트마다 빈 저장소를 새로 만드는 거예요. 저장소를 테스트끼리 나눠 쓰면 앞 테스트가 저장한 회원이 뒤 테스트에 새어 들어가, 번호표(id)가 1 이 아니라 엉뚱한 값이 돼요. 깨끗한 출발선을 깔고, 그 위에서 세 가지 동작 — id 증가·없는 id 예외·이메일 존재 여부 — 을 각각 확인해요.
예시 구현
// (테스트) com/instagram/javabasic/testing/solution/day46/MemberRepositoryTest.java
class MemberRepositoryTest {
private MemberRepository repository;
@BeforeEach
void setUp() {
repository = new MemberRepository();
}
@Test
@DisplayName("저장하면 번호표(id)가 1, 2 처럼 1 씩 커진다")
void saveAssignsIncreasingIds() {
Long firstId = repository.save(new Member("jaehoon", "jaehoon@instagram.com"));
Long secondId = repository.save(new Member("minji", "minji@instagram.com"));
assertEquals(1L, firstId);
assertEquals(2L, secondId);
}
@Test
@DisplayName("없는 id 로 찾으면 MemberNotFoundException 이 터진다")
void findByIdThrowsWhenMissing() {
assertThrows(
MemberNotFoundException.class,
() -> repository.findById(999L));
}
@Test
@DisplayName("저장한 이메일은 존재로, 없는 이메일은 부재로 가려낸다")
void existsByEmailTellsSavedFromMissing() {
repository.save(new Member("jaehoon", "jaehoon@instagram.com"));
assertTrue(repository.existsByEmail("jaehoon@instagram.com"));
assertFalse(repository.existsByEmail("nobody@instagram.com"));
}
}
미션별 답
- 번호표 증가: 빈 저장소에 두 번 저장하면 첫 번째는 1, 두 번째는 2 가 돼요.
@BeforeEach로 매번 새 저장소를 깔았기에 늘 1 부터 시작해요. - 없는 id 예외: 저장한 적 없는 999 번을 찾으면
MemberNotFoundException이 터져요.assertThrows로 "이 상황엔 이 예외가 나야 한다" 를 못 박아요. - 이메일 존재 여부: 저장한 이메일은
existsByEmail이true, 없는 이메일은false예요. 두 경우를 한 테스트에서assertTrue·assertFalse로 같이 확인했어요.
채점 포인트
| 포인트 | 무엇을 봐야 하는가 | 배점 가중 |
|---|---|---|
| 깨끗한 출발선 | @BeforeEach 로 매 테스트마다 새 저장소를 만들었는가 |
상 |
| 예외 검증 | 없는 id 에 assertThrows 로 예외를 확인했는가 |
상 |
| 참·거짓 검증 | 이메일 존재 여부를 assertTrue/assertFalse 로 확인했는가 |
상 |
| 번호표 규칙 | 저장이 1, 2 로 커지는 걸 assertEquals 로 확인했는가 |
중 |
흔한 실수
@BeforeEach없이 저장소를 한 번만 만들어 나눠 쓰는 경우 → 앞 테스트가 저장한 회원이 남아, 번호표가 1 이 아니라 3, 4 처럼 어긋나요. 테스트 독립성(F.I.R.S.T 의 I)을 지키려면 매번 새로 만들어야 해요.assertThrows에 코드를 람다로 안 감싸는 경우 →assertThrows(예외.class, repository.findById(999L))처럼 바로 부르면, 예외가 그 자리에서 터져 테스트가 멈춰요. 반드시() -> ...로 감싸 넘겨요.
실무 개선 포인트 (심화)
지금은 메모리 위에서 도는 MemberRepository 라 테스트가 순식간에 끝나고(F.I.R.S.T 의 Fast), 매번 새로 만들어 깨끗해요(Repeatable). 다음 과목에서 진짜 데이터베이스에 저장하는 저장소를 만나면, 테스트마다 데이터를 비우고 시작하는 장치가 더 중요해져요 — 앞 테스트가 DB 에 남긴 흔적이 뒤 테스트를 망치지 않게요. 오늘 @BeforeEach 로 익힌 "매번 깨끗하게" 가 그때 그대로 쓰여요.
과제 3 예시답안 — 추천 점수 공식을 표로 검증하기
핵심 접근
거의 똑같은 검사를 입력만 바꿔 여러 번 하고 싶을 때 @ParameterizedTest 와 @CsvSource 가 딱이에요. 한 줄에 팔로워·게시물·함께 아는 친구·활동일·기대 점수를 적으면, 그 줄이 메서드의 매개변수로 들어가 한 번의 테스트가 돼요. 공식이 정수 나눗셈이라 소수점을 버린다는 점만 조심하면 돼요.
예시 구현
// (테스트) com/instagram/javabasic/testing/solution/day46/MemberScoreCsvTest.java
class MemberScoreCsvTest {
@ParameterizedTest
@CsvSource({
"100, 5, 1, 30, 13",
"1000, 50, 5, 300, 80",
"0, 0, 0, 0, 0",
"250, 12, 3, 90, 37"
})
@DisplayName("여러 입력 조합에서 추천 점수 공식이 정확히 맞아떨어진다")
void scoreMatchesFormula(int followers, int posts, int mutualFriends,
int daysActive, int expectedScore) {
Member member = new Member("user", followers, posts, mutualFriends, daysActive);
assertEquals(expectedScore, member.calculateRecommendScore());
}
}
미션별 답
공식은 팔로워/100 + 게시물/5 + 함께 아는 친구×10 + 활동일/30 이에요. 표의 각 줄을 손으로 계산해 보면 기대 점수가 왜 그 값인지 보여요.
100, 5, 1, 30→1 + 1 + 10 + 1 = 131000, 50, 5, 300→10 + 10 + 50 + 10 = 800, 0, 0, 0→0(경계값 — 모두 0 일 때도 정확히 0 인지 확인)250, 12, 3, 90→2 + 2 + 30 + 3 = 37
@CsvSource 의 네 줄이 각각 한 번씩, 모두 네 번의 테스트로 돌아가요.
채점 포인트
| 포인트 | 무엇을 봐야 하는가 | 배점 가중 |
|---|---|---|
| 파라미터화 | @ParameterizedTest + @CsvSource 로 표를 만들었는가 |
상 |
| 매개변수 연결 | 표의 값 순서와 메서드 매개변수 순서가 맞는가 | 상 |
| 기대값 계산 | 정수 나눗셈을 반영해 기대 점수를 맞게 적었는가 | 상 |
| 다양한 조합 | 서로 다른 네 줄(경계값 포함)을 넣었는가 | 중 |
흔한 실수
- 정수 나눗셈을 잊는 경우 →
게시물 5 / 5 = 1은 맞지만,게시물 12 / 5 = 2예요(2.4 가 아니라 소수점을 버려 2). 기대값을 적을 때 이 점을 놓치면 테스트가 빨간 불이 나요. 빨간 불이 났을 때 "내 공식이 틀렸나, 기대값이 틀렸나" 를 차분히 따져보면 돼요. - 표의 값 개수와 매개변수 개수가 안 맞는 경우 → 한 줄에 값이 다섯 개면 메서드 매개변수도 다섯 개여야 해요. 콤마 개수를 세어 맞춰요.
실무 개선 포인트 (심화)
표로 테스트하면 좋은 점이 또 있어요 — 새 경우가 떠오를 때마다 줄만 한 줄 더하면 돼요. 예를 들어 "팔로워가 음수로 들어오면?" 같은 경계 상황을 추가하고 싶으면, 그 입력과 기대 결과를 한 줄로 적기만 하면 되죠. 실무에선 이렇게 "정상 입력 몇 개 + 경계·이상 입력 몇 개" 를 한 표에 모아, 메서드가 구석진 경우에서도 흔들리지 않는지 촘촘히 확인해요.
생각해볼 주제 예시답안
생각해볼 주제 1 예시답안 — 테스트가 모두 통과하면 버그가 없다고 확신해도 될까
[문제 상황 요약]
오늘 우리는 테스트를 초록 불로 통과시키며 뿌듯했어요. 그런데 "모든 테스트 통과" 가 "버그 없음" 과 같은 말일까요? 통과가 정확히 무엇을 보장하고 무엇은 보장하지 못하는지에 대한 질문이에요.
[튜터의 가이드 및 해설]
먼저 분명히 할 게 있어요. 테스트가 통과한다는 건 "우리가 확인하기로 적어둔 경우" 가 기대대로 동작한다 는 뜻이에요. 딱 그만큼이에요. 적어두지 않은 경우는 통과 여부와 아무 상관이 없어요.
예를 들어 추천 점수 테스트를 떠올려 봐요. 우리는 팔로워 1000, 게시물 50 같은 평범한 값으로 80 점을 확인했어요. 그런데 팔로워가 음수로 들어오면? 엄청나게 큰 수가 들어와 넘쳐버리면? 이름이 빈 문자열이면? 이런 경우는 테스트에 적지 않았으니, 전부 통과해도 여전히 "모르는 채" 로 남아요. 테스트는 우리가 던진 질문에만 답할 뿐, 우리가 던지지 않은 질문은 답하지 않아요.
그래서 "테스트 통과" 와 "버그 없음" 사이엔 거리가 있어요. 이 거리를 좁히는 길은 두 가지예요. 하나는 경계와 이상한 입력까지 질문을 넓히는 것 — 0, 음수, 빈 값, 아주 큰 값처럼 구석진 경우를 표에 더해요. 다른 하나는 버그가 났을 때 그 경우를 테스트로 박제하는 것 — 한 번 터진 버그는 테스트로 남겨두면 다신 같은 자리에서 안 터져요. 결국 테스트는 "버그가 없음을 증명하는 도구" 가 아니라, "확인한 만큼 안심해도 된다는 안전망" 이에요. 그물코를 촘촘히 짤수록 더 많은 걸 잡지만, 그물에 없는 구멍으로는 여전히 빠져나갈 수 있다는 걸 잊지 않는 게 중요해요.
🎯 면접관을 홀리는 핵심 멘트
"테스트 통과는 '내가 작성한 경우가 기대대로 동작한다' 만 보장하지, '모든 경우에 버그가 없다' 를 보장하지 않습니다. 작성하지 않은 입력 — 경계값, 음수, 빈 값, 오버플로 — 은 통과와 무관하게 미지로 남습니다. 그래서 저는 정상 경로뿐 아니라 경계·이상 입력까지 테스트로 넓히고, 한 번 발생한 버그는 회귀 테스트로 박제해 같은 결함이 재발하지 않게 합니다. 테스트는 '버그 없음의 증명' 이 아니라 '확인한 만큼의 안전망' 이라는 관점으로 접근합니다."
생각해볼 주제 2 예시답안 — 테스트 코드도 결국 유지보수해야 하는 짐일까
[문제 상황 요약]
테스트를 많이 짜둘수록 든든하지만, 프로덕션 코드가 바뀌면 그에 묶인 테스트도 함께 고쳐야 해요. 테스트가 오히려 변경의 발목을 잡는 짐이 되는 건 아닐지, 그렇다면 어떤 테스트가 좋은 테스트인지에 대한 질문이에요.
[튜터의 가이드 및 해설]
테스트가 짐이 되는지 아닌지는, 무엇을 검증하느냐 에 달려 있어요. 핵심은 "겉으로 드러나는 동작" 을 검증하는 테스트와 "속의 구현 방식" 에 매인 테스트를 구분하는 거예요.
좋은 테스트는 동작(behavior) 을 봐요. "추천 점수가 80 이 나온다", "비공개 글은 공유가 막힌다" 처럼, 사용자나 다른 코드가 실제로 기대하는 결과를 확인하죠. 이런 테스트는 속 구현을 어떻게 바꾸든 — 점수 계산을 더 빠른 방식으로 고치든 — 결과만 같으면 그대로 통과해요. 그래서 리팩토링을 든든하게 받쳐줘요. "구조를 바꿔도 동작은 그대로구나" 를 테스트가 증명해 주니까요.
반대로 깨지기 쉬운 테스트는 구현 세부에 시시콜콜 매여 있어요. "내부의 이 private 메서드가 몇 번 불렸나", "필드 이름이 정확히 이거다" 같은 걸 검사하면, 동작은 그대로인데 구조만 살짝 바꿔도 테스트가 우수수 깨져요. 이러면 테스트가 안전망이 아니라 족쇄가 돼요 — 고치려 할 때마다 멀쩡한 테스트를 같이 손봐야 하니까요.
그러니 결론은 "테스트가 짐이다 / 아니다" 가 아니라, "동작을 검증하는 테스트는 자산, 구현에 매인 테스트는 부채" 예요. 여기에 F.I.R.S.T 가 잣대가 돼요. 잘 깨지지 않으면서도(구현이 아닌 동작을 보니까) 빠르고 독립적인 테스트를 지향하면, 테스트는 늘려도 짐이 안 되고 오히려 변경을 더 과감하게 만들어줘요. 테스트가 많아 부담스럽다면, 개수보다 "무엇을 검증하고 있는지" 를 먼저 돌아보는 게 좋아요.
🎯 면접관을 홀리는 핵심 멘트
"테스트가 짐이 되느냐는 '무엇을 검증하느냐' 에 달려 있습니다. 겉으로 드러나는 동작을 검증하는 테스트는 구현을 바꿔도 결과만 같으면 통과하므로 리팩토링을 떠받치는 자산이 됩니다. 반대로 내부 구현 세부에 매인 테스트는 동작이 그대로여도 구조만 바꾸면 깨져서 변경의 족쇄가 됩니다. 그래서 저는 private 호출 횟수나 필드 이름 같은 구현이 아니라 public 으로 드러나는 동작을 검증하고, F.I.R.S.T 기준으로 잘 깨지지 않는 테스트를 지향합니다. 그러면 테스트 수가 늘어도 부채가 아니라 자산이 됩니다."
생각해볼 주제 3 예시답안 — private 메서드도 따로 테스트해야 할까
[문제 상황 요약]
테스트는 보통 public 메서드를 통해 동작을 확인해요. 그런데 클래스 안에 숨은 private 메서드가 복잡하면 그것도 직접 테스트하고 싶어져요. 지난 시간 배운 setAccessible(true) 을 쓰면 private 도 열 수 있는데, 그게 정말 좋은 선택일지에 대한 질문이에요.
[튜터의 가이드 및 해설]
기본 원칙부터 잡아요. private 메서드는 보통 직접 테스트하지 않고, 그걸 부르는 public 동작을 통해 간접적으로 검증 해요. 왜냐면 private 은 그 클래스가 일을 해내는 "속사정" 이고, 바깥에서 약속한 건 public 동작이거든요. public 메서드를 다양한 입력으로 충분히 테스트하면, 그 안에서 불리는 private 도 자연히 함께 검증돼요.
지난 시간 배운 setAccessible(true) 을 쓰면 private 도 열어서 직접 부를 수 있긴 해요. 하지만 그렇게 private 을 콕 집어 테스트하면 두 가지 대가가 따라와요. 첫째, 캡슐화를 깨요 — 숨겨둔 속을 테스트가 들여다보니, 나중에 그 private 의 이름이나 모양만 바꿔도 테스트가 깨져요(주제 2 에서 말한 "구현에 매인 깨지기 쉬운 테스트" 가 되는 거죠). 둘째, 신호를 놓쳐요 — private 하나가 너무 복잡해서 따로 테스트하고 싶을 만큼이라면, 그건 보통 "이 private 을 별도의 클래스로 꺼내라" 는 신호예요. 꺼내서 public 으로 만들면, 캡슐화를 깨지 않고도 떳떳하게 테스트할 수 있어요.
그러니 판단 기준은 이래요. private 이 단순하면 public 동작으로 간접 검증하면 충분하고, private 이 직접 테스트하고 싶을 만큼 복잡하면 리플렉션으로 억지로 여는 대신 별도 클래스로 추출 하는 걸 먼저 생각해요. setAccessible 로 여는 건 정말 드물게, 다른 방법이 없을 때의 마지막 카드예요. 테스트하기 어렵다는 느낌 자체가 설계를 다듬으라는 힌트인 경우가 많아요.
🎯 면접관을 홀리는 핵심 멘트
"private 메서드는 원칙적으로 직접 테스트하지 않고, 그것을 호출하는 public 동작을 통해 간접 검증합니다. private 은 구현 세부라서, 리플렉션으로 열어 직접 테스트하면 캡슐화가 깨지고 테스트가 구현에 매여 쉽게 부서집니다. 오히려 private 이 따로 테스트하고 싶을 만큼 복잡하다면, 그건 별도 클래스로 추출하라는 설계 신호로 받아들입니다. 추출해서 public API 로 만들면 캡슐화를 지키면서 정당하게 테스트할 수 있습니다. setAccessible 로 여는 건 대안이 없을 때의 마지막 수단입니다."