Code/Test

[단위 테스트] 4-2. 좋은 단위 테스트의 4대 요소: 이상적인 테스트

noahkim_ 2025. 1. 24. 00:02

블라디미르 코리코프 님의 "단위 테스트" 책을 정리한 포스팅입니다.


3. 이상적인 테스트를 찾아서

테스트의 가치

  • 좋은 단위 테스트의 4대 특성을 곱하여 얻은 결과 (추정치)
  • 소수의 가치있는 테스트가 평범한 테스트보다 프로젝트 성장에 훨씬 더 효과적
  • 테스트 스위트에 테스트를 계속 둘지 여부를 결정할 수 있음
    • 임계치를 충족하는 테스트만 두어, 책임을 적절하게 맡도록 해야 함

 

이상적인 테스트를 만들 수 있는가?

  • 이상적인 테스트는 네 가지 특성 모두에서 최대 점수를 받는 테스트
  • 실제로는 세 가지 특성(회귀 방지, 리팩터링 내성, 빠른 피드백)이 서로 충돌함
  • 모두를 동시에 만족하는 것은 불가능함

 

핵심 전략
항목 설명
하나의 특성을 완전히 버릴 수 없다
하나를 버리면 곱셈 규칙(가치의 곱)에 의해 전체 테스트의 유용성이 0에 가까워짐
리팩터링 내성은 필수
이는 이진 속성이므로, 있거나 없거나이다 → 절대 포기할 수 없음
회귀 방지 & 빠른 피드백
이 둘은 정도의 차이로 조정 가능 (스펙트럼 상 위치 조정 가능)

 

트레이드 오프
특성 조합 설명
빠른 피드백을 희생 테스트 실행 속도가 느려짐
ex) 엔드 투 엔드(E2E) 테스트
회귀 방지를 희생 실제 기능 고장을 잘 검출하지 못함
ex) 너무 피상적인 테스트

 

4. 대중적인 테스트 자동화 개념 살펴보기

테스트 피라미드

  • 테스트의 종류를 위계 구조로 나눈 모델
  • 테스트 유형 간 일정한 비율의 균형을 맞추기 위한 전략 (기반은 넓고, 위로 갈수록 좁아지는 구조)
  • 비용과 성능간의 트레이드 오프
구분 단위 테스트(Unit) 통합 테스트(Integration)
E2E 테스트(End-to-End)
범위 코드의 최소 단위 (함수, 메서드) 여러 구성요소 간 상호작용 전체 시스템
속도 매우 빠름 중간 느림
안정성 매우 높음 보통 낮음
작성 난이도 낮음 중간 높음
비용 저렴 중간 비쌈
사용자 관점 낮음 중간 매우 높음

 

예시) 단위 테스트

더보기
@ExtendWith(MockitoExtension.class)
class PostServiceTest {

    @Mock
    private PostRepository postRepository;

    @InjectMocks
    private PostService postService;

    @Test
    void 게시글을_저장한다() {
        // Arrange (준비)
        Post post = new Post("제목", "내용");
        when(postRepository.save(any())).thenReturn(post);

        // Act (실행)
        Post saved = postService.save(post);

        // Assert (검증)
        assertEquals("제목", saved.getTitle());
        verify(postRepository).save(post);
    }
}
  • 하나의 클래스/메서드를 외부 의존성 없이 테스트

 

예시) 통합 테스트

더보기
@SpringBootTest
@AutoConfigureTestDatabase
@Transactional
class PostServiceIntegrationTest {

    @Autowired
    private PostService postService;

    @Autowired
    private PostRepository postRepository;

    @Test
    void 게시글을_저장하고_조회한다() {
        // Arrange
        Post post = new Post("제목", "내용");

        // Act
        postService.save(post);
        Post found = postRepository.findById(post.getId()).orElseThrow();

        // Assert
        assertEquals("제목", found.getTitle());
    }
}
  • 컴포넌트 간 연동이 제대로 이루어지는지 확인
  • 여러 컴포넌트를 함께 묶어 실제 환경과 유사하게 테스트

 

예시) E2E 테스트

더보기
@SpringBootTest
@AutoConfigureMockMvc
class PostControllerE2ETest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void 게시글을_등록하고_조회할_수_있다() throws Exception {
        // 게시글 등록
        mockMvc.perform(post("/posts")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {
                        "title": "제목",
                        "content": "내용"
                    }
                """))
                .andExpect(status().isCreated());

        // 게시글 목록 조회
        mockMvc.perform(get("/posts"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].title").value("제목"));
    }
}
  • 전체 시스템을 사용자의 관점에서 테스트 (UI/API 포함)
  • 실제 사용자 흐름이 잘 작동하는지 확인

 

블랙박스 테스트와 화이트박스 테스트 간의 선택

항목 블랙박스 테스트 화이트박스 테스트
작성자 테스터, QA 엔지니어
개발자
접근 방식 시스템의 내부 구조를 모른 채 테스트
내부 코드와 로직을 분석하여 테스트
기준 명세서, 요구사항 기반
소스코드 구조 기반
초점 기능 검증
구현 검증
리팩토링 내성 강함 (내부 코드 변경이 있어도 테스트 영향 적음)
약함 (내부 코드 변경에 따라 테스트가 쉽게 깨짐)
회귀 방지 효과 약함
강함 (코드 변경에 따른 오류 탐지에 유리)
사용 시점 외부 인터페이스를 기준으로 테스트할 때
코드 레벨 검토나 로직 검증이 필요할 때
사례 UI 테스트, API 응답 검증, 기능 요구사항 기반 테스트
조건문 분기 커버리지, 루프 검사, 경계값 테스트 등

 

예시) 블랙박스 테스트

더보기
@SpringBootTest
class UserServiceBlackBoxTest {

    @Autowired
    private UserService userService;

    @Autowired
    private UserRepository userRepository;

    @Test
    void 사용자는_정상적으로_회원가입할_수_있다() {
        // given
        String email = "test@example.com";
        String password = "secure123";

        // when
        userService.register(email, password);

        // then
        User savedUser = userRepository.findByEmail(email).orElseThrow();
        assertEquals(email, savedUser.getEmail());
        assertNotEquals(password, savedUser.getPassword()); // 암호화되었는지 확인
    }
}

 

예시) 화이트박스 테스트

더보기
class PasswordValidator {
    public boolean isValid(String password) {
        return password.length() >= 8 &&
               password.matches(".*[A-Z].*") &&
               password.matches(".*[a-z].*") &&
               password.matches(".*[0-9].*");
    }
}
class PasswordValidatorWhiteBoxTest {

    private final PasswordValidator validator = new PasswordValidator();

    @Test
    void 길이_8미만이면_false() {
        assertFalse(validator.isValid("Abc123"));
    }

    @Test
    void 대문자가_없으면_false() {
        assertFalse(validator.isValid("abcdefg1"));
    }

    @Test
    void 숫자가_없으면_false() {
        assertFalse(validator.isValid("Abcdefgh"));
    }

    @Test
    void 모든_조건을_만족하면_true() {
        assertTrue(validator.isValid("Abcdefg1"));
    }
}