Testable Code
테스트가 쉽게 작성되고, 유지보수와 확장이 용이한 코드
Testable Code란?
Testable Code는 테스트가 쉽게 작성되고, 유지보수와 실행이 용이한 코드를 의미한다.
즉, 코드의 동작을 다양한 테스트를 통해 검증할 수 있도록 작성된 코드이다.
테스트 가능한 코드는 일반적으로 더 이해하기 쉽고, 재사용 가능하며, 버그 발생 가능성이 적다.
Testable Code의 특징
1. 분리된 관심사 (Separation of Concerns)
- 코드가 하나의 책임(Single Responsibility Principle)을 가지며, 서로 독립적으로 동작한다.
- 한 컴포넌트가 변경되더라도 다른 컴포넌트에 영향을 주지 않는다.
- 특히 비즈니스 로직을 외부 시스템(데이터베이스, API 호출 등)과 분리하여 테스트 가능성을 높인다.
- 예시
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
public class OrderService { private final PaymentProcessor paymentProcessor; public OrderService(PaymentProcessor paymentProcessor) { this.paymentProcessor = paymentProcessor; } public boolean processOrder(Order order) { return paymentProcessor.process(order.getAmount()); } } // 결제 처리와 관련된 로직을 별도 클래스로 분리 // 외부 결제 시스템이라고 볼 수도 있음 public class PaymentProcessor { public boolean process(double amount) { // 실제 결제 처리 로직 // ... return amount > 0; } } @Test void testProcessOrder() { PaymentProcessor mockProcessor = mock(PaymentProcessor.class); when(mockProcessor.process(100.0)).thenReturn(true); OrderService orderService = new OrderService(mockProcessor); Order order = new Order(100.0); assertTrue(orderService.processOrder(order)); }
2. 의존성 주입 (Dependency Injection)
- 의존성을 내부에서 직접 생성하지 않고 외부에서 주입한다.
테스트 시 Mock 객체를 주입하여 독립적인 테스트가 가능하다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository = userRepository; // 의존성을 외부에서 주입 } public User findUserById(String userId) { return userRepository.findById(userId); } } @Test void testFindUserById() { UserRepository mockRepository = mock(UserRepository.class); UserService userService = new UserService(mockRepository); when(mockRepository.findById("123")).thenReturn(new User("123", "John")); User user = userService.findUserById("123"); assertEquals("John", user.getName()); }
3. 작은 함수와 클래스
- 하나의 함수 또는 클래스가 작고, 단일 책임을 가지도록 작성한다.
- 작은 단위로 작성된 코드는 개별 테스트가 쉽다.
- 예시
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
public class Calculator { public int add(int a, int b) { return a + b; // 단일 책임 } public int multiply(int a, int b) { return a * b; } } @Test void testAdd() { Calculator calculator = new Calculator(); assertEquals(5, calculator.add(2, 3)); } @Test void testMultiply() { Calculator calculator = new Calculator(); assertEquals(6, calculator.multiply(2, 3)); }
4. 상태와 입출력의 명확성
- 함수가 상태를 변경하지 않고, 명시적인 입력과 출력에 따라 동작한다. 즉, 함수가 내부 상태를 변경하지 않고, 결과를 반환하도록 작성한다.
이를 통해 함수가 외부 요소와 독립적으로 테스트 가능하다.
- 예시 1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
// bad case // 이 함수는 호출될 때마다 count라는 내부 상태를 변경합 // 이로 인해 함수의 동작이 호출 순서와 이전 호출 결과에 의존함 // 테스트 중 함수 호출 횟수나 순서에 따라 결과가 달라질 수 있음 public class Counter { private int count = 0; // 내부 상태 public int increment() { count += 1; // 내부 상태를 변경 return count; } } Counter counter = new Counter(); counter.increment(); // 결과: 1 counter.increment(); // 결과: 2 // good case // 이 함수는 입력값(currentCount)만 사용하여 결과를 계산하고 반환하며 // 내부 상태를 변경하지 않으므로 함수는 완전히 독립적이며 부작용이 없음 public class Counter { public int increment(int currentCount) { return currentCount + 1; // 입력값에만 의존하고 상태 변경 없음 } } Counter counter = new Counter(); int result1 = counter.increment(0); // 결과: 1 int result2 = counter.increment(1); // 결과: 2
- 예시 2
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
// bad case // 상태(lastPrice)를 변경하기 때문에 다음과 같은 문제가 발생 // 함수 호출 순서가 중요해짐 // 함수가 외부의 상태에 의존하며, 테스트가 어려워짐 public class DiscountCalculator { private double lastPrice; // 내부 상태 public void applyDiscount(double price, double discountRate) { lastPrice = price * (1 - discountRate); // 내부 상태 변경 } public double getLastPrice() { return lastPrice; } } // good case public class DiscountCalculator { public double applyDiscount(double price, double discountRate) { return price * (1 - discountRate); } } @Test void testApplyDiscount() { DiscountCalculator calculator = new DiscountCalculator(); assertEquals(90.0, calculator.applyDiscount(100.0, 0.1), 0.001); }
5. 테스트 더블의 활용
- Mock, Stub, Fake 객체를 사용하여 의존성을 대체하고, 독립적인 테스트 환경을 구축한다.
- 예시
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
public class InventoryService { public boolean isAvailable(String productId) { // 재고 조회 로직 // .... return true; } } // Mock을 사용한 테스트 @Test void testInventoryCheck() { InventoryService mockInventoryService = mock(InventoryService.class); when(mockInventoryService.isAvailable("product123")).thenReturn(true); boolean result = mockInventoryService.isAvailable("product123"); assertTrue(result); }
Testable Code의 장점
1. 높은 유지보수성
테스트 가능성이 높은 코드는 쉽게 수정되고 확장될 수 있다.
2. 낮은 결합도
의존성이 분리된 코드는 독립적으로 테스트와 수정이 가능하다.
3. 높은 신뢰성
각 단위가 독립적으로 테스트되므로, 예상치 못한 오류를 사전에 방지할 수 있다.
4. 리팩토링의 용이성
테스트가 보장되므로 코드 변경 시 기존 기능의 안정성을 유지할 수 있다.
결론
Testable Code는 단순히 “테스트를 작성할 수 있는 코드”가 아니라, 테스트가 쉽게 작성되고, 유지보수와 확장이 용이한 코드를 의미한다.
이를 위해 코드는 항상 단일 책임, 낮은 결합도, 명확한 입력과 출력을 갖추도록 설계되어야 한다.
테스트 가능한 코드를 작성함으로써, 개발자는 더 신뢰성 있는 소프트웨어를 빠르고 안정적으로 구축할 수 있다.
[출처] https://www.testrail.com/blog/highly-testable-code/
This post is licensed under CC BY 4.0 by the author.