Ezcho

[소프트웨어 테스트] 좋은 테스트, 나쁜 테스트 본문

Java

[소프트웨어 테스트] 좋은 테스트, 나쁜 테스트

Ezcho 2026. 1. 13. 14:54

1. 소프트웨어 테스트

Java 개발은 다양한 환경에서 돌아갈 수 있습니다.

  • Tomcat WAS
  • Spring Boot JAR
  • Docker 컨테이 너
  • Kubernetes 클러스터
  • 콘솔 기반 모듈

하지만 테스트코드는 항상 Junit으로 작성되어야 합니다. 운영환경이 무엇이든, 개발 도메인이 무엇이든 비즈니스 로직을 검증하는 단위 테스트 방법은 변하지 않습니다.

1. 소프트웨어 공학에서 Test 란?

소프트웨어 테스트란 소프트웨어가 요구사항을 만족하는지 확인하고, 결함(버그)을 조기에 발견하기 위해 수행하는 활동입니다.

  • 결함발견
  • Verification
  • Validation
  • 신뢰성 확보
  • 유지보수 비용 저감

2. Error, Defeat, Failure의 차이

  • Error: 개발자가 잘못 생각하거나 잘못 구현한 사람의 실수
  • Defeat: 코드 내부에 존재하는 잘못된 로직/오류
  • Failure: 결제 페이지에서의 가격이 마이너스로 나옴

예시

  • Error: “할인 가격 계산을 잘못 설계함”
  • Defect: discountRate = 150% 같은 코드 오류
  • Failure: 결제 페이지에서 가격이 마이너스로 나옴

3. Verification vs Validation

  • 검증(Verification)은, 설계도 대로 집이 지어지고 있는가를 확인하는 가정이고, Validation은 집이 지어진 이후, 집에 사람이 살 수 있는가를 확인하는 과정이다.

개념 질문 때

Verification (검증) "우리는 제대로 만들고 있는가?" 개발 과정 중
Validation (확인) "우리가 만든 것이 맞는 것인가?" 제품 완성 후

Junit 테스트는 Verification에 강하게 대응하는 과정임.

4. 테스트 레벨(Test Levels)

소프트웨어를 테스트할 때는 범위에 따라 단계가 나뉜다.

단위 테스트(Unit Test)

  • 가장 작은 단위(클래스, 함수)를 테스트
  • JUnit으로 작성하는 것
  • 가장 빠르고 자동화하기 쉬움

통합 테스트(Integration Test)

  • 모듈/컴포넌트가 상호작용할 때 정상 동작하는지 확인

시스템 테스트(System Test)

  • 전체 시스템이 요구사항을 만족하는지 검사

인수 테스트(Acceptance Test)

  • 사용자/고객이 정의한 시나리오를 만족하는지 테스트

→ 4가지의 테스트를 모두 경험해보는것이 수업의 목표

4. 테스트 기법(Test Design Techniques)

화이트박스 테스트(White-box Testing)

  • 내부 로직을 알고 있는 상태에서 테스트
  • 개발자 관점
  • 단위 테스트에서 사용됨

블랙박스 테스트(Black-box Testing)

  • 내부 구현을 모르고 입력/출력만 보고 테스트
  • QA, 사용자 관점
  • 예: 동등 분할, 경계값 분석

회귀 테스트(Regression Test)

  • 변경 후 기존 기능이 깨지지 않았는지 확인

테스트 단계별 도구

테스트 레벨 목적 주요 도구

단위 테스트 함수/클래스 단위 검증 JUnit, Mockito
통합 테스트 모듈 간 연동 검증 SpringBootTest, TestRestTemplate
시스템 테스트 시스템 전체(E2E) 검증 Selenium, Cypress, REST Assured, Postman
인수 테스트 비즈니스 요구사항 중심 테스트 Cucumber, Karate, Spring REST Docs

단위 테스트 → 코드가 맞나?

시스템 테스트 → 시스템 전체가 돌아가나?

인수 테스트 → 고객/사용자가 원하는 기능을 만족하나?

라고 생각하면 좋음

단위 테스트(Unit Test)

할인 계산 함수가 10% 할인 시 정확한 값을 반환하는가?

시스템 테스트(System Test)

상품 등록 → 장바구니 → 결제 전체 플로우가 오류 없이 동작하는가?

인수 테스트(Acceptance Test)

"회원이 특정 조건을 만족하면 첫 구매 시 5천원 쿠폰을 자동 지급한다"
라는 비즈니스 요구사항이 실제로 만족되는가?

