모두가 작성하지 않지만 코드 품질을 위하여..

현재 프로젝트에서 사용하고 있는 기술스택은 … 이러한 이유로 테스트 코드를 작성하기로 결심했다. 어떤 테스트 코드를 작성해야할까..? 결론은 slice 테스트가 유일한 길이었다. 현재 service, controller 레이어만 테스트를 작성한 상태 service -> Mock controller -> Mock mapper -> 통합테스트를 적용하여 개발 db에 붙여보기 도전중..

Mock, Spy.. 그리고

Dao 테스트

현재 Mybatis 중에서도 dao 방식으로 데이터에 접근하도록 구축되어 있다. 하지만 여러 의존성 이슈..

MSA로 구성되었다.. 하지만 모노리식, 멀티모듈 구조라는 표현이 더 적합하다고 생각한다.

메인 모듈에서 모든 설정과 의존성 주입된다. 서브모듈을 라이브러리 형식으로 main애서 implement하여 사용한다. 이러한 문제로 config나 주입을 하기위해 여러가지를 고민해야 했다.

  1. 데이터에 접근하는 공통 dao 설정만 따로 분리 -> 시도중
  2. main 모듈을 testImplement하여 사용 -> 실패

목차

테스트 코드를 작성하며 겪게된 여래 시행착오와 배웠던 점을 간단하게 정리해보려고 한다. 본 프로젝트에서 사용하는 스택과 구조에 대하여 간단하게 설명을 하고 테스트에 대하여 알아보자.

  • Jdk 17.0.14
  • Junit 5.10.5
  • Mybatis3.0.3
  • Spring-boot 3.3.8 (>canalFrame)

위 스펙을 사용하여 멀티모듈 구조로 이루어져 있다. 예를 들어 A, B, C라는 도메인들이 있고 하나의 도메인 A에서 B, C를 implement하여 사용하고 있는 구조이다. A -> B, C

즉, 모든 설정에 관련된 정보는 메인 모듈 A에 의존적이다. 이 구조로 SpringBootApplication이 A 모듈에만 존재하기 때문에 B, C 모듈에서는 이 의존성을 주입해줄 방법이 없었다.

더 쉽게 말해 datasource를 통하여 DB Connection을 찾아가야 하는데 datasource에 관련된 설정은 A 모듈에만 존재하기 때문에 B, C 모듈에서는 새로운 설정을 할 필요했다.


테스트 방식 선정 단계

테스트를 작성하기 전에 Mock사용하여 단위테스트를 진행할지 통합 테스트를 하는것이 좋을까?

이 고민은 초기에 Application Context에 Bean을 등록할 수 없는 문제로 종결되었다. 단위테스트를 하기로 마음을 먹었으며, 단위 테스트를 하게 될때 장점은 아래와 같다.

단위테스트는 단순한 오류를 찾는 것이 아니다. 각각의 컴포넌트, 즉 단위 별로 독립적으로 잘 동작을 하는지 검증하는 것이다. 각각의 단위를 조립 했을때 유기적으로 코드가 흘러가는 것을 가정하며 진행한다.

단위테스트 VS. 통합테스트

단위테스트

  • 하나의 컴포넌트를 기준으로 독립적으로 진행되는 가장 작은 단위의 테스트
  • 컴포넌트는 하나의 기능 또는 메소드
  • 어떤 기능이 실행되면 어떤 결과가 나오도록 테스트하는 것

통합테스트

  • 모듈을 통합하는 과정에서 모듈 간의 호환성을 확인하는 테스트
    • A service를 호출하는 B Service와 각각 database에서 값을 읽어와 상호작용하여 기댓값을 잘 봔환하는지 확인
  • 모든 통합된 컴포넌트들이 연계되어 동작하는지 검증
단위테스트의 문제점

구조적으로 1개의 애플리케이션은 또 다른 1개의 객체와 메시지를 주고 받아야한다. 앞서 한개의 컴포넌트 단위로 테스트를 독립적으로 테스트 해야하기 때문에 다른 객체를 호출할때 문제가 있을 수 있다. 이러한 경우 Stubbing을 통하여 문제를 해결할 수 있다.


