테스트 코드는 多多益善 입니다. (w. jacoco)

komment·2024년 7월 22일
37

2024 개발 일지

목록 보기
5/6
post-thumbnail

서론

  테스트(Test)는 소프트웨어의 관점에서 바라보면 프로그램이 요구사항에 맞게 동작하는지 검증하는 행위이다. 테스트를 하기 위한 방법은 여러 개가 있는데, 그 중 하나가 바로 테스트 코드 작성이다. 각종 아티클이나 여러 채용공고에서 테스트 코드와 관련된 내용을 심심찮게 찾아볼 수 있을 정도로 테스트 코드 작성은 정말 중요하다.

필자 이력서 中

  테스트 코드는 선택이 아닌 필수다. 테스트 케이스는 무조건 다다익선이다. 나는 서버 개발을 시작하며 약 2년 전부터 테스트 작성을 습관화 하였다. 진행한 모든 프로젝트에서 테스트 작성을 필수로 하진 않았지만, 실무에서는 (팀장님께서 지켜보시기 때문에) 당연히, 애정이 담긴 사이드 프로젝트에서도 열심히 작성하였다.

  케이크크 v2.0 개발에 참여하기로 한 후, 배포까지 2달 간 약 40개의 크고 작은 API를 개발하였다. 또, 초기에는 바쁘다는 핑계로 테스트 작성을 조금 미뤄두었지만, v2.0.2를 준비하면서 추가적인 Unit Test를 작성하면서 코드 품질을 향상시키기 위해 노력했다.

  이번 포스팅에서는 테스트 코드에 대한 이야기와 더불어 케이크크의 테스트에 대해 작성해볼 예정이다.

Test Code, Why?

  사실 테스트 작성에 대한 필요성을 느끼지 못하는 개발자들이 종종 있다. 과거의 나도 그 중 한명이었던 것 같다. 테스트 코드를 통해 큰 효과를 보지 못했고, 오히려 기능 개발보다 테스트 코드를 작성하는 시간이 더 많이 들어 테스트 작성하는 것이 싫었다.

  위의 사진은 Service 레이어의 로그아웃 메서드와 Mocking을 통한 애플리케이션 비즈니스 단위 테스트다. 애플리케이션 통합 테스트나 도메인 비즈니스 단위 테스트까지 작성하면 아마 시간을 훌쩍 지나가 있을 것이다. 실제로 대부분의 개발자들이 기능 개발에 30%의 시간을, 테스트 작성에 70%의 시간을 투자한다. 우리는 왜 테스트 코드 작성에 심혈을 기울일까?

i) 코드 품질 향상

  테스트 코드 작성은 코드 품질을 향상 시킨다. 개발 완료 후 제품에 대하여 QA 혹은 출시를 하면 생각보다 많은 버그를 만나볼 수 있다. 실제로 1차 QA만 돌려도 Defect 관련 티켓 폭탄을 맛볼 수 있다. 만약 테스트 코드를 작성했다면 우리는 발생 가능한 버그를 미리 찾아내 방지할 수 있고, 이는 개발자가 신뢰성 높은 코드 작성을 가능하게 해주고, 이는 생산성을 향상 시켜준다.

  • Unit 테스트
    • 하나의 모듈을 기준으로 독립적으로 진행하는 가장 작은 단위의 테스트
    • 애플리케이션에서 동작하는 하나의 기능 또는 메서드 테스트
    • 도메인 모델과 비즈니스 로직 테스트
  • Integration 테스트
    • 모듈을 통합하는 과정에서 각 모듈 간 상호작용의 유효성을 검증하는 테스트
    • 통합된 모듈들이 연계되어 정상적으로 동작하는지 검증
    • 주요 외부 의존성에 대해서도 테스트 (ex. DB)
  • e2e 테스트
    • 최종 사용자의 흐름에 대해 서비스의 기능이 정상적으로 동작하는 검증하는 테스트
    • 외부로부터의 요청부터 응답까지 기능이 잘 동작하는지 검증

  결함이 없을 수는 없다. 하지만 이렇게 테스트 케이스를 구성하고, 성공 테스트뿐 아니라 실패 테스트까지 작성하여 결함을 최소화 함으로써 코드 품질을 향상 시킬 수 있다.

