FIRST 원칙으로 바라본 테스트 코드 작성법

2026. 1. 30. 17:33·스프링

0. 들어가며

테스트 코드를 처음 작성하기 시작했을 때는 “테스트가 통과하는지” 자체에만 집중을 했던것 같습니다.
기능이 정상적으로 동작하는지를 확인할 수만 있다면 그 테스트 코드는 충분히 역할을 하고 있다고 생각했기 때문입니다.

하지만 테스트 코드의 양이 늘어나고 프로젝트가 점점 커질수록 테스트 코드 역시 유지보수의 대상이 되었습니다.

 


이 과정에서 어떤 테스트는 수정이 부담스럽고 어떤 테스트는 실패 원인을 파악하는 데 오히려 시간을 더 쓰게 되는 경험을 하였습니다.  이러한 경험을 하고 잘 작성된 테스트코드의 필요성을 알게 되었고 좋은 테스트 코드를 작성하기 위한 FIRST 원칙을 알게 되었습니다. 


이 글에서는 좋은 테스트 코드의 기준으로 자주 언급되는 FIRST 원칙을 중심으로 테스트 코드를 어떤 관점에서 작성해야 하는지 정리해 보겠습니다.

 


1. FIRST 원칙이란?

FIRST 원칙은 좋은 테스트 코드가 가져야 할 다섯 가지 특성을 정리한 개념입니다.
각 특성의 앞 글자를 따서 FIRST라는 이름으로 불리며 테스트 코드의 품질을 판단하는 하나의 기준으로 활용됩니다.

FIRST는 다음 다섯 가지 요소로 구성됩니다.

  • Fast
  • Independent
  • Repeatable
  • Self-Validating
  • Timely

이 원칙들은 테스트 프레임워크나 언어에 국한되지 않으며 단위 테스트를 작성할 때 공통적으로 고려해야 할 기준이라고 볼 수 있습니다.


2. Fast - 빠른 테스트가 좋은 테스트다

FIRST의 첫 번째 원칙은 Fast, 빠름입니다. 테스트는 빠르게 실행되어야 합니다. "빠르다"는 것이 정확히 얼마나 빠른 것을 의미할까요? 명확한 기준은 없지만, 일반적으로 단위 테스트는 밀리초 단위로 실행되어야 하고, 전체 테스트 스위트는 몇 분 안에 완료되어야 합니다.

 

왜 테스트가 빠라야 할까요? 테스트가 느리면 개발자들이 테스트를 실행하지 않게 됩니다. 코드를 조금 수정할 때마다 테스트를 돌려보는 것이 TDD의 핵심인데, 테스트가 10분씩 걸린다면 이것이 불가능합니다. 결국 테스트를 건너뛰게 되고, 버그가 늦게 발견되며, 수정 비용이 커집니다.

테스트가 느린 가장 흔한 이유는 외부 의존성입니다. 데이터베이스, 파일 시스템, 네트워크, 외부 API 등 I/O 작업은 메모리 연산에 비해 압도적으로 느립니다.

 

@Test
public void 사용자_조회_테스트() {
    // 실제 DB에 연결
    User user = new User("test@email.com", "홍길동");
    userRepository.save(user);  // DB INSERT: 100ms
    
    // 조회
    User found = userRepository.findByEmail("test@email.com");  // DB SELECT: 50ms
    
    assertThat(found.getName()).isEqualTo("홍길동");
    
    // 정리
    userRepository.delete(user);  // DB DELETE: 50ms
    // 총 200ms... 테스트가 1000개면 200초
}

이런 테스트를 빠르게 만들려면 외부 의존성을 제거해야 합니다. Mock 객체나 Stub을 사용하여 실제 데이터베이스를 대신합니다.

 

@Test
public void 사용자_조회_테스트() {
    // Mock Repository
    UserRepository mockRepository = mock(UserRepository.class);
    User user = new User("test@email.com", "홍길동");
    when(mockRepository.findByEmail("test@email.com")).thenReturn(Optional.of(user));
    
    UserService userService = new UserService(mockRepository);
    
    // 조회
    User found = userService.findByEmail("test@email.com");  // 메모리 연산: <1ms
    
    assertThat(found.getName()).isEqualTo("홍길동");
    // 총 <1ms... 테스트가 1000개여도 1초 미만
}

이제 테스트는 순수하게 메모리에서만 동작합니다. 데이터베이스 연결도, 네트워크 호출도, 파일 I/O도 없습니다. 밀리초 단위로 실행됩니다.

 

하지만 모든 외부 의존성을 제거할 수 있는 것은 아닙니다. 통합 테스트나 E2E 테스트는 실제 데이터베이스와 외부 시스템을 사용해야 합니다. 이런 경우에는 테스트를 계층화하는 것이 중요합니다.

 

단위 테스트는 빠르게 실행되고 자주 돌립니다. 통합 테스트는 상대적으로 느리지만 덜 자주 실행합니다. E2E 테스트는 가장 느리지만 배포 전에만 실행합니다. 이렇게 피라미드 구조로 테스트를 구성하면, 대부분의 피드백을 빠른 단위 테스트에서 받을 수 있습니다.

 

// 단위 테스트: 매우 빠름, 자주 실행
@Test
public void 주문_금액_계산_테스트() {
    Order order = new Order();
    order.addItem(new OrderItem(10000, 2));  // 20000원
    order.addItem(new OrderItem(5000, 1));   // 5000원
    
    assertThat(order.getTotalAmount()).isEqualTo(25000);
    // 실행 시간: <1ms
}

