How to design testing for a project?
Vitest를 프로젝트에 도입하기로 결정한 후, 테스트 환경을 구축하기 전에 다시 한 번 프로젝트를 살펴보며 전반적으로 테스트 코드 구조를 어떻게 구성하면 좋을지 고민하게 되었다.
여기서 어떻게란 단순한 테스트 코드 추가를 넘어서, 테스트 코드 자체가 프로젝트 전체 구조에 긍정적인 영향을 줄 수 있는 방향이면 좋겠다고 생각했다. 긍정적인 영향이란 단순히 테스트 코드 작성이 쉬운 구조인지 확인하는 것뿐만 아니라, 테스트 코드를 추가하면서 프로젝트의 코드가 좋은(?) 코드인지도 점검할 수 있어야 한다고 생각했다. 위와 같은 고민을 하며 테스트 도입에 도움이 될 만한 디자인 패턴들을 찾아보았고, 알아본 디자인 패턴들과 앞으로 진행할 테스트 도입 구조를 간략히 정리해보려 한다.
Design Patterns for Testing
Dependency Injection
의존성 주입 패턴(Dependency Injection)은 많이 들어본 패턴이라 익숙할 것이라 예상된다. 이는 객체나 로직을 내부에서 직접 생성하지 않고 외부에서 주입함으로써, 재사용성과 확장성을 높일 수 있도록 설계하는 방식이다.
아래와 같이 name 상태를 관리하는 훅을 생성하고, API를 통해 유저 정보를 가져오는 로직은 외부에서 주입받도록 한다.
tsxexport const useUserName = (fetchUser: () => Promise<string>) => {
const [name, setName] = useState("");
useEffect(() => {
fetchUser().then(setName);
}, [fetchUser]);
return name;
};
컴포넌트의 경우에도 props를 통해, 실제 렌더링에 필요한 상태 조회 API를 외부에서 주입받도록 설계한다.
tsxtype UserProps = {
fetchUser: () => Promise<string>;
};
export const User = ({ fetchUser }: UserProps) => {
const name = useUserName(fetchUser);
return <div>name</div>;
};
위와 같이 API와 같은 함수나 객체를 외부에서 주입받는 형태로 설계함으로써, 테스트 단계에서 비즈니스 로직과 외부 의존성을 mock으로 대체하여 독립적인 테스트가 가능하도록 한다.
tsximport { renderHook, act } from "@testing-library/react";
import { useUserName } from "./useUserName";
test("fetches and returns user name", async () => {
const mockFetchUser = jest.fn().mockResolvedValue("Luffy");
const { result } = renderHook(() => useUserName(mockFetchUser));
// wait for effect
await act(async () => {});
expect(mockFetchUser).toHaveBeenCalled();
expect(result.current).toBe("Lyffy");
});
전략 패턴(Strategy Pattern), 레포지토리 패턴(Repository Pattern) 등 널리 알려진 패턴들은 의존성 주입 패턴을 기반으로 확장된 디자인 패턴이기 때문에, 핵심적인 부분은 유사하다고 판단하여 해당 정리는 생략하겠다.
State Reducer Pattern
상태 리듀서 패턴(State Reducer Pattern)은 리액트 설계 시 자주 언급되는 디자인 패턴 중 하나로, 상태 관리 로직을 순수 함수(reducer)로 분리함으로써 컴포넌트와 분리된 구조로 설계할 수 있어 상태 관리 및 테스트 설계에 유용한 패턴이다.
아래와 같이 상태 및 액션을 처리하는 리듀서를 생성한 후, 이를 상태 관리 로직에서 사용하도록 한다.
tsx// reducer.ts
export type CounterState = { count: number };
export type CounterAction = { type: "increment" } | { type: "decrement" };
export function counterReducer(
state: CounterState,
action: CounterAction
): CounterState {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
default:
return state;
}
}
현재 프로젝트에서는 상태 관리 라이브러리로 Zustand를 사용하고 있으며, 아래는 위에서 정의한 리듀서를 Zustand와 결합한 간단한 예시이다.
tsx// userCounterStore.ts
import { create } from "zustand";
import { counterReducer, CounterState, CounterAction } from "./reducer";
type Store = CounterState & {
dispatch: (action: CounterAction) => void;
};
export const useCounterStore = create<Store>((set) => ({
count: 0,
dispatch: (action) => set((state) => counterReducer(state, action)),
}));
위와 같이 상태 처리 로직과 관리 포인트를 분산함으로써, 실제 테스트에서는 순수 함수로 분리된 상태 처리 로직만 검증하여 보다 명확한 단위 테스트로 구성할 수 있다.
tsximport { counterReducer } from "./reducer";
expect(counterReducer({ count: 0 }, { type: "increment" })).toEqual({
count: 1,
});
Humble Object Pattern
Humble은 단순함을 의미하며, 테스트하기 어려운 부분은 단순하게 만들고, 테스트가 필요한 부분은 별도의 로직으로 분리하여 설계한다.
의존성 주입에서 사용했던 훅을 험블 객체 패턴으로 변경해보자. API 테스트가 필요한 로직은 별도의 함수로 분리하고, 해당 함수를 훅에서 사용하도록 한다. 중요한 비즈니스 로직에 해당하는 API 요청 처리는 별도 함수로 분리하여 테스트하고, 훅과 컴포넌트는 험블 객체의 역할만 간단히 수행하도록 한다.
tsxexport async function fetchUser(): Promise<string> {
const response = await fetch("/api/user");
const data = await response.json();
return data.name;
}
export const useUserName = () => {
const [name, setName] = useState("");
useEffect(() => {
fetchUser().then(setName);
}, [fetchUser]);
return name;
};
fetchUser에서 처리되는 API 요청 부분을 jest.Mock으로 처리하여, 결과에 대해 간단하고 독립적인 테스트가 가능하도록 만들 수 있다.
jsxtest('fetchUser returns user name from API', async () => {
const mockData = { name: 'Alice' };
(fetch as jest.Mock).mockResolvedValueOnce({
json: () => Promise.resolve(mockData),
});
const result = await fetchUser();
expect(fetch).toHaveBeenCalledWith('/api/user');
expect(result).toBe('Alice');
});
useUserName 자체는 훅으로 테스트하면 좋지만, 실제 로직상 라이브러리의 useState, useEffect 훅에 의존하고 있기 때문에, 테스트 시에는 의존성 주입에서 보았던 것처럼 mocking 처리와 비동기 제어(act, waitFor)가 추가로 필요하다.
Which design pattern should we use?
위에서 정리한 패턴 외에도 테스트 도입에 도움이 되는 디자인 패턴은 아마 무수히 많을 것이다. 이미 알고 있는 사람들도 많겠지만, 위 디자인 패턴들을 살펴보다 보면 공통적인 특징이 있다는 것을 알 수 있다. 그것은 바로, 테스트가 필요하다고 생각되는 로직이나 중요한 로직들과 그렇지 않은 로직을 명확히 분리하는 것이다.
위와 같은 공통점은 큰 단위에서 시작하면, 프로젝트나 라이브러리, 우리가 흔히 모듈이라고 부르는 요소들뿐만 아니라 더 나아가 하나의 역할만 수행하는 함수 단위까지도 분리할 수 있을 것이다. 이처럼 하나의 독립적인 역할을 수행할 수 있는 단위로 중요 로직을 분리하면, 테스트 또한 별도의 독립적인 테스트로 수행할 수 있는 구조가 된다. 그렇다면 어떤 구조나 디자인 패턴으로 분리하는 것이 좋을까?
In conclusion
어떤 구조로 설계하거나 어떤 디자인 패턴을 사용하는 것이 좋을까?’에 대한 고민은, 디자인 패턴을 조사하고 최근 파서 로직을 구현하면서 어느 정도 정리되었다.
파서 로직 구현 초안(Draft) 리뷰 과정 중, ’왜 그 디자인 패턴을 사용했는가?’에 대한 질문과 답변을 주고받는 시간이 있었다. 이 과정에서 구현한 로직을 리팩토링하며, 테스트를 도입할 프로젝트에도 이러한 방향으로 진행하면 좋겠다는 생각이 들었다. 그동안은 프로젝트 구조를 잡거나 설계할 때, 항상 가장 큰 단위의 프로젝트를 기준으로만 ’어떤 디자인 패턴을 써야 할까?’를 고민했던 것 같다. 하지만 이번에 디자인 패턴을 조사하고 파서 로직을 구현해보며, 하나의 큰 모듈부터 작게는 함수 단위까지 각자의 역할에 맞는 다양한 디자인 패턴이 존재하고, 상황에 맞게 적절히 적용하는 것이 더 바람직하다고 느꼈다.
위와 같은 생각 또한 정답이 아닐 수 있으며, 테스트를 작성할 때마다 해당 로직에 맞는 디자인 패턴을 고민하는 과정도 쉽지 않을 것으로 예상된다. 테스트에 대한 좋은 글과 자료는 이미 많이 존재하지만, 각자의 환경에 따라 달라지는 테스트에 대한 고민은 끝없는 싸움이 되지 않을까 생각된다.