그럼 테스트 코드를 작성하고 설계하는 과정은 언제 일어나야 할까?

TDD (Test-Driven Development) → 코드보다 테스트를 먼저 작성하는 방식

Test-Last → 코드를 작성한 뒤 테스트를 작성하는 방식

정답은 존재하지않으나

핵심 비즈니스 로직에 대해서 TDD로 설계하고, 나머지는 코드 작성 후 Test-after로 설계한다.


2. 좋은 테스트의 특징

1. 좋은 테스트의 특징

국내외 SW공학 교재에서 필수로 등장

  • 독립성(Independent)
  • 반복 가능(Repeatable)
  • 신뢰성(Reliable)
  • 명확성(Clear)
  • 자동화 가능(Automatable)
  • 빠름(Fast)

2. 테스트 가능한 코드(Testable Code)의 조건

  • 함수는 하나의 책임만
  • I/O 분리
  • 순수 함수(Pure Function) 활용
  • 의존성 주입(DI)
  • Mock 가능하게 설계
  • static 의존성 최소화
  • 전역 변수/싱글톤 지양
  • 서비스와 로직 분리

단일 책임 원칙

→ 하나의 함수/클래스는 하나의 책임만 가져야 한다

//로직 + DB 저장 + 이메일 전송까지 한 메서드가 다 함
createOrderAndSaveDBAndSendEmail();

//책임을 분리
createOrder();
orderRepository.save();
emailService.send();

DI, Dependency Injection

→ 클래스가 직접 객체를 생성하는것이 아닌 외부에서 주입받아야 한다.

테스트환경에서는 Mock객체를 넣어야 하는데 내부에서 new로 생성하면 교체가 불가능하다.

//나쁜 예
OrderService {
    private EmailService email = new EmailService();
}

//좋은 예
OrderService {
    private final EmailService email;
    public OrderService(EmailService email) {
        this.email = email;
    }
}

I/O를 핵심 로직에서 분리

비즈니스 로직은 순수해야 하며, 입출력은 별도 계층에서 처리해야한다.

테스트 시 DB/네트워크 등 작업이 있으면 느리고 불안정함. 핵심 로직만 처리해야함

1번과 동일한 내용 중 하나

테스트를 위해 입출력(외부 시스템 접근)을 비즈니스 로직에서 분리해야 한다는 개념

외부 IO(DB, 파일, 네트워크, 시간 등)는 테스트에서 Mock으로 대체 가능해야 함

Pure Function, 순수함수로 만들기.

//좋은 예: 순수 함수
int add(int a, int b) { return a + b; }

//나쁜 예: 부수효과가 있어 테스트 어려움
int add(int a, int b) {
    log("called");
    totalCount++;   // 전역 변수를 변경
    return a + b;
}

테스트 간 상황파악이 명확해진다. 예측가능하다는 장점

  • 전역변수 참조는 불가피한데 어떻게 대처하냐?
    //전역 변수
    public static int count;
    
    //인스턴스 상태로 변경
    private int count;
    
    public void increase() { count++; }
    
    
    클래스 생성자로 DI 하면 된다.즉, 테스트에서는 Mock 형태로 주입가능하게 동작하면 된다.
  • new UserService(mockRepo);
  • // 전역 Repository static UserRepository repo = new UserRepository(); // DI 적용 private final UserRepository repo; public UserService(UserRepository repo) { this.repo = repo; }
  • 아래와 같이 인스턴스 상태로 변경하거나.

전역상태(Global State)를 지양

전역 변수나 싱글톤 값이 바뀌면 테스트 케이스 간 간섭이 발생함

  • 모든 테스트는 독립적이어야 하고, 전역변수 변경 간 간섭 발생 시 → 버그 발생 → 예측 불가
//나쁜 예: 전역 카운트
static int count;

public int func_a(){
	 count++;
	 return count;
}

public int func_b(){
	 count++;
	 return count;
}

시간, 난수 등 예측 불가 요소를 분리 또는 주입

“현재 시간” / “랜덤 값” / “UUID” 등은 테스트하기 어렵다.

//내부에서 매번 다른 시간 생성 -> 예측 불가능
LocalDateTime.now();

//Clock을 주입 -> 주입 가능한 형태로 항상 변경할 것
LocalDateTime.now(clock);

public int func_b(int randomNum,Date clock){
	 int number = randomNum;
	 LocalDateTime.now(clock);
	 return number+10;
}

Static 사용 최소화

전역과 동일한 의미이며, 전역적으로 공유되면 테스트독립성이 깨지고 Mock이 불가능하다.