ii) 유지보수 용이

  서비스를 운영 하다보면 요구사항 변경으로 인한 기능 수정이나 리팩토링 등 코드를 수정하는 경우가 꽤 잦은데, 우리는 이런 상황들을 두려워한다. 왜냐 하면 코드를 수정하면 다른 곳에서 에러가 터졌다는 소식이 들려오기 때문이다. 이처럼 이전에 제대로 작동하던 소프트웨어 기능에 문제가 생기는 것을 회귀 버그 라고 한다.

  우리는 이러한 버그를 완벽히 차단할 수는 없다. 하지만 테스트 코드를 작성하여 구성된 수백 또는 수천개의 TC를 통해 어느 부분에서 회귀 버그가 발생하는지 미리 알고 대처할 수 있다. 이처럼 미리 작성한 테스트 코드들을 통해 우리는 안정적인 확장안정적인 리팩토링이 가능해지기에 테스트 코드 작성은 유지보수를 용이하게 해준다고 할 수 있다.

iii) 문서화 기능

  서비스를 운영하는 과정에는 코드에 대한 유지보수뿐 아니라 문서에 대한 유지보수도 존재한다. 기획서, 정책서 등은 타 직무에서 관리해준다 해도 개발 문서는 개발자가 직접 업데이트 하여 싱크를 맞춰줘야 한다. 하지만 동일선 상에서 유지보수를 가져가는 것은 쉽지 않다.

  테스트 코드는 문서로도 사용된다. 테스트 코드를 통해 개발자는 기능의 동작에 대해 보다 빠르게 이해할 수 있고, 커뮤니케이션 등 협업에도 큰 도움이 된다.

  위의 코드는 로그아웃에 대한 TC다. 우리는 잘 구성된 테스트 코드의 디스크립션만 보아도 어떤 경우에 성공하고, 어떤 경우에 실패하는지, 그 기능에 대해 자세히 알 수 있다.

iv) 테스트 자동화

  개발자는 종교가 딱히 없어도 기도를 한다. 이런 기도는 기원전 4세기 경, 작성한 코드가 운영 환경에서 정상 작동할지에 대한 불안감에서 시작되었다. (개소리다.) 실제로 내가 다니는 회사도 한달 전에 서비스 런칭을 하기 전에 기도를 했다. 소용없긴 했지만,,, (아마도 간절함이 부족했던게 틀림없다.)

  이런 기도 메타(?)를 없앨 수는 없지만, 기도의 수를 줄여줄 수 있는 것이 바로 테스트 자동화 이다. 우리는 CI(; Continuous Integration)을 통해 코드에 심어져 있는 버그에 대해 미리 대처할 수 있다.


  이렇듯 테스트 작성은 굉장히 많은 이점을 가지고 있다. 간혹 몇몇 SI 회사에서는 개발 속도를 위해 테스트 코드를 작성하지 않는데, 과연 테스트 코드 작성이 생산성을 저하시키는 것인지 한번쯤은 의심해 볼 필요가 있다고 생각 든다.

Test Code, How?

  그래서 테스트 코드, 도대체 어떻게 작성하는건데? 일단 좋은 테스트는 다음과 같다.

  • 어떤 환경에서도, 빠르게 수행돼야 한다.
  • 하나의 테스트 코드는 하나의 목적에 대해서만 수행돼야 한다.
  • 테스트가 왜 성공했고 왜 실패했는지 확인할 수 있는 코드가 있어야 한다.
  • 이해하기 쉽게 작성돼야 한다.

  자세한 내용은 First 전략 관련 설명을 통해 확인할 수 있다.

  이런 좋은 테스트 작성에 대해 알기 위해 우리가 먼저 알아야 할 것은 Mocking이다. Mock 데이터, Dummy 데이터 라는 말은 많이 들어봤을 것이다. 이러한 것들은 테스트 더블이라고 한다.

