테스트 코드의 이상적 형태 (with Jest)

Jaden Kim·2023년 4월 23일
1
post-thumbnail

회사에서 여러 태스크들을 하면서 테스트 코드의 필요성을 많이 느꼈다.
특히 레거시 코드 수정으로 인해 서비스 장애가 나는 것을 경험하면서 현실 직시가 크게 되었다.
테스트 코드가 있었다면 분명 잡을 수 있던 오류들이 많았다...😭

회사에서도 테스트 작성을 프로세스화 하고 관련 컨벤션을 정리하고 있는 상황이다.
하지만 문제는 그걸 제안한 나도 테스트를 잘 알지는 못했다는 것...!😲

먼저 지금 하고 있는 사이드 프로젝트에 테스트 코드를 작성해보면서, 테스트 코드를 어떻게 짜야 하는지에 대해서 고민을 많이 하고 있다.
다음 내용은 여러 글들을 읽으면서 정리하게된 나의 생각이다.

코드는 js를 기반으로 작성되어 있고, 기본적인 js 모듈 기반 형태로 프로젝트가 구성되어 있다고 가정하자.
백엔드 프로젝트의 Service 모듈에 대한 단위 테스트를 작성하는 상황이다.

1. 테스트의 이상적인 형태 : input - return 기반

특정 메서드에 대한 테스트 코드의 이상적인 형태는, 특정 input으로 해당 메서드를 호출한 return 값을 검증하는 것이라고 생각한다.

정상 호출 테스트 예시 )

예를 들어 서비스 모듈 postService의 블로그 포스팅을 가져오는 getPosts 메서드에 대한 테스트 코드를 작성한다고 생각해보자.
블로그 포스팅은 velog의 외부 api와의 통신을 통해 가져오는 상황이다.

it('userId, velogToken이 유효할 경우 post 목록을 반환', async () => {
	// given
	const userId = 1;
	const velogToken = '12345';

	// when
	const posts = await postService.getPosts({
		userId,
		velogToken,
	});

	// then
	expect(posts).toEqual(POST_LIST);
});

유효한 userId, velogToken 이용하여 postServicegetPosts 메서드를 호출하면,
그 결과로 적절한 포스트 목록을 받게 되는지 검증한다.

에러 상황 테스트 예시)

에러 상황에 대해서는 아래와 같이 테스트를 작성할 수 있다.

it('유효하지 않은 userId로 호출하면 UserNotFoundError 발생', async () => {
	// given
	const userId = 999;
	const velogToken = '12345';

	// when
	// then
	await expect(() =>
		postService.getPosts({
            userId,
            velogToken,
		}),
	).rejects.toEqual(new UserNotFoundError());
});

이번 테스트 케이스에서는 userId에 유효하지 않은 값(999)이 들어갔다.
유효하지 않은 userId로 getPosts 메서드를 호출하면 UserNotFound 에러가 발생하는지 검증한다.

위 테스트들의 공통점

위 테스트들은 특정 메서드를 호출하고, 그 결과를 확인하는 매우 간단한 형태로 되어 있다.
정상 호출에서는 데이터의 상태를 검증하고, 에러 상황에서는 에러의 발생 여부를 검증한다.

2. 이렇게 테스트를 작성해야 하는 이유

위와 같이 input - return 기반으로 테스트를 작성하면 얻게 되는 장점은 다음과 같다.

1) 테스트 코드가 문서 역할을 할 수 있음

먼저 테스트 코드에 작성되어있는 테스트 케이스를 살펴보는 것만으로,
테스트 대상 메서드가 어떤 역할을 하는지 간단하게 확인할 수 있다는 점이다.

getPosts 에 대한 다양한 테스트 케이스에 대한 테스트 코드를 작성하면 아래와 같은 형태가 될 것이다.

describe('getPosts', () => {
	it('userId, velogToken이 유효할 경우 post 목록을 반환', async () => {
		...
	});
	it('유효하지 않은 userId로 호출하면 UserNotFoundError 발생', async () => {
		...			
	});
	it('유효하지 않은 velogToken으로 호출하면 VelogAxiosError 발생', async () => {
		...
	});
}

이런 식으로 테스트 코드가 작성되어 있으면 해당 로직을 처음 보는 사람도 메서드의 역할을 쉽게 파악할 수 있다.
어떤 input을 넣어서 해당 메서드를 호출해야 하는지, 어떤 상황에서 에러가 발생하는지를 한 눈에 볼 수 있다.

많은 사람들이 함께 협업을 하고, 서로 코드 리뷰를 해야 하는 상황에서 이는 큰 이점으로 작용한다.
테스트 코드는 테스트 대상 코드를 처음 읽는 사람들에게 친절한 설명서 역할을 할 수 있다.

2) 테스트 코드를 직관적으로 구성 가능

