개발하다 보면 항상 고민되는 게 있다. “이 코드가 제대로 작동할까?”
처음에는 그냥 실행해보고 에러가 없으면 되는 줄 알았다. 하지만 프로젝트가 커질수록, 팀이 커질수록 그런 방식으로는 한계가 명확해졌다.
“이 부분은 테스트해봤는데, 저 부분은 어떨까?” 이런 불안감이 계속 생긴다. 그럴 때 코드 커버리지라는 개념을 알게 되었다.
하지만 커버리지 100%가 정말 좋은 걸까? 숫자만 보고 판단하는 게 맞을까?
코드 커버리지는 테스트가 실제 코드의 얼마나 많은 부분을 실행했는지를 측정하는 지표다.
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public int divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("0으로 나눌 수 없습니다");
}
return a / b;
}
}
이 코드에서 add 메서드만 테스트했다면 커버리지는 50%가 된다. divide 메서드의 예외 처리 부분까지 테스트해야 100%가 된다.
실행된 코드 라인의 비율을 측정한다.
public void processUser(String name, int age) {
if (name == null) { // 라인 1
throw new IllegalArgumentException(); // 라인 2
}
if (age < 0) { // 라인 3
throw new IllegalArgumentException(); // 라인 4
}
// 실제 처리 로직 // 라인 5
System.out.println(name + " : " + age);
}
name이 null인 경우만 테스트하면 라인 1, 2, 5가 실행되어 60% 커버리지가 된다.
분기문을 얼마나 테스트했는지 측정한다.
public String getGrade(int score) {
if (score >= 90) {
return "A";
} else if (score >= 80) {
return "B";
} else if (score >= 70) {
return "C";
} else {
return "F";
}
}
각 조건문의 true/false 케이스를 모두 테스트해야 한다.
각 조건의 true/false를 테스트한다.
public boolean canVote(int age, boolean isCitizen) {
return age >= 18 && isCitizen;
}
age >= 18과 isCitizen 각각의 true/false 케이스를 테스트해야 한다.
널리 사용되는 Java 커버리지 도구다.
<!-- Maven 설정 -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.7</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
{
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage"
},
"devDependencies": {
"jest": "^27.0.0",
"@types/jest": "^27.0.0"
}
}
pip install coverage
coverage run -m pytest
coverage report
coverage html # HTML 리포트 생성
커버리지 100%가 항상 좋은 건 아니다. 잘못된 테스트로 인해 오히려 나쁠 수 있다.
public String processData(String input) {
// 복잡한 비즈니스 로직
if (input == null) {
return "default";
}
// 실제 중요한 로직
String result = complexBusinessLogic(input);
return result;
}
// 잘못된 테스트 - 커버리지만 채우려고 작성
@Test
public void testProcessData() {
String result = processData("test");
assertNotNull(result); // 의미 없는 테스트
}
이 테스트는 커버리지는 높지만 실제로 코드가 제대로 작동하는지 검증하지 못한다.
// 올바른 테스트
@Test
public void testProcessData_ValidInput() {
String result = processData("valid_input");
assertEquals("expected_result", result);
}
@Test
public void testProcessData_NullInput() {
String result = processData(null);
assertEquals("default", result);
}
@Test
public void testProcessData_EdgeCase() {
String result = processData("");
assertEquals("empty_result", result);
}
테스트는 단순히 버그를 찾기 위한 게 아니다. 더 중요한 건 코드의 의도를 명확히 하는 것이다.
// 테스트가 없는 코드
public class UserService {
public User createUser(String email, String password) {
// 복잡한 로직
User user = new User();
user.setEmail(email);
user.setPassword(hashPassword(password));
user.setCreatedAt(LocalDateTime.now());
return userRepository.save(user);
}
}
// 테스트가 있는 코드
@Test
public void testCreateUser_ValidInput() {
// Given
String email = "test@example.com";
String password = "password123";
// When
User result = userService.createUser(email, password);
// Then
assertEquals(email, result.getEmail());
assertNotNull(result.getPassword());
assertTrue(result.getPassword().startsWith("$2a$")); // 해시 확인
assertNotNull(result.getCreatedAt());
}
테스트를 보면 이 메서드가 무엇을 하는지, 어떤 결과를 기대하는지 명확해진다.
커버리지만으로는 품질을 판단할 수 없다. 여러 지표를 종합적으로 봐야 한다.
// 복잡도가 높은 메서드
public String processOrder(Order order) {
if (order == null) return "INVALID";
if (!order.isValid()) return "INVALID";
if (order.getStatus() == OrderStatus.CANCELLED) return "CANCELLED";
if (order.getPaymentStatus() != PaymentStatus.PAID) return "UNPAID";
if (order.getItems().isEmpty()) return "EMPTY";
// 실제 처리 로직
return "PROCESSED";
}
// 결합도가 높은 코드
public class OrderService {
private UserService userService;
private PaymentService paymentService;
private InventoryService inventoryService;
private NotificationService notificationService;
private EmailService emailService;
// ... 더 많은 의존성
}
// 응집도가 높은 코드
public class PasswordValidator {
public boolean isValid(String password) {
return hasMinLength(password) &&
hasUpperCase(password) &&
hasNumber(password);
}
}
한 번에 모든 것을 완벽하게 하려고 하면 실패한다. 점진적으로 개선해나가야 한다.
// 가장 중요한 비즈니스 로직부터
public class PaymentService {
public PaymentResult processPayment(PaymentRequest request) {
// 핵심 로직
if (request.getAmount() <= 0) {
return PaymentResult.failure("Invalid amount");
}
// 결제 처리
return processPaymentWithGateway(request);
}
}
@Test
public void testProcessPayment_EdgeCases() {
// 0원 결제
PaymentResult result1 = paymentService.processPayment(
new PaymentRequest(0));
assertFalse(result1.isSuccess());
// 음수 금액
PaymentResult result2 = paymentService.processPayment(
new PaymentRequest(-100));
assertFalse(result2.isSuccess());
// 매우 큰 금액
PaymentResult result3 = paymentService.processPayment(
new PaymentRequest(Integer.MAX_VALUE));
// 예상 결과에 따라 검증
}
@SpringBootTest
public class PaymentIntegrationTest {
@Autowired
private PaymentService paymentService;
@Test
@Transactional
public void testCompletePaymentFlow() {
// 실제 데이터베이스와 함께 테스트
PaymentRequest request = createValidPaymentRequest();
PaymentResult result = paymentService.processPayment(request);
assertTrue(result.isSuccess());
// 데이터베이스 상태도 확인
}
}
코드 리뷰할 때는 단순히 문법이나 스타일만 보는 게 아니다. 테스트가 제대로 있는지, 의미 있는 테스트인지 확인해야 한다. 커버리지가 적절한지도 보고, 복잡도가 높은 부분은 더 많은 테스트가 필요하다는 걸 알려줘야 한다.
# GitHub Actions 예시
name: Test and Coverage
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run tests with coverage
run: |
mvn test jacoco:report
- name: Check coverage threshold
run: |
# 커버리지 임계값 확인
mvn jacoco:check
이론적으로는 모든 코드를 완벽하게 테스트해야 한다. 하지만 현실은 다르다.
기존 코드에 테스트가 없다고 해서 모든 것을 다시 작성할 수는 없다.
// 레거시 코드
public class LegacyService {
public String processLegacyData(String data) {
// 복잡하고 테스트하기 어려운 코드
// 하지만 현재 잘 작동하고 있음
return processedData;
}
}
// 점진적 개선 방법
public class LegacyServiceWrapper {
private LegacyService legacyService;
public String processData(String data) {
// 입력 검증 추가
if (data == null || data.trim().isEmpty()) {
throw new IllegalArgumentException("Data cannot be empty");
}
// 기존 로직 실행
String result = legacyService.processLegacyData(data);
// 결과 검증 추가
if (result == null) {
throw new RuntimeException("Processing failed");
}
return result;
}
}
코드 커버리지는 품질 관리의 도구일 뿐이다. 목적이 되어서는 안 된다.
진짜 중요한 건 의미 있는 테스트를 작성하는 것이다. 테스트가 코드의 의도를 명확히 하고, 변경 시 안전장치 역할을 해야 한다.
커버리지 100%를 달성하는 것보다, 핵심 로직을 확실하게 테스트하고, 팀이 함께 품질을 개선해나가는 문화를 만드는 게 더 중요하다.
숫자에 매몰되지 말고, 실제로 코드가 더 안전하고 유지보수하기 쉬워지는지에 집중해야 한다.