테스트 더블 (Test Double)

  테스트 더블이란 테스트 중인 시스템의 일부분이 테스트 하기 어려운 상황일 때 사용할 수 있는 가짜 컴포넌트를 말한다. 우리는 테스트 더블을 통해 특정 조건과 상호작용을 쉽게 재현하거나 특정 상황을 시뮬레이션 할 수 있다. 또 테스트 더블을 활용하면 테스트 실행 시간 또한 단축시킬 수 있다.

  테스트 더블의 종류는 크게 Stub, Mock, Spy, Fake가 있다.

  • Stub
    • 호출된 메서드에 대해 미리 정의된 응답을 반환
    • 외부 시스템의 응답을 시뮬레이션 할 때 사용
  • Mock
    • 호출될 것으로 예상되는 사양을 정의
    • 행위를 검증할 때 사용
  • Spy
    • 실제 객체의 기능을 그대로 사용하면서 호출됐을 때의 정보를 기록
    • 예상대로 동작하는지 검증할 때 사용
  • Fake
    • 실제 동작하는 객체의 간단한 버전

  테스트 더블의 목적은 외부 시스템과의 의존관계를 끊고, 테스트 환경을 보다 가볍게 만드는 것이다. 또 실패 상황, 예외 상황 등을 편리하게 시뮬레이션 하여 여러 TC를 통해 시스템의 안정성을 보장할 수 있다. 다음과 같은 메서드를 예로 들어보자.

public CakeImageListResponse searchCakeImagesByCursorAndViews(final CakeSearchByViewsRequest dto) {
	final long offset = isNull(dto.offset()) ? 0 : dto.offset();
	final int pageSize = dto.pageSize();
	final List<Long> cakeIds = cakeViewsRedisRepository.findTopCakeIdsByOffsetAndCount(offset, pageSize);

	if (isNull(cakeIds) || cakeIds.isEmpty()) {
		return CakeMapper.supplyCakeImageListResponse(List.of(), cakeIds);
	}

	final List<CakeImageResponseParam> cakeImages = cakeReader.searchCakeImagesByCakeIds(cakeIds);
	return CakeMapper.supplyCakeImageListResponse(cakeImages, cakeIds);
}

  코드를 보면 알 수 있듯이 RDB뿐만 아니라 Redis까지 활용하고 있다. 만약 가장 작은 단위의 테스트인 유닛 테스트에서 이런 외부 의존성을 모두 가진채 실행하게 되면 테스트는 무거워질 것이다. 따라서 다음과 같이 테스트 더블을 활용하여 테스트를 작성 해보았다.

@TestWithDisplayName("인기 케이크 목록을 조회한다")
void searchCakeImagesByCursorAndViews1() {
	// given
	final long cursor = 0L;
	final int pageSize = 3;
	final CakeSearchByViewsRequest dto = new CakeSearchByViewsRequest(cursor, pageSize);
	final List<Long> cakeIds = List.of(1L, 2L, 3L);
	final List<CakeImageResponseParam> cakeImages = getConstructorMonkey().giveMeBuilder(CakeImageResponseParam.class)
		.set("cakeId", Arbitraries.longs().between(1, 3))
		.set("cakeShopId", Arbitraries.longs().greaterOrEqual(1))
		.set("cakeImageUrl", Arbitraries.strings().alpha().ofMinLength(10).ofMaxLength(20))
		.sampleList(3);

	// Stubbing
	doReturn(cakeIds).when(cakeViewsRedisRepository).findTopCakeIdsByOffsetAndCount(cursor, pageSize);
	doReturn(cakeImages).when(cakeReader).searchCakeImagesByCakeIds(cakeIds);

	// when
	CakeImageListResponse result = cakeService.searchCakeImagesByCursorAndViews(dto);

	// then
	Assertions.assertNotNull(result.cakeImages());
	Assertions.assertNull(result.lastCakeId());

	verify(cakeViewsRedisRepository, times(1)).findTopCakeIdsByOffsetAndCount(cursor, pageSize);
	verify(cakeReader, times(1)).searchCakeImagesByCakeIds(cakeIds);
}

  cakeViewsRedisRepository와 cakeReader는 Mock 객체로 구성하고, Stub을 통해 해당 메서드에 미리 정의된 응답을 반환하도록 해주었다. 만약 다른 TC에서 에러가 발생하는 상황에 대해 검증하고 싶다면 다음과 같이 Stubbing 하고 에러에 대해 검증하면 된다.

. . .

// Stubbing
doThrow(new CakkException(ReturnCode.INTERNAL_SERVER_ERROR)).when(cakeViewsRedisRepository).findTopCakeIdsByOffsetAndCount(cursor, pageSize);