// 통합 테스트: 느림, 덜 자주 실행
@SpringBootTest
@Transactional
public void 주문_생성_통합_테스트() {
    User user = userRepository.save(new User("test@email.com"));
    Product product = productRepository.save(new Product("상품", 10000));
    
    Order order = orderService.createOrder(user.getId(), product.getId(), 1);
    
    assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
    // 실행 시간: 100-500ms
}

// E2E 테스트: 매우 느림, 배포 전 실행
@Test
public void 주문_전체_플로우_테스트() {
    // 브라우저 자동화
    webDriver.get("/login");
    webDriver.findElement(By.id("email")).sendKeys("test@email.com");
    // ... 전체 플로우 테스트
    // 실행 시간: 수 초
}

 

 

테스트를 빠르게 만드는 또 다른 방법은 불필요한 설정을 제거하는 것입니다. 특히 스프링 부트 테스트에서 @SpringBootTest를 남발하면 매번 전체 애플리케이션 컨텍스트를 로드하느라 시간이 오래 걸립니다.

// 느린 테스트
@SpringBootTest
public class OrderServiceTest {
    @Autowired
    private OrderService orderService;
    
    @Test
    public void 테스트() {
        // 전체 스프링 컨텍스트 로드: 5-10초
    }
}

// 빠른 테스트
@ExtendWith(MockitoExtension.class)
public class OrderServiceTest {
    @Mock
    private UserRepository userRepository;
    @Mock
    private ProductRepository productRepository;
    
    @InjectMocks
    private OrderService orderService;
    
    @Test
    public void 테스트() {
        // 필요한 객체만 생성: <10ms
    }
}

필요한 최소한의 의존성만 로드하는 것이 중요합니다. 서비스 레이어만 테스트한다면 전체 웹 레이어를 로드할 필요가 없습니다. Repository 테스트라면 @DataJpaTest를 사용하여 JPA 관련 설정만 로드합니다.

빠른 테스트는 개발자에게 즉각적인 피드백을 제공합니다. 코드를 수정하고 몇 초 안에 결과를 확인할 수 있습니다. 이것이 TDD를 가능하게 하고, 리팩토링에 대한 자신감을 주며, 버그를 빠르게 발견할 수 있게 합니다.


3. Independent - 테스트는 서로 독립적이어야 한다

FIRST의 두 번째 원칙은 Independent, 독립성입니다.

각 테스트는 다른 테스트에 의존하지 않고 독립적으로 실행되어야 합니다. 테스트의 실행 순서가 바뀌어도 결과는 같아야 하고, 어떤 테스트를 건너뛰어도 다른 테스트에 영향을 주면 안 됩니다.

 

 

테스트 간 의존성이 생기면 하나의 테스트 실패가 연쇄적인 실패로 이어질 수 있고 실제 원인을 파악하는 데 많은 시간이 소요됩니다. 따라서 테스트는 실행 순서와 무관하게 항상 동일한 결과를 낼 수 있도록 작성되어야 하며 공유 상태를 최소화하는 것이 중요합니다.

 

예시 코드를 보며 알아봅시다.

public class OrderServiceTest {
    
    private static User testUser;  // 공유 상태
    
    @BeforeAll
    public static void setup() {
        testUser = new User("test@email.com", "홍길동");
        testUser.setBalance(100000);
    }
    
    @Test
    public void 주문_생성_테스트() {
        Order order = orderService.createOrder(testUser.getId(), productId, 1);
        testUser.setBalance(testUser.getBalance() - order.getTotalAmount());
        
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
        // testUser의 잔액이 변경됨
    }
    
    @Test
    public void 잔액_확인_테스트() {
        assertThat(testUser.getBalance()).isEqualTo(100000);
        // 이전 테스트에서 잔액이 변경되었다면 실패
        // 실행 순서에 따라 결과가 달라짐
    }
}

이런 테스트는 매우 불안정합니다. 테스트를 하나씩 실행하면 성공하지만 전체를 실행하면 실패할 수 있습니다. 실행 순서에 따라 결과가 달라지므로 어떤 날은 성공하고 어떤 날은 실패합니다. 이런 테스트를 "Flaky Test"라고 하는데  신뢰할 수 없는 테스트의 대표적인 예입니다.

 

독립적인 테스트를 만들려면 각 테스트가 자신만의 데이터를 가져야 합니다. 테스트 시작 시점에 필요한 데이터를 준비하고, 테스트 종료 시점에 정리합니다.

 

public class OrderServiceTest {
    
    @Test
    public void 주문_생성_테스트() {
        // 이 테스트만의 데이터 준비
        User testUser = new User("test@email.com", "홍길동");
        testUser.setBalance(100000);
        
        Order order = orderService.createOrder(testUser.getId(), productId, 1);
        
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
        // 다른 테스트에 영향 없음
    }
    
    @Test
    public void 잔액_확인_테스트() {
        // 이 테스트만의 데이터 준비
        User testUser = new User("test2@email.com", "김철수");
        testUser.setBalance(100000);
        
        assertThat(testUser.getBalance()).isEqualTo(100000);
        // 항상 독립적으로 실행됨
    }
}

데이터베이스를 사용하는 통합 테스트에서는 격리가 더 중요합니다. 한 테스트에서 INSERT한 데이터가 다른 테스트에 영향을 주지 않도록 해야 합니다.

 

