- 테스트 커버리지 100% 달성기[1] - 레이어별 100% 달성 과정
- 테스트 커버리지 100% 달성기[2] - 테스트 환경 구축 및 시간 단축
-------------------------
병렬 테스트 문제
mock() 을 통해 의존객체 모킹시 IllegalArgument 문제
-> 병렬로 여러 스레드에서 동시에 mock()을 사용하는 경우 각 스레드에서 목객체를 만들게되는데 이때 각 테스트에서 사용하는 mock의 타겟 객체의 타입이 달라 정상적으로 타입 변환이 되지 않는 것으로 보임
Byte Buddy could not instrument all classes within the mock's type hierarchy
InlineBytecodeGenerator의 lastException 속성이 있는 경우 해당 예외가 발생되는데
Throwable throwable = this.lastException;
if (throwable != null) {
throw new IllegalStateException(StringUtil.join(new Object[]{"Byte Buddy could not instrument all classes within the mock's type hierarchy", "", "This problem should never occur for javac-compiled classes. This problem has been observed for classes that are:", " - Compiled by older versions of scalac", " - Classes that are part of the Android distribution"}), throwable);
}
transfrom 메서드의 byteBuddy.redefine 메서드를 실행하며 예외가 발생 되는 캐치하여 저장한다.
byteBuddy의 redefine을 통해 가짜객체를 타깃 객체 타입으로 변환하는데 이 과정에서 동시성 문제가 발생한듯 하다.
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
if (classBeingRedefined != null && (this.mocked.contains(classBeingRedefined) || this.flatMocked.contains(classBeingRedefined)) && !EXCLUDES.contains(classBeingRedefined)) {
try {
return this.byteBuddy.redefine(classBeingRedefined, Simple.of(classBeingRedefined.getName(), classfileBuffer)).visit(new ParameterWritingVisitorWrapper(classBeingRedefined)).visit(this.mockTransformer).make().getBytes();
} catch (Throwable var7) {
this.lastException = var7;
return null;
}
} else {
return null;
}
}
byteBuddy
불필요한 모킹
// given
val expectedIP = "203.0.113.195"
val reqAttr = mock(ServletRequestAttributes::class.java)
val req = mock(HttpServletRequest::class.java)
`when`(reqAttr.request).thenReturn(req)
RequestContextHolder.setRequestAttributes(reqAttr)
`when`(req.getHeader(ProxyIPHeaderType.XForwardedFor.attrName)).thenReturn(expectedIP)
아래와 같이 변경
val expectedIP = "203.0.113.195"
val req = MockHttpServletRequest()
val reqAttr = ServletRequestAttributes(req)
RequestContextHolder.setRequestAttributes(reqAttr)
req.addHeader(ProxyIPHeaderType.XForwardedFor.attrName, expectedIP)
-------------------------
2. 테스트 시간단축
테스트 커버리지 100%라는 것은 모든 클래스와 모든 메서드 그리고 모든 분기 처리에 대한 검증을 진행한다는 것을 의미하기에
테스트 양도 많고 시간도 오래걸린다.
따라서 테스트 시간 단축은 필수 고려 사항이다.
따라서 나는 아래와 같은 전략을 사용하였다.
- 프레임워크 의존 없는 순수 단위 테스트
- 스프링 컨텍스트 공유
- 병렬 테스트 진행
프레임워크 의존 없는 순수 단위 테스트는 각자 잘 진행할 것이라 생각한다.
굳이 순수 단위 테스트를 작성할 수 있음에도 프레임워크를 띄워 테스트를 진행할 것이라고 생각하지 않는다.
스프링 컨텍스트 공유
스프링 컨텍스트 공유는 단일 모듈 내에서 컨텍스트를 띄워 테스트하는 경우
컨텍스트를 한번만 띄워 모든 테스트에서 해당 컨텍스트 설정으로 테스트를 진행하는 경우이다.
스프링 컨텍스트를 재부팅하는 시간을 줄이기 위함이다.
테스트 코드 실행시 모킹 객체가 하나만 달라지더라도 스프링에서는 컨텍스트를 재부팅한다.
따라서 모킹 정보를 한군데에 모은 TestConfiguration을 만들고 해당 config를 테스트에서 사용하는 것이다.
ContextConfiguration 어노테이션
@ContextConfiguration(classes = [RestApiWebMvcMockBeanConfig::class])
@WebMvcTest(
value = [
HealthCheck::class,
LoginFailureController::class,
MemberReadController::class,
...
]
)
@ActiveProfiles("rest-api")
annotation class WebMvcUnitTest
Mocking 객체 Config 설정
@Import(
CsMockBeanConfig::class,
FileMockBeanConfig::class,
MathCategoryMockBeanConfig::class,
...
)
@TestConfiguration
class RestApiWebMvcMockBeanConfig {
@Bean
fun securityConfig(): SecurityConfig = Mockito.mock()
@Bean
fun securityFilterChain(): SecurityFilterChain = Mockito.mock()
@Bean
fun handlerMethodArgumentResolver(): HandlerMethodArgumentResolver {
return MockUserDetailArgumentResolver()
}
@Bean
fun webConfig(): WebConfig {
return WebConfig(handlerMethodArgumentResolver())
}
}
위와 같이 모든 테스트 대상에 대한 mocking configuration을 한군데에 정의하고 사용하면
스프링 컨텍스트를 1번만 띄워 모듈내 전체 테스트가 가능하다.
Repository 모듈 단위 테스트에서도 동일한 개념으로 접근하면 된다.
병렬 테스트 진행
테스트 커버리지 100% 달성 소감
테스트 커버리지 100% 달성은 장단점이 뚜렷하다고 생각한다.
허나, 테스트 커버리지 100% 달성은 운영 코드를 더욱 단순하게 만드는 리팩토링에도 굉장한 도움이 된다.
특히나 분기 테스트에서 불필요한 분기 조건을 사용하여 코드 파악과 테스트 코드 작성에 어려움을 겪게 하였다.
그러한 코드는 과감하게 더욱 단순한 분기조건을 같도록 수정하였다.
또한, '이런것까지 테스트를 해야하나?', '이게 테스트 대상이 맞나?'라는 생각이 드는 코드에서 잘 못 구현되어있는 것들을 발견하였다.
대게 중요하다고 생각하는 코드야 작성할때부터 집중하고 심혈을 기울이지만
오히려 당연하다고 생각하는 코드에서 생각보다 잘못 구현된 것들이 많다.
커버리지 100%를 달성하면 억지 테스트 코드라고 생각 되는 부분도 많이 있었다.
100% 달성 실패 이유
엔티티 직접 사용하지 않는 경우
메뉴 테이블과 같이 db에서 값을 직접 집어넣고 프로그래밍 서버에서는 조회만 하는 경우 queydsl로 dto로 가져온다면
엔티티 사용을 하지 않아 테스트시 미사용
테스트 실행시간 단축
- 병렬 테스트
-> db, controller 동시성 문제
테스트 어노테이션 종류 차이 사용법
mvc, webTestClient, mock, pure, DataJpa
컨텍스트에 뜨는 클래스 종류들, 갯수(의존성에 따라서)
테스트 컨테이너, flyway
sdf
사용 되지 않는 메서드 처리
@Converter(autoApply = true)
class FormulaClassificationTypeConverter : AttributeConverter<FormulaClassificationType, String> {
override fun convertToEntityAttribute(column: String?): FormulaClassificationType? {
return FormulaClassificationType.entries.find { it.name.lowercase() == column }
}
override fun convertToDatabaseColumn(property: FormulaClassificationType): String {
return property.name.lowercase()
}
}
내 서비스의 enum이 사용되는 엔티티의 속성은 db의 칼럼으로 양방향 변환되기 위해 위와 같은 컨버터를 이용함
모든 엔티티 이넘 속성이 위와 같은 컨버터를 위해 동작함
하지만 위 테이블은 메뉴 테이블이라 row가 추가되는 경우 db에서 직접 쿼리를 날림
프로그래밍 서버에서 영속화 시키는 메서드가 존재하지 않음
따라서 두번째 메서드는 사용되는 곳이 없음
이때,
@PostLoad fun postLoad() { classificationType = FormulaClassificationType.entries.find { it.name.lowercase() == classificationTypeColumn } }
와 같은 방식을 사용할 것인가??
비효율적 저것만 형식이 바뀌는건 옳지 않음
@SuppressWarnings("unused")를 통해 해당 메서드 제외??
둘다 좋아 보이지 않음
혹시나 나중에 기능이 추가되어 직접 추가해줘야하는 경우 위와 같은 예외케이스가 있다는 것이 유지보수를 까다롭게 할 수 있음
그냥 컨버터에 대해서만 단위 테스트 진행
사용하지 않는 칼럼들, 다른 프로젝트(관리자)에서만 사용함
I/O 테스트
jacoco 최신버전 사용 0.8.11 -> 0.8.12
lateinit 브랜치 커버리지 못 잡아내는 버그 해결
https://www.jacoco.org/jacoco/trunk/doc/changes.html
'테스트' 카테고리의 다른 글
테스트 커버리지 100% 달성기[1] - 레이어별 100% 달성 과정 (2) | 2025.01.04 |
---|