QA 출신 개발자의 첫 자바 단위 테스트(Unit Test) 작성 회고

QA 엔지니어로 일할 때는 항상 “완성된 제품” 을 검증하는 게 내 일이었다.

화면에서 버튼을 누르거나 값을 넣거나 Playwright로 브라우저를 띄우고, 실제 API가 쏘는 응답을 기다리며 사용자의 흐름(E2E)을 테스트했다.

개발자가 되면서 단위 테스트를 해야하는데 방법은 모르고 IDE에서 빈 테스트 클래스를 마주한 순간 멍해졌다. 브라우저도 없고, DB도 연결 안 된 상태에서 도대체 어떻게 테스트를 짠단 말인가?

이 글은 블랙박스(Black Box) 테스트만 하던 내가, 화이트박스(White Box)의 세계로 들어와 자바 단위 테스트(Unit Test) 와 씨름하며 배운 것들을 정리한 회고록이다.

1. 도구(Tool) 파악: 내 익숙한 무기들과의 매핑

처음 접한 자바 테스트 라이브러리들은 낯설었지만, 개념적으로 QA 도구들과 매핑해보니 이해가 빨랐다.

1) JUnit 5 (org.junit.jupiter) : Test Runner

QA 시절, 수백 개의 테스트 케이스를 관리하던 Test Runner와 똑같다.

@Test 어노테이션은 “이게 테스트 케이스야”라고 깃발을 꽂는 것이고, BeforeEach는 테스트 전에 환경을 초기화하는 setup() 단계였다. 이건 익숙했다.

2) Mockito (org.mockito) : 가짜 서버의 ‘객체’ 버전

E2E 테스트할 때 백엔드 API가 준비 안 되면 Mock Server를 띄워서 가짜 응답을 주곤 했다.

Mockito는 그 개념을 자바 객체 레벨로 가져온 것이었다.

  • 상황: UserRelationshipService를 테스트해야 하는데, 실제 DB(Repository)를 쓰면 데이터가 꼬일 것 같다.

  • 해결: “야, Repository! 너는 실제 DB 가지 말고, 내가 시키는 대로 가짜 데이터만 뱉어.” (mocking)

3) AssertJ (org.assertj) : 가독성 좋은 검증문

assertThat(result).isEqualTo(expected);

Playwright의 expect(locator).toBeVisible()과 너무 비슷했다. 개발자가 읽기 좋게 영어 문장처럼 검증 로직을 짤 수 있게 해주는 도구였다.

2. 어노테이션(Annotation): 테스트 환경 조립하기

QA 할 때는 테스트 환경(QA, Staging, Prod)이 이미 구축되어 있었지만, 단위 테스트는 내가 직접 코드 레벨에서 환경을 조립해야 했다.

@ExtendWith(MockitoExtension.class)

“자, 이번 테스트 시나리오에서는 ‘대역 배우(Mock)‘들을 쓸 거니까 준비해 줘.”라고 JUnit에게 알리는 선언문이다.

@Mock vs @InjectMocks (나의 SUT는 누구인가?)

코드를 작성하면서 가장 헷갈렸던 부분이다. QA 용어로 SUT(System Under Test), 즉 ‘테스트 대상’이 누구냐를 명확히 해야 했다.

  • @InjectMocks (주인공): 내가 지금 검증하려는 로직이 담긴 객체. (UserRelationshipService)

  • @Mock (조연): 주인공이 작동하기 위해 필요한 부품들. (Repository, UserDomainService 등)

QA 때는 그냥 시스템 전체를 찔러봤지만, 개발자가 되니 “내가 테스트하고 싶은 건 오직 Service의 로직뿐이야. 나머지는 가짜여도 돼.” 라는 격리(Isolation) 의 개념을 배우게 되었다.

3. 실전 구현: ‘행위’를 코드로 검증하다

실제 코드(UserRelationshipServiceTest.java)를 작성하면서 겪은 시행착오들이다.

상황 1: UUID 생성 같은 ‘통제 불가능한 값’ 처리하기