input-output 기반으로 테스트를 작성하지 않을 경우, mocking 기법을 사용해서 특정 메서드가 호출되었는지를 확인하는 식으로 검증할 수 있다.
그러나 이렇게 작성할 경우 아래와 같이 직관적이지 않은 코드가 완성된다.
postService.getPosts가 내부적으로 velogUidRepository.getByUserId를 호출한다고 가정해보자.

it('userId, velogToken이 유효할 경우 post 목록을 반환', async () => {
  	// given
	const userId = 1;
	const velogToken = '12345';

	// when
	const posts = await postService.getPosts({
		userId,
		velogToken,
	});

	// then
	expect(posts).toEqual(POST_LIST);
  	expect(velogUidRepository.getByUserId).toHaveBeenCalledWith(1);
	});

검증부에 mocking한 특정 모듈의 메서드가 호출되었는지를 확인하는 부분이 추가된다.
이렇게 작성하면 테스트 코드와 mocking 관련 코드가 뒤섞이게 되고, 직관적이지 않은 테스트 코드가 완성된다.

위와 같이 행위를 검증하는 방식을 mock이라고 하고, 상태를 검증하는 방식을 stub이라고 한다.
참고 링크 - Mock 테스트와 Stub 테스트의 차이

postService가 의존하고 있는 velogUidRepositorygetByUserId가 알맞게 호출되었는지의 문제는,
postService.getPosts 메서드의 본질적인 역할과는 거리가 있는 부분이다.

이로 인해 테스트 코드가 문서의 역할을 수행하는데 지장이 생긴다고 볼 수 있다.

또한 실제 구현을 검증하는 테스트 코드는 내구도가 낮아서, 실제 구현 코드가 리팩토링 되면 쉽게 깨질 수 있다는 문제도 함께 가지고 있다.
참고 링크 - 테스트 코드에서 내부 구현 검증 피하기

따라서 테스트 코드는 input - output으로 간결하게 이루어져야 하고, 상태를 검증하는 형태로 완성되어야 한다.
그래야만 테스트 코드의 직관성을 유지할 수 있다.

3) 코드의 bad smell을 확인할 수 있음

이 부분은 테스트 코드를 작성하면 얻게 되는 보편적인 이점에 가까운 것 같다.

코드의 나쁜 설계, 잘못된 구현을 ‘smell’이라고 한다.
테스트를 작성하는 과정에서는 기존 코드를 한 번 더 검토하게 되고, 이 과정에서 나쁜 냄새를 알아차리기 쉽다.

위에서 예를 든 getPosts가 처음에 아래와 같이 작성되어 있었다고 가정해보자.
db 처리 + axios 요청을 함께 직접 구현하고 있는, 다양한 역할을 가지고 있는 메서드이다.

위 코드의 나쁜 점은 무엇일까?

1) 추상화 수준의 불일치

우선 추상화 수준이 일관되지 않다. 상단에서는 db 처리를 추상화하고 있는 velogUidRepository.getByUserId()를 호출하고 있지만,
그 밑에 있는 axios 호출 부분은 추상화 없이 생으로 외부 호출을 처리하고 있다.

2) 테스트 코드 작성의 어려움

위 코드를 바탕으로 테스트 코드를 작성하는 것도 쉽지 않다.
axios 모듈 자체를 모킹해서 구현 해야 하고, targetUrl이나 bearer token authorization 등 axios의 세부적인 호출 방법에 대한 고려도 필요하다.

이렇게 테스트 코드를 작성하기 어려운 것은, 대표적은 bad smell의 증상이다!

코드의 개선 방법

axios 요청을 보내는 부분을 별도로 추상화하여 velogPostAxios에 위임하면, 다음과 같이 읽기 쉬운 코드를 만들 수 있다.

이와 같이 테스트 코드 작성을 하는 과정에서는 코드를 개선할 수 있는 포인트에 대해서 힌트를 얻는 경우가 많다.
단순히 로직을 한 번 더 검토하는 것을 넘어서, 설계를 다시 고려할 수 있는 기회가 된다.

글을 마치며..

현재 진행하고 있는 사이드 프로젝트에는 테스트 코드 작성에 꽤나 공을 들였다. - 깃헙
몇차례 리팩토링을 거치면서 올바른 테스트 코드에 대한 여러 고민들을 많이 할 수 있는 시간이었다.

그 과정에서 테스트 코드와 함께 본 로직의 코드도 점점 개선되어 가는 경험,
또 이후에 본 로직의 코드를 수정한 후에도 테스트를 간단히 실행함으로써 로직의 무결함을 확인할 수 있었던 경험.
이 두 개의 경험은 꽤나 잊지 못할 깨달음의 순간으로 남을 것 같다.

이런 경험을 회사의 동료들에게도 전파할 수 있었으면 좋겠다.😄

0개의 댓글