@SpringBootTest
@Transactional  // 각 테스트 후 롤백
public class OrderServiceIntegrationTest {
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private OrderService orderService;
    
    @Test
    public void 주문_생성_테스트() {
        User user = userRepository.save(new User("test@email.com"));
        
        Order order = orderService.createOrder(user.getId(), productId, 1);
        
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
        
        // @Transactional로 인해 테스트 종료 후 자동 롤백
        // 다음 테스트는 깨끗한 상태에서 시작
    }
    
    @Test
    public void 다른_테스트() {
        // 이전 테스트의 데이터는 존재하지 않음
        // 독립적으로 실행됨
    }
}

@Transactional 어노테이션을 테스트 클래스에 붙이면, 각 테스트 메서드가 트랜잭션 안에서 실행되고 테스트가 끝나면 자동으로 롤백됩니다. 이렇게 하면 각 테스트는 항상 깨끗한 데이터베이스 상태에서 시작합니다.

 

 

하지만 @Transactional을 사용할 수 없는 경우도 있습니다. 예를 들어 비동기 처리나 멀티 스레드 환경에서는 트랜잭션이 제대로 동작하지 않을 수 있습니다. 이런 경우에는 테스트 데이터를 명시적으로 정리해야 합니다.

 

@SpringBootTest
public class AsyncOrderServiceTest {
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private OrderRepository orderRepository;
    
    private User testUser;
    
    @BeforeEach
    public void setup() {
        testUser = userRepository.save(new User("test@email.com"));
    }
    
    @AfterEach
    public void cleanup() {
        orderRepository.deleteAll();
        userRepository.deleteAll();
    }
    
    @Test
    public void 비동기_주문_처리_테스트() throws Exception {
        // 테스트 로직
        // cleanup()에서 데이터 정리됨
    }
}

테스트 간 의존성의 또 다른 형태는 실행 순서 의존성입니다. A 테스트가 먼저 실행되어야 B 테스트가 성공하는 경우입니다.

 

// 나쁜 예: 실행 순서에 의존
public class UserServiceTest {
    
    @Test
    @Order(1)
    public void 사용자_생성_테스트() {
        User user = userService.create("test@email.com");
        // DB에 저장되고 ID를 받음
    }
    
    @Test
    @Order(2)
    public void 사용자_조회_테스트() {
        // 이전 테스트에서 생성된 사용자를 조회
        User user = userService.findByEmail("test@email.com");
        assertThat(user).isNotNull();
        // 순서가 바뀌면 실패
    }
}

이런 방식은 매우 위험합니다. 테스트 프레임워크가 순서를 보장하지 않을 수 있고, 일부 테스트만 실행하면 실패합니다. 각 테스트는 자신이 필요한 모든 것을 스스로 준비해야 합니다.

 

 

// 좋은 예: 각 테스트가 독립적
public class UserServiceTest {
    
    @Test
    public void 사용자_생성_테스트() {
        User user = userService.create("test1@email.com");
        assertThat(user.getId()).isNotNull();
    }
    
    @Test
    public void 사용자_조회_테스트() {
        // 이 테스트를 위한 데이터 준비
        User savedUser = userService.create("test2@email.com");
        
        User user = userService.findByEmail("test2@email.com");
        assertThat(user).isNotNull();
        assertThat(user.getId()).isEqualTo(savedUser.getId());
    }
}

독립적인 테스트는 병렬로 실행할 수 있습니다. 서로 영향을 주지 않으므로, 여러 테스트를 동시에 실행하여 전체 테스트 시간을 줄일 수 있습니다. 테스트가 수천 개가 되면 이것은 큰 차이를 만듭니다.

또한 독립적인 테스트는 디버깅이 쉽습니다. 테스트가 실패하면 그 테스트만 실행해서 원인을 찾을 수 있습니다. 다른 테스트를 함께 실행할 필요가 없으므로, 문제를 빠르게 격리하고 해결할 수 있습니다.

 


4. Repeatable - 언제 어디서나 같은 결과를 보장해야 한다

FIRST의 세 번째 원칙은 Repeatable, 반복 가능성입니다. 같은 테스트를 여러 번 실행해도 항상 같은 결과가 나와야 합니다. 오늘 성공한 테스트는 내일도 성공해야 하고, 내 컴퓨터에서 성공한 테스트는 동료의 컴퓨터에서도, CI 서버에서도 성공해야 합니다.

 

같은 결과가 나오지 않는다면 개발자에게 신뢰를 주지 못합니다. 

시간, 랜덤 값, 외부 시스템 상태에 의존하는 테스트는 Repeatable 원칙을 깨뜨리기 쉽습니다.
이러한 요소들은 고정된 값으로 대체하거나 테스트 대상에서 분리하여 제어할 수 있어야 합니다.

반복 실행해도 항상 같은 결과가 나오는 테스트만이 안정적인 테스트라고 할 수 있습니다.

 

예제 코드를 보며 더 자세히  알아보겠습니다.

public class CouponService {
    
    public boolean isValid(Coupon coupon) {
        LocalDateTime now = LocalDateTime.now();  // 현재 시간에 의존
        return coupon.getExpiryDate().isAfter(now);
    }
}

@Test
public void 쿠폰_유효성_테스트() {
    Coupon coupon = new Coupon("SALE2025", LocalDateTime.of(2025, 12, 31, 23, 59));
    
    boolean valid = couponService.isValid(coupon);
    
    assertThat(valid).isTrue();
    // 2025년 12월 31일 이전에는 성공
    // 2026년 1월 1일 이후에는 실패
}