JUnit

given-when-then

강의나 대부분의 실무에서 해당 패턴을 활용하여 테스트 코드를 작성한다.

패턴 given - 어떤 데이터가 준비되었을때 - 준비 when - 테스트할 컴포넌트를 실행하면 - 실행 then - 어떠한 결과가 나올것 - 검증 해당 패턴을 활용하여 개발을 진행했으며 간혹 verify를 통하여 호출 횟수를 검증하기도 했다.

예시코드

@Test  
@DisplayName("01_판매번호_종합조회_기본조회")  
void searchSalesNo() {  
    // given  
    SalesNoLtgrSttusResDto expected =  
            SalesNoLtgrSttusResDto.builder()  
                    .coCd(CO_CD)  
                    .outwhSalNo(SAL_NO)  
                    .build();  
  
  
    given(userContext.getCoCd()).willReturn(CO_CD);  
    given(commonDao.selectList("salesNoLtgrSttusService.searchSalesNo", reqDto)).willReturn(List.of(expected));  
    //when  
  
    when(salesNoLtgrSttusService.searchSalesNo(reqDto)).thenReturn(List.of(expected));  
  
    //then  
    assertThat(salesNoLtgrSttusService.searchSalesNo(reqDto)).isEqualTo(List.of(expected));  
    verify(commonDao).selectList("salesNoLtgrSttusService.searchSalesNo", reqDto);  
}

테스트를 위하여 필요한 함수가 별도로 있으면 테스트 코드 내부에 선언하여 사용해도 괜찮다. spring-test와 junit-jupiter 각각의 역할을 제대로 구분짓고 가보려고 한다.



JUnit 5와 Spring의 역할 구분
JUnit 5 (junit-jupiter)
  • 테스트 프레임워크
  • 테스트 실행과 생명주기 관리
    Spring (@SpringBootTest@ContextConfiguration)
  • 애플리케이션 컨텍스트 관리
  • 의존성 주입 및 테스트 컨텍스트 초기화

이 두 역할을 명확히 구분할 필요가 있다. 특히 테스트 컨텍스트 초기화 시점과 테스트 실행 시점의 차이는 중요한 개념이다.

예를 들어, Jasypt와 같은 암호화 알고리즘을 활용해 yml 파일에 암호화된 정보를 저장하고 이를 복호화하여 DataSource에 등록하려 할 때, 복호화 과정이 DataSource 초기화 시점보다 우선되어야 한다.

하지만 JUnit 5의 테스트 실행 순서는 Spring 컨텍스트 초기화와 시점 차이가 발생할 수 있다. 특히 HikariCP 초기화 시점과 Spring 컨텍스트에서 StringEncryptor 빈 등록 시점이 엇갈리면, DataSource가 ENC(...) 값을 그대로 사용ORA-01017: invalid username/password와 같은 오류가 발생할 수 있다.

뿐만아니라 공부를 하면서 학습한 내용으로 테스트코드에서는 생성자 주입으로 의존성을 주입하기 위해서는 @TestContructor라는 별도의 어노테이션을 작성해줘야 한다.

예시코드

// SpringExtension.class 내부
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {  
    Parameter parameter = parameterContext.getParameter();  
    Executable executable = parameter.getDeclaringExecutable();  
    Class<?> testClass = extensionContext.getRequiredTestClass();  
    PropertyProvider junitPropertyProvider = (propertyName) -> {  
        return (String)extensionContext.getConfigurationParameter(propertyName).orElse((Object)null);  
    };  
    // 테스트
    return TestConstructorUtils.isAutowirableConstructor(executable, testClass, junitPropertyProvider) || ApplicationContext.class.isAssignableFrom(parameter.getType()) || this.supportsApplicationEvents(parameterContext) || ParameterResolutionDelegate.isAutowirable(parameter, parameterContext.getIndex());  
}