서비스 코드 내에서 UUID.randomUUID()를 호출하는 부분이 있었다. 이건 실행할 때마다 값이 바뀌니 테스트가 불가능했다.

QA 였다면 “대충 UUID 형식이면 통과” 시켰겠지만, 단위 테스트에서는 정확한 값 검증이 필요했다.

결국 given(…).willAnswer(…)를 사용해서 Mock Repository가 저장될 때, 내가 원하는 가짜 UUID를 강제로 집어넣게 만들었다.

“테스트를 위해선 우연에 맡기는 코드가 단 한 줄도 없어야 한다” 는 걸 깨달았다.

상황 2: ReflectionTestUtils라는 Backdoor

엔티티의 ID(userId)는 private이라 외부에서 넣을 수 없게 막혀 있었다. (캡슐화)

하지만 테스트 데이터에는 ID가 필요했다.

QA 때는 개발자에게 “테스트용 데이터를 DB에 넣어주세요”라고 부탁했지만, 이제는 내가 ReflectionTestUtils를 써서 강제로 값을 주입했다.

4. 깊이 있는 검증: verifyArgumentCaptor

QA 자동화 스크립트 짤 때는 주로 “결과 화면이 잘 떴는가?”(State Verification) 를 확인했다.

하지만 단위 테스트에서는 “내부적으로 올바르게 호출했는가?”(Behavior Verification) 를 확인할 수 있었다.

verify(notificationService).createNotification(...)

UserRelationshipServiceTest에서 코칭 요청을 하면 알림이 가야 한다.

화면 UI를 확인할 필요 없이, verify 메서드 한 줄로 “알림 서비스의 생성 메서드가 정확히 1번 호출되었음” 을 보장할 수 있었다.

ArgumentCaptor 데이터 낚아채기

서비스 내부에서 생성된 객체는 밖에서 볼 수가 없다.

이때 ArgumentCaptor를 써서, 서비스가 리포지토리로 던지는 객체를 공중에서 낚아채 검사했다.

도청하는 느낌이다.

// 서비스가 Repository에게 넘긴 객체를 캡쳐!
verify(userRelationshipMapRepository).save(relationCaptor.capture());
// 캡쳐한 객체의 상태가 PENDING인지 확인
assertThat(savedRelation.getStatus()).isEqualTo(UserRelationshipStatusEnum.PENDING);

마치 네트워크 패킷을 스니핑해서 뜯어보는 기분이었다.

5. 회고: QA 눈과 개발자 눈의 차이

이번에 단위 테스트 코드를 직접 짜보면서, QA 엔지니어 시절 가졌던 의문들이 많이 해소되었다.

  1. “왜 개발자들은 버그를 못 잡았을까?”

    • 못 잡은 게 아니라, 단위 테스트는 ‘숲’이 아니라 ‘나무’를 보는 작업이었기 때문이다.

    • NotificationServiceTest에서 복잡한 탈퇴 로직(한 명 탈퇴 vs 둘 다 탈퇴)을 테스트할 때는 단위 테스트가 훨씬 강력했다. UI로 이 모든 케이스를 재현하려면 하루 종일 걸렸을 것이다.

  2. Shift Left의 의미

    • QA가 되어서 완성품을 테스트하는 것보다, 개발 단계에서 Test Code로 로직의 구멍을 미리 막는 것이 훨씬 비용이 적게 든다는 말을 몸소 체험했다.

    • 특히 requestCoaching_fail_alreadyMatched 같은 예외 케이스를 미리 테스트로 짜두니, 나중에 코드를 고칠 때도 안심이 되었다.

이제 나는 브라우저를 켜지 않고도 내 코드의 품질을 증명할 수 있는 도구를 하나 더 얻었다.

앞으로 개발자로서 기능을 구현할 때, “QA가 테스트하기 힘든 엣지 케이스는 내가 단위 테스트로 미리 다 막아버리자!” 라는 마인드로 개발해야겠다. 어렵겠지만