이 테스트는 실행 시점에 따라 결과가 달라집니다. 오늘은 성공하지만 내일은 실패할 수 있습니다. 이것은 반복 가능하지 않은 테스트입니다.

해결 방법은 시간을 주입받도록 변경하는 것입니다. 프로덕션 코드에서는 실제 시간을 사용하고, 테스트에서는 고정된 시간을 주입합니다.

 

public class CouponService {
    
    private final Clock clock;
    
    public CouponService(Clock clock) {
        this.clock = clock;
    }
    
    public boolean isValid(Coupon coupon) {
        LocalDateTime now = LocalDateTime.now(clock);  // 주입받은 Clock 사용
        return coupon.getExpiryDate().isAfter(now);
    }
}

@Test
public void 쿠폰_유효성_테스트() {
    // 고정된 시간으로 Clock 생성
    Clock fixedClock = Clock.fixed(
        LocalDateTime.of(2025, 6, 1, 0, 0).toInstant(ZoneOffset.UTC),
        ZoneOffset.UTC
    );
    
    CouponService couponService = new CouponService(fixedClock);
    Coupon coupon = new Coupon("SALE2025", LocalDateTime.of(2025, 12, 31, 23, 59));
    
    boolean valid = couponService.isValid(coupon);
    
    assertThat(valid).isTrue();
    // 언제 실행해도 2025년 6월 1일 기준으로 검증
    // 항상 같은 결과
}

이제 테스트는 언제 실행해도 같은 결과를 냅니다. 시간을 제어할 수 있게 되면서, 과거나 미래의 시나리오도 테스트할 수 있습니다.

 

 

 

@Test
public void 만료된_쿠폰_테스트() {
    // 쿠폰 만료일 이후로 시간 설정
    Clock fixedClock = Clock.fixed(
        LocalDateTime.of(2026, 1, 1, 0, 0).toInstant(ZoneOffset.UTC),
        ZoneOffset.UTC
    );
    
    CouponService couponService = new CouponService(fixedClock);
    Coupon coupon = new Coupon("SALE2025", LocalDateTime.of(2025, 12, 31, 23, 59));
    
    boolean valid = couponService.isValid(coupon);
    
    assertThat(valid).isFalse();
}

랜덤 값도 마찬가지입니다. 랜덤 값에 의존하는 코드는 테스트하기 어렵습니다.

 

public class OrderNumberGenerator {
    
    public String generate() {
        return "ORD-" + UUID.randomUUID().toString();
    }
}

@Test
public void 주문번호_생성_테스트() {
    String orderNumber = generator.generate();
    
    assertThat(orderNumber).startsWith("ORD-");
    // UUID는 매번 다름
    // 정확한 값을 검증할 수 없음
}

이것도 랜덤 생성기를 주입받도록 변경하면 해결됩니다.

 

public class OrderNumberGenerator {
    
    private final Supplier<UUID> uuidSupplier;
    
    public OrderNumberGenerator(Supplier<UUID> uuidSupplier) {
        this.uuidSupplier = uuidSupplier;
    }
    
    public String generate() {
        return "ORD-" + uuidSupplier.get().toString();
    }
}

@Test
public void 주문번호_생성_테스트() {
    UUID fixedUuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174000");
    OrderNumberGenerator generator = new OrderNumberGenerator(() -> fixedUuid);
    
    String orderNumber = generator.generate();
    
    assertThat(orderNumber).isEqualTo("ORD-123e4567-e89b-12d3-a456-426614174000");
    // 항상 같은 값
}

외부 API에 대한 의존도 문제입니다. 외부 API는 응답 시간이 일정하지 않고, 가끔 실패하며, 데이터가 변경될 수 있습니다.

 

public class WeatherService {
    
    public String getCurrentWeather(String city) {
        // 외부 날씨 API 호출
        WeatherApiResponse response = weatherApiClient.get("/weather?city=" + city);
        return response.getDescription();
    }
}

@Test
public void 날씨_조회_테스트() {
    String weather = weatherService.getCurrentWeather("Seoul");
    
    assertThat(weather).isEqualTo("맑음");
    // 실제 날씨에 따라 결과가 달라짐
    // 네트워크가 불안정하면 실패
}

이런 테스트는 신뢰할 수 없습니다. Mock을 사용하여 외부 API를 대체해야 합니다.

 

@Test
public void 날씨_조회_테스트() {
    WeatherApiClient mockClient = mock(WeatherApiClient.class);
    WeatherApiResponse mockResponse = new WeatherApiResponse("맑음", 25);
    when(mockClient.get("/weather?city=Seoul")).thenReturn(mockResponse);
    
    WeatherService weatherService = new WeatherService(mockClient);
    String weather = weatherService.getCurrentWeather("Seoul");
    
    assertThat(weather).isEqualTo("맑음");
    // 항상 같은 결과
    // 외부 API에 의존하지 않음
}

 

 

테스트 환경도 중요합니다. 테스트가 특정 환경에서만 동작하면 반복 가능하지 않습니다. 예를 들어 절대 경로를 하드코딩하면, 다른 개발자의 컴퓨터에서는 실패할 수 있습니다.

// 나쁜 예
@Test
public void 파일_읽기_테스트() {
    File file = new File("/Users/myname/project/test.txt");
    // 다른 사용자의 컴퓨터에서는 경로가 다름
}