// TestConstructorUtils.class
/**
* 아래 @TestConstructor가 있다면 supportsParameter를 통하여 생성자 주입을 가능하게 한다.
*/
public static boolean isAutowirableConstructor(Constructor<?> constructor, Class<?> testClass, @Nullable PropertyProvider fallbackPropertyProvider) {  
    if (isAnnotatedWithAutowiredOrInject(constructor)) {  
        return true;  
    } else {  
        TestConstructor testConstructor = (TestConstructor)TestContextAnnotationUtils.findMergedAnnotation(testClass, TestConstructor.class);  
        TestConstructor.AutowireMode autowireMode;  
        if (testConstructor != null) {  
            autowireMode = testConstructor.autowireMode();  
        } else {  
            String value = SpringProperties.getProperty("spring.test.constructor.autowire.mode");  
            autowireMode = AutowireMode.from(value);  
            if (autowireMode == null && fallbackPropertyProvider != null) {  
                value = fallbackPropertyProvider.get("spring.test.constructor.autowire.mode");  
                autowireMode = AutowireMode.from(value);  
            }  
        }  
        return autowireMode == AutowireMode.ALL;  
    }  
}

  • Spring의 @Autowired와 JUnit 5의 ParameterResolver는 서로 다른 목적과 방식으로 동작한다.
  • Spring은 필드 주입(@Autowired) 을 주로 사용하며, JUnit 5의 ParameterResolver는 메서드 매개변수 주입을 위한 것이다.
  • JUnit 5 테스트에서 @ExtendWith(SpringExtension.class)와 함께 Spring 컨텍스트에서 일부 매개변수 주입이 가능하다.

실제로 설정정보에 관한 의존성을 주입해주기 위해서는 다음과 같은 코드를 사용했습니다.

@SpringBootTest(classes = {TestConfiguration.class})  
@Import({JasyptConfig.class, TestConfig.class})  
@Slf4j  
@ActiveProfiles("test")  
@RequiredArgsConstructor // 생성자 생성
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)  
class DITest {

	
	private final ApplicationContext applicationContext;  
	
	// spring이 생성자 주입을 해주는 대상
	private final  CommonDao commonDao;

	@Test  
	void 의존성_주입_테스트() {  
	    //given  
	    String[] beanDefinitionNames = applicationContext.getBeanDefinitionNames();  
	    for (String beanDefinitionName : beanDefinitionNames) {  
	        log.debug("[TEST DEBUG] beanDefinitionName : {}", beanDefinitionName);  
	    }  
	    //when-then  
	    assertThat(applicationContext.getBeanNamesForType(CommonDao.class)).isNotEmpty();  
	}
	
	...

}


테스트 코드 실행 결과 ![[Pasted image 20250214132947.png]]

Test를 하기위한 Context만 Load되는것을 실행 화면에서 확인할 수 있다.





Test Double(테스트 대역)

위 세가지 모두 테스트에서 의존성이나 외부 객체 동작을 제어하거나 검증하기 위해 사용하는 Test Double(테스트 대역)이다.

  • 테스트 대역은 실제 객체를 대체하지만, 완전히 동일한 기능을 제공하는 것이 아닌 특정 목적에 맞게 동작을 변경하는 것이다.

| 종류 | 실제 객체 대신하는 방식 | | ——– | —————– | | Stub | 고정된 값을 반환하여 출력 제어 | | Mock | 메서드 호출 여부 검증 | | Spy | 실제 객체 + 일부 동작 조작 |

테스트 대역을 사용하는 이유

단위 테스트를 하고 싶다.

Service 작성하여 test 시

class SalLdgrServiceTest {  
    
    private SalLdgrService salLdgrService;

	@Test
	void createSalLdgr() {
		// 전역 사용자 생성
		UserContext userContext = new UserContext();
		// 공용 dao 객체 생성
		CommonDao commonDao = new CommonDao(userContext);
		// salLdgrService 생성
		salLdgrService(commonDao);
		
		SalLdgrRequestDto reqDto;

		//원장 생성
		salLdgrService.createSalLdgr(reqDto);
	}


위와 같은 특정 Service를 테스트 하기 위해서는 하단에 조건이 충족 되어야 한다.