. . .

// then
. . .
assertThrows(
	CakkException.class, 
    () -> cakeService.searchCakeImagesByCursorAndViews(dto)
);

  이런 테스트 방법들은 크고 작은 프로젝트에서 테스트의 효율성을 위해 많이 사용되기에 활용법에 대해 익숙히 학습해보는 것이 좋아 보인다.

테스트를 위해서 실제 코드를 건드리는 것이 맞는가?

  테스트 코드를 작성 하다보면 구현 코드를 수정하고 싶을 때가 있다. 이럴 때마다 고민하게 된다. "테스트 코드 작성을 위해 실제 구현 코드를 변경해도 될까?"

@Transactional
public void save(final TestDto dto) {
	. . .
    final TestEntity entity = dto.toEntity();
    
	testRepository.save(entity);
}

  위의 메서드를 유닛 테스트를 작성해보자. 우리는 void 메서드이기 때문에 상태 검증을 위해 리턴 타입을 다음과 같이 변경하는 것에 대해 고민할 수 있다.

@Transactional
public Long save(final TestDto dto) {
	. . .
    final Long id = testRepository.save(entity).getId();
    return id;
}

  나는 이런 케이스에선 테스트를 위해 구현 코드가 변경되는 것이 맞지 않다고 생각한다. 이와 같은 경우, 우리는 주로 Mock을 활용하여 행위 검증에 초점을 맞추는 것이 맞다.

void 테스트_엔티티를_저장한다() {
	TestDto dto = TestDto.builder()
    	. . .
        .build();
	
    doNothing().when(testRepository).save(any());
    
	assertDoesNotThrow(() -> testService.save(dto));
    verify(testRepository, times(1)).save(any());
}

  여기서 하나의 물음표가 생긴다. Mock을 활용한 테스트는 화이트박스 테스트 성격을 띄게 되는데, 이렇게 되면 구현 코드와의 결합도가 높아져 확장 및 유지보수에 취약하게 된다. 이를 방지하기 위해 우리는 다음과 같은 세 가지 노력을 할 수 있다.

  • Mocking 범위 최소화
  • 통합 테스트 설계 및 작성을 통한 유연성 유지
  • SRP를 준수하여, 클래스나 메서드가 하나의 책임만 갖도록 설계

  위의 케이스와 별개로 구현 코드의 설계 자체를 변경해야 하는 경우가 있다. "좋은 코드는 테스트 하기도 쉽다"는 말이 있다. 만약 테스트 하기 까다롭다고 생각이 들면 SRPDIP를 잘 지키고 있는지, 너무 많은 의존 관계를 갖고 있는 것은 아닌지 의심해보고 구현 코드를 수정 할 필요가 있다.

케이크크의 테스트 전략

  케이크크 서버팀은 개발 전부터 테스트 코드를 꼼꼼히 작성하기로 결정하고, 어떻게 테스트 해야 잘했다고 소문날지 고민했다. 우리의 니즈는 다음과 같았다.

  • 가벼운 테스트를 작성하자.
  • 실제 운영 환경에 대해 검증하자.

  하지만 두 니즈는 아예 다른 목적을 가지고 있다. 따라서 유닛 테스트에 Mocking을 도입하여 외부 의존성을 차단한 가벼운 테스트를 작성하여 비즈니스 로직에 대해서 검증하기로 했다. 또, Testcontainer를 활용하여 실제 운영 환경과 최대한 가까운 환경을 구성하여 통합 테스트를 진행하기로 했다.

재사용 가능한 구조

  먼저 공통 코드 재사용과 테스트 설정의 일관성 유지를 위해 재사용한 구조를 구성해보았다.