// 좋은 예
@Test
public void 파일_읽기_테스트() {
    File file = new File(getClass().getResource("/test.txt").getFile());
    // 클래스패스 기준으로 찾음
    // 어디서든 동일하게 동작
}

 

 

데이터베이스 상태도 문제가 될 수 있습니다. 테스트가 특정 데이터가 있다고 가정하면, 데이터베이스를 초기화하지 않은 상태에서는 실패합니다.

 

@Test
public void 관리자_조회_테스트() {
    // DB에 admin@example.com 사용자가 있다고 가정
    User admin = userRepository.findByEmail("admin@example.com");
    assertThat(admin.getRole()).isEqualTo(Role.ADMIN);
    // DB가 깨끗한 상태면 실패
}

 

 

테스트가 필요한 데이터는 테스트가 직접 준비해야 합니다.

 

@Test
public void 관리자_조회_테스트() {
    // 테스트가 필요한 데이터를 직접 준비
    User admin = userRepository.save(new User("admin@example.com", Role.ADMIN));
    
    User found = userRepository.findByEmail("admin@example.com");
    assertThat(found.getRole()).isEqualTo(Role.ADMIN);
}

반복 가능한 테스트는 신뢰를 줍니다. 테스트가 실패하면 코드에 문제가 있다는 것을 확신할 수 있습니다. 환경 때문이거나, 운이 나빠서가 아니라, 실제로 버그가 있는 것입니다. 이런 신뢰가 있어야 테스트를 진지하게 받아들이고, 리팩토링을 자신 있게 할 수 있습니다.

 


5. Self-Validating - 스스로 성공과 실패를 판단해야 한다

FIRST의 네 번째 원칙은 Self-Validating, 자가 검증입니다

 

좋은 테스트 코드는 사람이 결과를 해석하지 않아도 성공과 실패가 명확해야 합니다.
출력 로그를 눈으로 확인해야 하는 테스트는 자동화된 테스트의 장점을 제대로 활용하지 못한 경우입니다.

테스트는 명확한 assertion을 통해 기대 결과와 실제 결과를 비교해야 하며실패 시에도 어떤 부분이 잘못되었는지 드러나야 합니다.

 

Self-Validating 원칙은 테스트를 “자동화된 검증 도구”로 만드는 핵심 요소라고 느껴집니다.

 

 

테스트가 자가 검증되지 않으면 어떤 문제가 생길까요? 테스트 결과를 해석하는 데 시간이 걸리고, 사람마다 다르게 해석할 수 있으며, 자동화된 CI/CD 파이프라인에서 사용할 수 없습니다.

 

예시 코드를 보며 더 자세히 알아보겠습니다.

// 나쁜 예: 수동 확인 필요
@Test
public void 주문_생성_테스트() {
    Order order = orderService.createOrder(userId, productId, 1);
    
    System.out.println("Order ID: " + order.getId());
    System.out.println("Status: " + order.getStatus());
    System.out.println("Amount: " + order.getTotalAmount());
    
    // 사람이 직접 출력 결과를 보고 판단해야 함
    // 성공인지 실패인지 자동으로 알 수 없음
}

 

이런 테스트는 자동화할 수 없습니다. 누군가 출력 결과를 읽고 "이게 맞는 건가?"를 판단해야 합니다. 테스트가 수백 개가 되면 불가능합니다.

테스트는 명확한 단언(assertion)을 포함해야 합니다. 기대하는 결과를 명시하고 실제 결과와 비교하여 자동으로 성공/실패를 판단해야 합니다.

 
 
// 좋은 예: 자동 검증
@Test
public void 주문_생성_테스트() {
    Order order = orderService.createOrder(userId, productId, 1);
    
    assertThat(order.getId()).isNotNull();
    assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
    assertThat(order.getTotalAmount()).isEqualTo(10000);
    
    // 모든 조건이 맞으면 성공
    // 하나라도 틀리면 실패
    // 자동으로 판단 가능
}
 

이제 테스트는 스스로 결과를 판단합니다. 사람의 개입이 필요 없습니다. CI 서버에서 실행되어도 정확하게 성공/실패를 알 수 있습니다.

 

 

단언문은 구체적이고 명확해야 합니다. 모호한 단언은 테스트의 의도를 흐리게 만듭니다.

// 모호한 단언
@Test
public void 할인_적용_테스트() {
    Order order = orderService.createOrder(userId, productId, 1);
    orderService.applyDiscount(order, "SALE10");
    
    assertThat(order.getTotalAmount()).isGreaterThan(0);
    // 할인이 제대로 적용되었는지 알 수 없음
    // 단지 금액이 양수라는 것만 확인
}

// 명확한 단언
@Test
public void 할인_적용_테스트() {
    Product product = new Product("상품", 10000);
    Order order = orderService.createOrder(userId, product.getId(), 1);
    
    orderService.applyDiscount(order, "SALE10");
    
    assertThat(order.getTotalAmount()).isEqualTo(9000);
    // 10% 할인이 정확히 적용되었는지 확인
}

 

 

예외 처리도 명확하게 검증해야 합니다. 예외가 발생하는 것이 정상적인 동작이라면, 그것을 명시적으로 테스트해야 합니다.

@Test
public void 재고_부족시_예외_발생() {
    Product product = new Product("상품", 10000);
    product.setStock(0);
    
    assertThatThrownBy(() -> {
        orderService.createOrder(userId, product.getId(), 1);
    })
    .isInstanceOf(InsufficientStockException.class)
    .hasMessage("재고가 부족합니다");
    
    // 예외 타입과 메시지까지 정확히 검증
}

 

 