  • RDB Connection 세팅
  • RDB에 로직 테스트 조건에 맞는 데이터 세팅
  • CommonDao에서 사용 SqlSessionFactory 생성 및 주입
  • createSalLdgr 테스트 이후에 test Data rollback 등..

모두 작성하지 않았지만 고려해야할 내용이 너무 많다. 만약 더 복잡한 로직이라면 추가해줘야할 의존성과 설정이 너무 많기 때문에 slice 테스트를 진행하는 것이 유리한 경우가 꽤 있다. 뿐만 아니라 위와 같은 설정과 정보를 가져오는데 속도 저하는 피할 수 없다.

Service 메서드 관점에서 DB나 여러 정보에 대하여 관심이 딱히 없다.

우리가 검증해야 하는 주된 내용은

  • createSalLdgr이 1회 정상 호출 됐는지
  • createSalLdgr 실행시에 DuplicateException과 같은 예외가 정상적으로 발생하는지
  • createSalLdgr 내부 타 로직 호출 내용이 정상 실행되는지

온통 SalLdgrService에 관련되 내용 뿐이다. 이러한 이유에서 아래와 같은 테스트 대역들이 등장 했으며 자세하게 한번 살표보자.


Stub

고정된 값을 반환하거나 미리 정의된 동작을 수행하는 테스트 대역이다

  • 고정된 값을 반환하여 예측 가능한 값을 반환하도록 동작을 미리 정의한다.
  • 동작만 제공하며 검증을 하지 않는다. (이 기능은 메소드 호출여부나 몇번 호출했는지 확인하는 기능이 없다는 의미로 mockicto에 포함되어 있는 기능이다)
  • 상태 기반의 테스트에 적합하기 때문에 테스트 결과가 특정 상태와 일치하는지 확인할 때 사용한다.
class StringServiceStub extneds StringService {
	@Overide
	public String concatString(String s1, String s2){
		// 실제 로직을 무시하며 hello만 항상 반환한다.
		return "hello";
	}
}

위와 같은 로직을 stubbing이라고 볼 수 있다.


Mock

mock이란 ‘모의, 가짜’란 의미이다. 테스트할 때 필요한 실제 객체와 동일한 가짜 객체를 만들어 사용하는 객체로 Mock Object가 있다. 매소드 호출여부, 호출 횟수, 전달된 인자를 검증 가능한 테스트 대역이다.

  • 행위 기반 테스트에 적합하며 호출 여부와 호출 패턴을 검증한다.
  • 테스트 중 동적으로 행동을 정의할 수 있다.
  • Moclito 라이브러리에서 기능 제공
// Mockito 사용 예제
@ExtendWith(MockitoExtension.class)
class StringServiceTest {
	// @Mock - mocking할 객체 위에 작성한다.
	@InjectMock // @Mock 들을 해당 어노테이션이 붙은 객체들에게 주입한다.
	private StringService stringService;

	void 문자열_붙이기_테스트() {
		// given
		String s1 = "hell";
		String s2 = "o";
		String expected = "hello";
		
		// when - mocking
		when(stringService.concatString(s1, s2)).thenReturn(expected);

		// then mock 객체의 행위 검증
		verify(stringService).concatString(s1, s2);
	}
}


Spy

Spy는 실제 객체를 감싸면서 일부 메서드만 Stub처럼 동작하도록 구현하며, 기본적으로 실제 메서드가 호출되지만 특정 메서드는 조작이 가능한 형태이다.

  • 실제 동작과 스텁 동작 혼합 가능
  • 부분적인 검증이나 메서드 조작에 용이
// Mockito 사용 예제
@ExtendWith(MockitoExtension.class)
class StringServiceTest {
	// @Spy - mocking할 Spy 객체 지정
	@InjectMocks // @Spy 객체를 주입하여 InjectMocks 객체를 생성
	private StringService stringService;

	void 문자열_붙이기_테스트() {
		// given
		String s1 = "hell";
		String s2 = "o";
		String expected = "hello";
		
		// when - mocking
		stringService.concatString(s1, s2);

		// then mock 객체의 행위 검증, 실제 함수를 호출하여 1회 실행됐는지
		verify(stringService, times(1)).concatString(s1, s2);
	}
}