public static int count = 0; //매우 위험

public static void print() { ... }

public static final int MAX = 100; //괜찮음

public static int add(int a, int b) { return a + b; } //Not bad
//예제 준비

private static final Logger log = LoggerFactory.getLogger(MyClass.class);
//로그객체 정의 ㄱㅊ

외부 시스템 의존성 최소화

  • 네트워크, 외부API, 파일 등은 단위시스템에서 악성요소. 비즈니스 로직만 철저하게 분리 필요

Exception을 명확하게 던지고 처리할 것

  • 애매한 null 반환 대신 명확한 예외 또는 Result 객체를 처리할 것
//Bad
return null;

//
throw new Exception();

//Good
throw new InvalidUserException();

//테스트
assertThrows(InvalidUserException.class, () -> service.login("invalid"));

3. Testable vs Not Testable


사례 1

코드 1

public class UserService {

    public String createUser(String name) {
        UserRepository repo = new UserRepository();
        long timestamp = System.currentTimeMillis();
        int id = (int) (Math.random() * 10000);
        User user = new User(id, name, timestamp);
        repo.save(user);
        return "OK";
    }

//단일 책임 원칙 위배, 
//의존성 주입 원칙 위배(DIP)
//CurrentTime, Random
  • UserRepository를 Mock으로 바꿀 수 없음 → 의존성 주입 원칙 위반
  • System.currentTimeMillis() 때문에 항상 값이 달라짐 → 외부 환경에 의존
  • Math.random() 때문에 결과 예측이 불가능 → 외부 환경 의존
  • 단위 테스트 힘듦 → 단일 책임 위반 → 하나의 함수가 여러 역할을 수행함

코드 2

public interface UserRepository {
    void save(User user);
}

public interface TimeProvider {
    long now();
}

public interface IdGenerator {
    int generate();
}
public class UserService {

    private final UserRepository repo;
    private final TimeProvider timeProvider;
    private final IdGenerator idGenerator;

    public UserService(UserRepository repo, TimeProvider timeProvider, IdGenerator idGenerator) {
        this.repo = repo;
        this.timeProvider = timeProvider;
        this.idGenerator = idGenerator;
    }

    public User createUser(String name) {
        int id = idGenerator.generate();
        long timestamp = timeProvider.now();

        User user = new User(id, name, timestamp);
        repo.save(user);

        return user;
    }
}

테스트 코드 예시

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

class UserServiceTest {

    @Test
    void createUser_shouldCreateUserCorrectly() {
        // --- 1. Mock 객체 생성 ---
        UserRepository repo = mock(UserRepository.class);
        TimeProvider timeProvider = mock(TimeProvider.class);
        IdGenerator idGenerator = mock(IdGenerator.class);

        // --- 2. Mock 동작 정의 ---
        when(idGenerator.generate()).thenReturn(1234);
        when(timeProvider.now()).thenReturn(999999L);

        // --- 3. 테스트 대상(Service) 생성 ---
        UserService service = new UserService(repo, timeProvider, idGenerator);

        // --- 4. 테스트 실행 ---
        User user = service.createUser("Alice");

        // --- 5. 결과 검증 ---
        assertEquals(1234, user.getId());
        assertEquals("Alice", user.getName());
        assertEquals(999999L, user.getCreatedAt());

        // --- 6. Repository 동작 검증 ---
        verify(repo, times(1)).save(any(User.class));
    }
}

사례2 ->  문제점 찾기

코드 1

public class UserService {
    public User create() {
        int id = IdGenerator.generate(); // static 함수 사용
        return new User(id);
    }
}

public class IdGenerator {
    public static int generate() {
        return (int)(Math.random() * 10000);
    }
}
 

문제점

  • static 함수 사용하여 Mock 불가능
  • static 함수가 일관된 값(상수) 를 뱉으면 문제가 없지만, 항상 random 함
  • 테스트가 Non Deterministic 함 → 우리는 테스트의 결과를 알 수 없음

코드 2: 한번 생각해보세요!

public class GlobalConfig {
    public static String mode = "production";
}

public class UserService {
    public void work() {
        if (GlobalConfig.mode.equals("production")) {
            // ...
        }
    }
}

'Java' 카테고리의 다른 글

[Java] Thread와 상태제어  (1) 2022.11.25
[Java] Thread  (0) 2022.11.25
[java] java IO - Byte단위 입출력  (0) 2022.11.16
[java] 어노테이션  (0) 2022.11.09
[java] Date, Calendar클래스, Time 패키지  (0) 2022.11.09
Comments