테스트는 하나의 개념만 검증해야 합니다. 여러 개념을 한 테스트에서 검증하면, 실패했을 때 어느 부분이 문제인지 알기 어렵습니다.

// 나쁜 예: 여러 개념을 한 번에 검증
@Test
public void 주문_전체_프로세스_테스트() {
    // 사용자 생성
    User user = userService.create("test@email.com");
    assertThat(user.getId()).isNotNull();
    
    // 상품 생성
    Product product = productService.create("상품", 10000);
    assertThat(product.getStock()).isEqualTo(100);
    
    // 주문 생성
    Order order = orderService.createOrder(user.getId(), product.getId(), 1);
    assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
    
    // 결제 처리
    paymentService.process(order);
    assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID);
    
    // 실패하면 어느 부분이 문제인지 불명확
}

// 좋은 예: 각 개념을 별도로 검증
@Test
public void 주문_생성_테스트() {
    Order order = orderService.createOrder(userId, productId, 1);
    assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
}

@Test
public void 결제_처리_테스트() {
    Order order = createTestOrder();
    paymentService.process(order);
    assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID);
}

각 테스트가 하나의 개념만 검증하면, 실패했을 때 정확히 무엇이 문제인지 즉시 알 수 있습니다. 테스트 이름만 봐도 어떤 기능이 깨졌는지 파악할 수 있습니다.

 

 

테스트 실패 메시지도 명확해야 합니다. 실패 원인을 빠르게 파악할 수 있도록, 충분한 정보를 제공해야 합니다.

// 불충분한 정보
assertThat(order.getTotalAmount() == 9000).isTrue();
// 실패 시: expected: <true> but was: <false>
// 실제 값이 얼마였는지 알 수 없음

// 충분한 정보
assertThat(order.getTotalAmount()).isEqualTo(9000);
// 실패 시: expected: <9000> but was: <10000>
// 기대값과 실제값을 모두 알 수 있음

// 더 나은 정보
assertThat(order.getTotalAmount())
    .as("10% 할인 적용 후 금액")
    .isEqualTo(9000);
// 실패 시: [10% 할인 적용 후 금액] expected: <9000> but was: <10000>
// 컨텍스트까지 함께 제공

자가 검증되는 테스트는 개발자의 시간을 절약합니다. 테스트가 실패하면 즉시 문제를 파악하고 수정할 수 있습니다. 로그를 뒤지거나, 디버거를 붙이거나, 수동으로 확인할 필요가 없습니다.

이것이 테스트를 신뢰할 수 있게 만들고, CI/CD 파이프라인을 자동화할 수 있게 합니다.

 


6. 테스트는 적절한 시점에 작성되어야 한다

FIRST의 마지막 원칙은 Timely, 적시성입니다. 테스트는 적절한 시점에 작성되어야 합니다. 가장 좋은 시점은 프로덕션 코드를 작성하기 직전입니다. 이것이 바로 TDD(Test-Driven Development)의 핵심입니다.

 

 

왜 테스트를 먼저 작성해야 할까요?

왜 테스트를 먼저 작성해야 할까요? 테스트를 나중에 작성하면 여러 문제가 생깁니다. 첫째, 테스트하기 어려운 코드를 이미 작성한 후라서, 테스트를 위해 큰 리팩토링이 필요할 수 있습니다.
둘째, 이미 동작하는 코드를 보면서 테스트를 작성하면, 테스트가 코드를 검증하는 것이 아니라 코드를 그대로 따라가게 됩니다.
셋째, 시간에 쫓기다 보면 테스트를 나중으로 미루고, 결국 작성하지 않게 됩니다.

저 또한 기능 구현을 하고 테스트를 작성한 경험이 있는데요, 이러한 이유 때문에 테스트 코드를 짜기 싫었던 적이 있었습니다.

 

 

예제 코드를 보며 비교해 봅시다.

// 프로덕션 코드를 먼저 작성 (TDD가 아님)
public class OrderService {
    
    public Order createOrder(Long userId, Long productId, int quantity) {
        User user = userRepository.findById(userId).orElseThrow();
        Product product = productRepository.findById(productId).orElseThrow();
        
        // 복잡한 비즈니스 로직
        if (product.getStock() < quantity) {
            throw new InsufficientStockException();
        }
        
        int totalAmount = product.getPrice() * quantity;
        
        if (user.getVipLevel() >= 3) {
            totalAmount = (int)(totalAmount * 0.9);
        }
        
        product.setStock(product.getStock() - quantity);
        
        Order order = new Order(user, product, quantity, totalAmount);
        orderRepository.save(order);
        
        emailService.sendOrderConfirmation(user, order);
        
        return order;
    }
}

// 나중에 테스트 작성 시도
@Test
public void 주문_생성_테스트() {
    // 어디서부터 테스트해야 할지 막막함
    // 이미 복잡하게 얽힌 의존성
    // Mock을 많이 만들어야 함
    // 테스트하기 어려운 구조
}

TDD는 이런 문제를 예방합니다. 테스트를 먼저 작성하면, 테스트하기 쉬운 코드를 자연스럽게 만들게 됩니다.

 

// 1단계: 실패하는 테스트 작성 (Red)
@Test
public void 주문_생성시_재고_감소() {
    // Given
    Product product = new Product("상품", 10000, 10);
    
    // When
    Order order = orderService.createOrder(userId, product.getId(), 3);
    
    // Then
    assertThat(product.getStock()).isEqualTo(7);
}