// IntegrationTest.java
@ExtendWith(SpringExtension.class)
@ActiveProfiles("test")
@SpringBootTest(
	properties = "spring.profiles.active=test",
	webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
public abstract class IntegrationTest {

	@Autowired
	protected TestRestTemplate restTemplate;

	@LocalServerPort
	protected int port;

	@Autowired
	protected ObjectMapper objectMapper = new ObjectMapper();
    
    . . .
}

// MockitoTest.java
@Import(JpaConfig.class)
@ActiveProfiles("test")
@ExtendWith(MockitoExtension.class)
public abstract class MockitoTest {
	. . .
}

  위의 추상 클래스를 상속하여 각각 통합 테스트와 유닛 테스트를 작성하였다. 이전보다 편하게 작성할 수 있었고, 유지보수 또한 용이했다.

테스트 커버리지는 역시 Jacoco

  커버리지가 높은 테스트가 좋은 테스트라고 말할 순 없지만, 테스트에 대하여 수치상으로 확인할 수 있는 것이 테스트 커버리지다. Jacoco는 Java Code Coverage Library의 줄임말이다. 케이크크 서버팀은 Jacoco를 통해 테스트 커버리지를 측정하고, 코드 커버리지 툴로는 Codecov를 채택했다.

  케이크크 서버는 멀티모듈로 구성돼있기 때문에 최상위 gradle 설정에 다음과 같이 설정했다.

. . .
subprojects {
	. . .
    
	apply plugin: 'jacoco'

	. . .

	tasks.named('test') {
		useJUnitPlatform()
		finalizedBy 'jacocoTestReport'
	}

	jacoco {
		toolVersion = "0.8.12"
	}

	jacocoTestReport {
		reports {
			html.required.set(true)
			csv.required.set(false)
			xml.required.set(true)
		}

		finalizedBy 'jacocoTestCoverageVerification'
	}

	jacocoTestCoverageVerification {
		def Qdomains = []
		for (qPattern in '*.QA'..'*.QZ') {
			Qdomains.add(qPattern + '*')
		}

		violationRules {
			rule {
				enabled = true
				element = 'CLASS'

				limit {
					counter = 'LINE'
					value = 'COVEREDRATIO'
					minimum = 0.70
				}

				limit {
					counter = 'BRANCH'
					value = 'COVEREDRATIO'
					minimum = 0.70
				}

				limit {
					counter = "LINE"
					value = "TOTALCOUNT"
					maximum = "200".toBigDecimal()
				}

				excludes = [
					"com.cakk.api.Application",
				    "com.cakk.api.dto.**",
					"com.cakk.api.mapper.**",
					"com.cakk.api.vo.**",
					"com.cakk.domain.**"
				] + Qdomains as List<String>
			}
		}
	}
}
. . .

  일단은 70%로 커버리지 목표를 세웠고, 다음으로 codecov를 설정하였다. codecov 홈페이지로 이동하여 회원가입 후 Repository를 등록해주면 CODECOV_TOKEN을 복사할 수 있는 화면이 나온다. 이를 복사해서 다음과 같이 Repository Secret으로 등록해준다.

  이후 codecov에 대한 설정 파일을 다음과 같이 작성했다.

codecov:
  require_ci_to_pass: yes

comment:
  layout: "reach,diff,flags,files,footer"
  behavior: default
  require_changes: false
  require_base: no
  require_head: yes
  • codecov 블럭
    • require_ci_to_pass: yes
      • 모든 CI 작업이 성공적으로 완료되어야만 코드 커버리지 리포트 제출
  • comment 블럭
    • layout: "reach,diff,flags,files,footer"
      • Codecov가 커버리지 리포트에 포함할 댓글의 레이아웃을 지정
      • reach: 커버리지의 전체 범위
        • diff: 변경된 코드의 커버리지 차이
        • flags: 커버리지의 플래그 또는 태그
        • files: 파일별로 커버리지 정보
        • footer: 댓글의 하단에 추가 정보
    • behavior: default
      • 댓글을 작성하는 기본 동작 방식 사용
    • require_changes: false
      • 커버리지 리포트를 제출할 때, 코드 변경이 필요하지 않다는 것을 의미
    • require_base: no
      • 커버리지 리포트를 제출할 때, 기준 커버리지 리포트가 필요하지 않다는 것을 의미
    • require_head: yes
      • 커버리지 리포트를 제출할 때, 최신 커밋에 대한 리포트가 필요하다는 것을 의미

  마지막으로 github actions의 워크플로우에 작성해주면 codecov가 연동된 것을 확인할 수 있다.



포스팅과 관련된 코드는 케이크크 서버 Github에 저장돼 있습니다.

profile
안녕하세요. 서버 개발자 komment 입니다.

0개의 댓글