배경
『단위 테스트의 기술』과 『구글 엔지니어는 이렇게 일한다』를 읽으면서 테스트하기 쉬운 코드에 관심이 생겼고, 여러 아티클을 참고해 관련 내용을 더 깊이 공부했습니다. 이 과정에서 의존성 주입을 프론트엔드 테스팅 관점에서 다시 바라보게 되었고, 실제 테스트 코드에 어떻게 적용할 수 있을지 정리해 보았습니다.
의존성이란?
프론트엔드 개발을 하다 보면, 어떤 기능을 구현하기 위해 외부 라이브러리나 API를 사용하게 됩니다. 이러한 외부 요소들은 모두 의존성으로 볼 수 있습니다.
그렇다면 테스팅 관점에서 의존성은 어떤 의미일까요?
테스트 코드가 직접 제어할 수 없어 테스트 환경과 유지 보수를 어렵게 만드는 요인입니다.
여기서 말하는 '제어'란, 의존성의 동작 방식을 결정할 수 있는 능력입니다.
아래 코드는 함수 내에서 dayjs
를 사용하고 있어서 시간에 대한 제어권이 라이브러리에 있는 예시입니다.
// as-is : 테스트의 실행과 결과가 현재 요일에 따라 달라질 수 있는 예시
const foo = (input) => {
const dayOfWeek = dayjs().day();
if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
throw Error("주말");
}
return input;
};
describe("foo", () => {
const TODAY = dayjs().day();
it("항상 실행되지만, 현재 요일에 따라 아무것도 수행하지 않을 수 있다.", () => {
if ([SATURDAY, SUNDAY].includes(TODAY)) {
expect(() => foo("anything")).toThrowError("주말");
}
});
if ([SATURDAY, SUNDAY].includes(TODAY)) {
it("주말에만 실행 가능하다.", () => {
expect(() => foo("anything")).toThrowError("주말");
});
}
});
그럼 반대로 테스트가 시간을 제어할 수 있으려면 어떻게 해야 할까요? 매개변수로 요일을 전달받아 의존성에 대한 제어를 역전시키면 됩니다.
// to-be : 매개변수를 추가해 의존성에 대한 제어를 역전시킨 예시
const bar = (input, currentDay) => {
if ([SATURDAY, SUNDAY].includes(currentDay)) {
throw Error("주말");
}
return input;
};
describe("bar", () => {
it("일관성을 보장하며 항상 오류가 발생한다.", () => {
expect(() => bar("anything", SUNDAY)).toThrowError("주말");
});
});
이처럼 의존성을 내부에서 생성하지 않고, 외부에서 주입받도록 설계를 변경하는 것을 제어의 역전(Inversion of Control, IoC)이라고 합니다.
제어의 역전을 구현하는 방법: 의존성 주입(Dependency Injection, DI)
제어의 역전은 의존성을 외부에서 주입받으라는 설계 원칙이고, 이를 실제로 구현하는 방법 중 가장 널리 사용되는 것이 바로 의존성 주입입니다.
앞서 본 예시에서 매개변수로 currentDay
를 전달한 것도 의존성 주입의 한 형태입니다.
의존성 주입을 효과적으로 활용해 테스트가 쉬운 구조를 만들기 위해서는 seam이라는 개념을 이해해야 합니다. seam이란 테스트 더블을 활용할 수 있도록 코드 차원에서 교체 가능한 지점을 마련하는 것을 말합니다. 예를 들어 프로덕션 환경에서 이용하는 의존 대상을 테스트 환경에 맞게 다른 대상으로 교체할 수 있어야 합니다.
의존성 주입은 이러한 seam을 만드는 대표적인 방법으로, 인터페이스를 통해 의존성을 외부에서 내부로 전달하는 행위입니다. 이를 통해 런타임에 다양한 구현체를 주입할 수 있어 테스트 환경에서는 mock 객체를, 프로덕션 환경에서는 실제 구현체를 사용할 수 있습니다.
프론트엔드에서의 의존성 주입
백엔드 생태계는 Dependency Injection Container로 의존성을 관리합니다. Container는 객체 생성과 의존성 주입을 담당하는 도구로, 구현과 의존을 분리함으로써 애플리케이션의 유연성과 테스트 용이성을 높여줍니다.
이와 다르게 프론트엔드 생태계는 Angular를 제외하면 의존성 관리를 위한 프레임워크가 없습니다. Angular 공식 문서에서 Dependency injection in Angular를 설명할 만큼 의존성 주입은 Angular 프레임워크의 핵심 개념 중 하나입니다. DI 시스템에는 두 가지 주요 역할이 있는데, 하나는 dependency provider이고 다른 하나는 dependency consumer입니다. provider는 의존성을 생성하고 관리하는 역할이고, consumer는 의존성을 필요로 하는 컴포넌트입니다.
// 1. 서비스 (dependency provider)
import { Injectable } from "@angular/core";
@Injectable({
providedIn: "root",
})
export class UserService {
...
}
// 2. 컴포넌트 (dependency consumer)
import { Component } from "@angular/core";
export class UserListComponent {
constructor(private userService: UserService) {}
}
이렇게 되면 컴포넌트는 서비스를 직접 생성하지 않고 주입받기 때문에 코드의 결합도는 낮아지고 테스트가 쉬워집니다. 하지만 우리가 주로 사용하는 React에는 DI Container가 없기 때문에 의존성 주입을 위해 다른 방법을 사용해야 합니다.
가장 단순한 방법은 자바스크립트의 모듈 시스템(require
, import
)을 활용하는 것입니다.
테스트에서 vi.mock()
을 사용해 모듈을 모킹할 수 있기 때문입니다.
하지만 외부 모듈을 직접 참조하게 되면 의존성이 많아질수록 테스트 설정이 복잡해지고 코드 간 결합도가 높아진다는 단점이 있어 권장되는 방식은 아닙니다.
// as-is : import로 LogClient 직접 의존
import { LogClient } from "@logging-sdk/react";
function ServicePage() {
useEffect(() => {
LogClient.log(...)
}, [])
return <div>...</div>
}
그렇다면 더 체계적인 의존성 주입은 어떻게 구현할 수 있을까요? 타입스크립트 환경에서는 tsyringe나 inversify 같은 Container 라이브러리를 사용할 수 있습니다. 이런 라이브러리는 의존성을 등록하고 주입하는 과정을 자동으로 관리해 주기 때문에, 의존성 구조가 복잡하거나 런타임에 구현체를 유연하게 바꿔야 할 때 특히 유용합니다. 하지만 단순히 의존성을 주입할 수 있는 구조만 필요하다면 굳이 라이브러리까지 도입하지 않아도 괜찮습니다. 특히 React에서는 컴포넌트에 의존성 주입이 필요한 상황이라면 Context로 충분합니다.
// to-be : Context를 활용해 필요한 LogClient를 전달(주입)
// LogClient.ts
interface LogClientSpec {
log: (...) => void;
}
export class LogClient implements LogClientSpec {}
// App.tsx
function App() {
const client = new LogClient();
return (
<LogContextProvider client={client}>
<ServicePage />
</LogContextProvider>
);
}
// ServicePage.tsx
function ServicePage() {
const client = useContext(LogContext);
useEffect(() => {
client.log(...)
}, [])
return <div>...</div>
}
이처럼 필요한 의존성을 Context로 전달하면, 컴포넌트는 외부 환경과 분리되어 테스트하기 쉬운 구조를 가질 수 있습니다.
test("ServicePage가 렌더링되면 로깅을 한다.", () => {
const log = vi.fn();
// logClient 변수에 담긴 DebugLogClient 인스턴스가 테스트 더블
// DebugLogClient는 실제 외부 시스템 대신 테스트 가능한 mock 함수를 사용
const logClient = new DebugLogClient({ log });
render(<ServicePage />, {
wrapper: ({ children }) => (
<LogContextProvider client={logClient}>
{children}
</LogContextProvider>
),
});
expect(log).toHaveBeenCalled();
});
LogClient.log
를 직접 참조한다면, 구현에 API 호출이나 브라우저 런타임 같은 외부 의존성이 포함된 경우 테스트가 어려워질 수 있습니다.
테스트 환경에서 실행이 불가능한 의존성이 있을 수도 있고, 여러 의존성을 모킹하느라 테스트 코드가 복잡해질 수 있기 때문입니다.
하지만 LogClient
를 생성해 Context로 주입하면, 실제 구현이 외부에 의존하고 있더라도 테스트 환경에서는 원하는 형태로 바꿀 수 있어서 테스트하기 쉬워집니다.
Context가 의존성 주입의 완벽한 해결책인가?
Context를 의존성 주입의 도구로 활용하면 테스트를 조금 더 편하게 작성할 수 있지만, silver bullet이라고 할 수는 없습니다. 아래 예시처럼 의존성을 주입하는 여러 계층의 Provider가 존재하는 경우, callback hell이 떠오르지 않나요? trade-off가 있는 방법이기에 중첩 구조가 너무 깊어지지 않도록 주의해야 합니다.
test("동적으로 약관을 서버에서 불러와 렌더링 한다.", async () => {
render(
<SDKBridgeTestProvider bridge={{ ... }}>
<BasicClientProvider client={{ ... }}>
<AuthClientProvider client={{ ... }}>
<AgreementPage />
</AuthClientProvider>
</BasicClientProvider>
</SDKBridgeTestProvider>
);
expect(await screen.findByText(termTitle)).toBeInTheDocument();
});
마치며
프론트엔드 생태계에서는 Angular를 제외하면 DI Container가 없지만, 다른 분야와 마찬가지로 의존성 주입은 테스트를 용이하게 만들고 코드의 결합도를 낮추는 데 중요한 기법입니다. React에서는 Context를 활용해 의존성 주입의 효과를 충분히 누릴 수 있습니다. 의존성을 제어할 수 있는 구조를 만든다면 프론트엔드 테스팅이 훨씬 수월해질 것입니다. 의존성 주입의 개념과 프론트엔드 테스팅에서의 활용 방법에 대해 이해하는 데 이 글이 도움이 되었기를 바랍니다.