// 2단계: 테스트를 통과하는 최소한의 코드 작성 (Green)
public Order createOrder(Long userId, Long productId, int quantity) {
    Product product = productRepository.findById(productId).orElseThrow();
    product.decreaseStock(quantity);  // 간단하고 명확
    return new Order(userId, productId, quantity);
}

// 3단계: 리팩토링 (Refactor)
// 코드를 개선하되, 테스트는 계속 통과해야 함

TDD의 사이클은 Red-Green-Refactor입니다. 먼저 실패하는 테스트를 작성하고(Red), 테스트를 통과하는 최소한의 코드를 작성하고(Green), 코드를 개선합니다(Refactor). 이 과정을 반복하면서 기능을 완성해 나갑니다.

 

 

이렇게 하면 테스트 가능한 코드가 자연스럽게 만들어집니다. 테스트를 먼저 생각하므로, 의존성을 어떻게 주입받을지, 메서드를 어떻게 분리할지, 어떤 인터페이스가 필요한지를 미리 고민하게 됩니다.

 

// TDD로 작성한 코드는 테스트하기 쉬운 구조
public class OrderService {
    
    private final UserRepository userRepository;
    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;
    private final DiscountPolicy discountPolicy;
    private final EmailService emailService;
    
    // 의존성 주입으로 테스트 가능
    public OrderService(UserRepository userRepository,
                       ProductRepository productRepository,
                       OrderRepository orderRepository,
                       DiscountPolicy discountPolicy,
                       EmailService emailService) {
        this.userRepository = userRepository;
        this.productRepository = productRepository;
        this.orderRepository = orderRepository;
        this.discountPolicy = discountPolicy;
        this.emailService = emailService;
    }
    
    public Order createOrder(Long userId, Long productId, int quantity) {
        User user = findUser(userId);
        Product product = findProduct(productId);
        
        validateStock(product, quantity);
        
        int totalAmount = calculateAmount(user, product, quantity);
        
        product.decreaseStock(quantity);
        
        Order order = new Order(user, product, quantity, totalAmount);
        orderRepository.save(order);
        
        sendConfirmation(user, order);
        
        return order;
    }
    
    // 각 책임을 분리하여 테스트하기 쉽게
    private User findUser(Long userId) {
        return userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException());
    }
    
    private Product findProduct(Long productId) {
        return productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException());
    }
    
    private void validateStock(Product product, int quantity) {
        if (product.getStock() < quantity) {
            throw new InsufficientStockException();
        }
    }
    
    private int calculateAmount(User user, Product product, int quantity) {
        int baseAmount = product.getPrice() * quantity;
        return discountPolicy.apply(user, baseAmount);
    }
    
    private void sendConfirmation(User user, Order order) {
        emailService.sendOrderConfirmation(user, order);
    }
}

 

 

하지만 Timely는 TDD만을 의미하지 않습니다. 프로덕션 코드와 함께 테스트를 작성하는 것도 적시입니다. 중요한 것은 테스트를 나중으로 미루지 않는 것입니다.

 

 

"일단 기능을 만들고 나중에 테스트를 추가하자"는 생각은 위험합니다. 대부분의 경우 그 "나중"은 오지 않습니다. 마감에 쫓기고, 다른 급한 일이 생기면서, 테스트는 계속 미뤄집니다.

 

 

// 나쁜 습관
// TODO: 나중에 테스트 작성
public Order createOrder(Long userId, Long productId, int quantity) {
    // 복잡한 로직...
    // 테스트 없이 배포
    // 버그가 프로덕션에서 발견됨
}

테스트를 먼저 또는 함께 작성하면, 버그를 개발 단계에서 발견할 수 있습니다. 프로덕션에 배포된 후 발견하는 것보다 수정 비용이 훨씬 적습니다.

 

또한 테스트를 작성하는 과정에서 요구사항을 명확히 이해하게 됩니다. "이 기능이 정확히 무엇을 해야 하는가?"를 테스트 코드로 표현하면서, 모호했던 부분이 분명해집니다.

 

 

// 테스트를 먼저 작성하면서 요구사항 명확화
@Test
public void VIP_고객은_10퍼센트_할인() {
    // Given
    User vipUser = new User("vip@email.com", VipLevel.GOLD);
    Product product = new Product("상품", 10000);
    
    // When
    Order order = orderService.createOrder(vipUser.getId(), product.getId(), 1);
    
    // Then
    assertThat(order.getTotalAmount()).isEqualTo(9000);
    // VIP 할인이 정확히 10%인지 명확히 정의됨
}

@Test
public void 일반_고객은_할인_없음() {
    // Given
    User normalUser = new User("normal@email.com", VipLevel.NORMAL);
    Product product = new Product("상품", 10000);
    
    // When
    Order order = orderService.createOrder(normalUser.getId(), product.getId(), 1);
    
    // Then
    assertThat(order.getTotalAmount()).isEqualTo(10000);
    // 일반 고객의 케이스도 명확히 정의됨
}

레거시 코드를 다룰 때는 테스트를 먼저 작성할 수 없습니다. 이미 코드가 있으니까요. 이런 경우에는 리팩토링하기 전에 테스트를 추가하는 것이 적시입니다.

 

