[test-code] 테스트 코드의 중요성 _ 좋은 코드
테스트 코드와 좋은 코드의 연관성에 대한 개인적인 생각을 정리해보았습니다.
로딩중...
오래 걸리지 않기를 바라요..!
테스트 코드와 좋은 코드의 연관성에 대한 개인적인 생각을 정리해보았습니다.
사실 좋은 코드에 대해서 정답은 없다. 작성된 코드에 대한 적절한 당위성이 뒷받침 된다면 어느 코드는 해당 상황에서 “그렇게 쓰여야만 하는 코드”였을 것이다. 즉 해당 상황에서 해당 코드가 정답이었을 것이다. 이러한 상황은 일반화 할 수 없을 정도로 다양할 것이고, 이에 대해서 명확히 어떤 코드가 정답이라고 할 수 없다.
다만 많은 개발자들이 잘 작성된 코드를 이야기할 때 공통적으로 이야기 하는 점들이 존재한다.
…
다양한 기준이 존재하겠지만 위의 대부분의 좋은 코드의 특징들은 “테스트가 용이한 코드” 라는 키워드에 부합한다고 생각한다.
테스트가 용이하다는 것은 관심사별 모듈화가 잘 되어있고, 이로 인하여 가독성, 유지보수성, 신뢰성, 재사용성, 확장성이 있는 코드가 된다. 또한 테스트 코드는 하나의 문서화의 역할까지 해주기에, 정확히 이렇다 라고 정할 수는 없어도 테스트가 용이한 코드는 좋은 코드다 라고도 할 수 있을 것이다.
이 주제에 대해서 논하기 전에 먼저 아래의 코드의 테스트 코드를 작성한다고 생각해보자.
const MovieListViewer = () => {
const [ response, setResponse] = useState({
loading: true,
error: null,
movies: []
});
useEffect(() => {
fetch('/api/movielist')
.then(...)
},[])
if (response.loading) {
return <LoadingSpinner />}
if (response.error) {
return <ErrorPage error={response.error} />}
return (
<div>
{response.movies.map(...)}
</div>)
}어떤 기능들이 존재하고, 어떤 것을 목(mock)으로 만들어 사용해야 할까?
일단 기능을 먼저 나열해보자면..
이 기능에 대해서 테스트 케이스를 만들어 보면, fetch로 부터 받아오는 값에 따라서 상태값이 적절히 변경이 되어 상태값에 따라서 ui가 적절하게 보여지는지를 테스트 해야 한다. 하지만 이는 온전히 우리의 손으로 해결할 수 없는 위치에 존재한다. 컴포넌트 내부에 서버 요청 로직이 그대로 박혀 있다 보니까 서버의 상태나 반환값에 대해서 조절할 수 없다.
즉 서버에서 반환되는 값에 따라서 동일한 로직, 동일한 테스트에서 성공할수도, 실패할수도 있게 된다.
이를 테스트 하려면 http요청 자체를 가로채가는 MSW와 같은 라이브러리를 사용해야만 할 것이다. 하지만 이 또한 테스트에 의존성이 생기므로 좋은 방법이 아닐 수 있다.
⚠️ 최근에는 msw를 사용하여 api 로직 자체를 테스트 하기도 한다!
우리가 테스트 과정에서, 해당 로직의 결과를 관리하기 위해서는 목으로 만들 수 있는지에 대해서 생각해볼 필요가 있다.
그렇다면 이러한 로직을 테스트가 용이하게 변경하려면 어떻게 해야 할까?
간단히 관심사별로, 혹은 테스트 하고싶은 기능 별로 코드를 분리해주는 방식을 취할 수 있을 것 같다.
우선 서버 요청 로직이 제대로 동작하는지를 테스트 할 수 있도록 이를 따로 모듈화 해줄 수 있다.
// api.js
export const fetchMovieList = () => fetch('/api/movielist').then(res => res.json());그리고 서버 요청 결과값에 대해서 올바르게 상태값을 저장하는 로직을 테스트 해볼 수 있도록 또 다른 모듈을 두게 되었다.
// useMovieList.js
import { useState, useEffect } from 'react';
import { fetchMovieList } from './api';
export const useMovieList = (fetchFunction = fetchMovieList) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [movies, setMovies] = useState([]);
useEffect(() => {
const loadMovies = async () => {
try {
setLoading(true);
const data = await fetchFunction();
setMovies(data);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
loadMovies();
}, [fetchFunction]);
return { loading, error, movies };
};여기서 각 모듈(api 로직, 서버 상태 관리 로직)은 각기 다른 테스트를 해주어야 하므로, 목 함수를 넣어줄 수 있도록 api 로직을 함수의 인자로서(props로서) 받아서 이를 사용하도록 하였다.
테스트를 할 때에는 이렇게 목함수를 주입해줄 수 있다.
// useMovieList.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import { useMovieList } from './useMovieList';
describe('useMovieList', () => {
it('성공적으로 영화를 불러옵니다', async () => {
const mockMovies = [{ id: 1, title: '인셉션' }, { id: 2, title: '인터스텔라' }];
const mockFetchFunction = jest.fn().mockResolvedValue(mockMovies);
const { result, waitForNextUpdate } = renderHook(() => useMovieList(mockFetchFunction));
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
expect(result.current.movies).toEqual(mockMovies);
});
})전체 테스트 코드
// useMovieList.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import { useMovieList } from './useMovieList';
describe('useMovieList', () => {
it('초기 상태를 올바르게 설정합니다', () => {
const { result } = renderHook(() => useMovieList());
expect(result.current.loading).toBe(true);
expect(result.current.error).toBe(null);
expect(result.current.movies).toEqual([]);
});
it('성공적으로 영화를 불러옵니다', async () => {
const mockMovies = [{ id: 1, title: '인셉션' }, { id: 2, title: '인터스텔라' }];
const mockFetchFunction = jest.fn().mockResolvedValue(mockMovies);
const { result, waitForNextUpdate } = renderHook(() => useMovieList(mockFetchFunction));
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
expect(result.current.movies).toEqual(mockMovies);
});
it('오류 발생 시 에러 상태를 설정합니다', async () => {
const mockError = new Error('API 오류');
const mockFetchFunction = jest.fn().mockRejectedValue(mockError);
const { result, waitForNextUpdate } = renderHook(() => useMovieList(mockFetchFunction));
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(mockError);
expect(result.current.movies).toEqual([]);
});
it('fetchFunction이 변경되면 다시 데이터를 불러옵니다', async () => {
const mockMovies1 = [{ id: 1, title: '인셉션' }];
const mockMovies2 = [{ id: 2, title: '인터스텔라' }];
const mockFetchFunction1 = jest.fn().mockResolvedValue(mockMovies1);
const mockFetchFunction2 = jest.fn().mockResolvedValue(mockMovies2);
const { result, waitForNextUpdate, rerender } = renderHook(
({ fetchFunction }) => useMovieList(fetchFunction),
{ initialProps: { fetchFunction: mockFetchFunction1 } }
);
await waitForNextUpdate();
expect(result.current.movies).toEqual(mockMovies1);
rerender({ fetchFunction: mockFetchFunction2 });
await waitForNextUpdate();
expect(result.current.movies).toEqual(mockMovies2);
});
});서버의 상태에 따라서 다르게 보여줘야 하는 ui 또한 테스트할 수 있도록 따로 모듈화 할 수 있다.
// MovieListViewer.js
import React from 'react';
import { useMovieList } from './useMovieList';
import LoadingSpinner from './LoadingSpinner';
import ErrorPage from './ErrorPage';
const MovieListViewer = ({ fetchMovies = fetchMovieList }) => {
const { loading, error, movies } = useMovieList(fetchMovies);
if (loading) return <LoadingSpinner />;
if (error) return <ErrorPage error={error} />;
return (
<div>
{movies.map(movie => (
<div key={movie.id}>{movie.title}</div>
))}
</div>
);
};
export default MovieListViewer;이렇게 추상화 레벨을 3단계로 두니 테스트가 정말로 쉬워진 것을 볼 수 있다. 원하는 상태를 조작하여 주입해줄 수 있다.
여기서 주목할 점은 우리의 관심사 외의 것들(예를 들어 UI 에서는 의존하는 상태값들을, 상태값은 의존하고있는 api 로직 및 결과를) 임의로 만들어 (mocking 하여) props나 인자로서 “주입” 한다는 점이다.
이렇게 작성하는 방법을 “의존성 주입”_aka.DI 이라고 한다.
의존성 주입(Dependency Injection, DI)은 소프트웨어 설계 패턴 중 하나로, 객체 지향 프로그래밍에서 자주 사용되는 개념이다.
이전 설명에서 어느정도 느낄 수 있겠지만, 한 객체가 필요로 하는 다른 객체를 외부로부터 제공받는 방식을 말한다. 덕분에 해당 객체에서는 본인의 내부 로직에만 집중할 수 있게 된다. 해당 객체를 사용할 때 필요에 따라 맞는 객체를 주입해주면 된다.
의존성 주입이 가져다주는 장점은 아래와 같다.
하지만, 코드를 작성하는 입장에서 어느 것을 외부의 개념으로 볼지, 어느것을 내부의 개념으로 볼지에 대해서 헷갈릴 수 있다고 생각한다.
우리는 장점들 중에서 “테스트 용이성” 에 조금 더 집중해보면 이에 대한 기준을 명확히 할 수 있다.
이전 예제에서도 알 수 있듯 어떠한 기능을 테스트 하기 위하여 “무엇의 결과를 조작해야 하는지(무엇을 목으로 만들어야 하는지)” 를 생각해보면 된다.
그리고 이렇게 외부로 뺀 의존성들을 함수의 인자, 컴포넌트의 props로 전달해주도록 하면 된다.
자연스럽게 테스트는 용이해지고, 좋은 코드의 모습을 갖추게 될 것이다.
⚠️ 단 꼭 테스트가 용이하다고 좋은 코드라고 볼수는 없다. 맨 처음 이야기 했듯 좋은 코드는 명확히 무엇이라고 말할 수 없다. 테스트를 신경쓰게 되면 모듈화의 기준, 추상화의 기준이 조금 더 명확해지며 이에 따라 재사용성 및 확장성이 좋은 코드가 될 수 있다.