// 레거시 코드 (테스트 없음)
public void processOrder(Order order) {
    // 복잡한 레거시 로직
    // 수정하기 두려움
}

// 1단계: 현재 동작을 검증하는 테스트 작성
@Test
public void 기존_동작_검증() {
    Order order = createTestOrder();
    processOrder(order);
    // 현재 동작을 그대로 테스트
    // 리팩토링 전 안전망 확보
}

// 2단계: 안전하게 리팩토링
// 테스트가 깨지지 않는 선에서 코드 개선

테스트는 기능을 추가할 때만 작성하는 것이 아닙니다. 버그를 수정할 때도 테스트를 먼저 작성해야 합니다. 버그를 재현하는 테스트를 작성하고, 그 테스트를 통과하도록 코드를 수정합니다.

 

// 버그 리포트: 재고가 0인데도 주문이 생성됨

// 1단계: 버그를 재현하는 테스트 작성
@Test
public void 재고_0일때_주문_불가() {
    Product product = new Product("상품", 10000, 0);
    
    assertThatThrownBy(() -> {
        orderService.createOrder(userId, product.getId(), 1);
    }).isInstanceOf(InsufficientStockException.class);
    
    // 현재는 실패 (버그 존재)
}

// 2단계: 코드 수정
public Order createOrder(Long userId, Long productId, int quantity) {
    Product product = findProduct(productId);
    
    if (product.getStock() < quantity) {  // 이 검증이 빠져있었음
        throw new InsufficientStockException();
    }
    
    // ...
}

// 3단계: 테스트 통과 확인
// 같은 버그가 다시 발생하지 않도록 보장

이렇게 하면 같은 버그가 다시 발생하는 것을 방지할 수 있습니다. 테스트가 회귀(regression)를 막는 안전망 역할을 합니다.


마치며

이번 글에서는 좋은 테스트 코드가 가져야 할 다섯 가지 특성인 FIRST 원칙에 대해  살펴보았습니다.

 

FIRST 원칙을 정리하면서 느낀 점은 이 원칙들이 단순히 테스트 코드만을 위한 규칙이 아니라좋은 설계를 유도하는 기준이라는 점이었습니다. 빠른 테스트를 위해 의존성을 줄이고독립적인 테스트를 위해 책임을 분리하다 보면 자연스럽게 코드 구조 역시 개선되는 경우가 많았던것 같습니다.

 

또한 테스트를 언제, 어떤 방식으로 작성하느냐에 따라 테스트 코드에 대한 인식 자체가 달라진다는 점도 인상 깊었습니다.
테스트를 사후 검증 도구로 바라볼 때와 개발을 이끄는 도구로 바라볼 때의 차이는 생각보다 컸습니다

 

FIRST 원칙은 테스트 코드를 “잘 작성하기 위한 규칙”이라기보다는 테스트 코드를 통해 코드를 어떻게 바라볼 것인가에 대한 관점을 제시해 준다고 느껴집니다. 앞으로 테스트 코드를 작성할 때마다 이 원칙을 기준으로 한 번 더 점검해 보려 합니다.

 

저작자표시 비영리 (새창열림)

'스프링' 카테고리의 다른 글

Mockito 핵심 개념 Mock, Stub, Spy(feat : JUnit5)  (0) 2026.02.16
스프링 트랜잭션(@Transactional) 전파레벨 7가지  (0) 2026.02.13
Spring Validation 완전 정복 - 올바른 유효성 검증 전략  (0) 2026.01.29
스프링에서 스코프(Scope)란 무엇인가?  (0) 2026.01.19
스프링을 스프링답게 만드는 진짜 이유: IoC·AOP·PSA 삼각형의 정체  (0) 2026.01.18
'스프링' 카테고리의 다른 글
  • Mockito 핵심 개념 Mock, Stub, Spy(feat : JUnit5)
  • 스프링 트랜잭션(@Transactional) 전파레벨 7가지
  • Spring Validation 완전 정복 - 올바른 유효성 검증 전략
  • 스프링에서 스코프(Scope)란 무엇인가?
깊은바다속꼬북이
깊은바다속꼬북이
  • 깊은바다속꼬북이
    CodeBlossom
    깊은바다속꼬북이
  • 전체
    오늘
    어제
    • 분류 전체보기 (53) N
      • 라이징 캠프 (4)
      • 객채지향 개발론 (3)
      • 스프링 (10) N
      • 네트워크 (2)
      • 자바 (16)
      • 자료구조 (3)
      • 운영체제 (0)
      • 데이터베이스 (4)
      • 디자인패턴 (7)
      • JSP (1)
      • 개발 알쓸신잡 (2)
      • 일반 교양 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    MySQL 옵티마이저
    개발 교훈
    jit-compiler
    JUnnit5
    프로그램밍 언어
    mockito라이브러리
    java 버전별 특징
    전략 패턴(Strategy Pattern)
    spring
    junnit5프레임워크
    트랜잭션 전파레벨
    자바 Socket 클래스
    GC
    JVM
    jvm 클래스 로더
    디자인 패턴
    개발 철학
    자료구조
    한국어 검색
    객체지향
    java data area
    개발자 철학
    싱글턴 패턴(Singleton Pattern)
    어댑터 패턴(Adapter Pattern)
    템플릿 메서드 패턴(Template Method Pattern)
    MySQL 파서
    스프링
    디자인패턴
    java
    백엔드
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
깊은바다속꼬북이
FIRST 원칙으로 바라본 테스트 코드 작성법
상단으로

티스토리툴바