지금껏 프론트엔드를 개발하면서 부끄럽지만 테스트의 필요성을 딱히 느끼지 못한 것 같다.
물론 이는 사람마다 테스트 코드를 바라보는 관점이 다르고, 현재 자신이 어떤 상황에 처해 있냐가 더 중요하다고 생각한다. 개발 사이클이 빠르고 코드가 자주 변경되는 스타트업이라면 더욱 테스트 코드에 대해서 “언감생심”을 가지기 마련이다.
하지만 최근 Micro Frontend와 비슷하게, 어쩌면 더 이전에 대두되었던 TDD가 프론트엔드 분야를 조금씩 점령하는 느낌이다. 과거와는 달리 Storybook, Vitest 같은 테스트를 보다 유연하고 쉽게 도와주는 테스팅 라이브러리도 많이 나왔으니까 말이다.
이번 글을 통해 내가 테스트 코드에 대해서 가졌던 의문을 정리하고 이에 대한 결론을 하나씩 작성해보려고 한다.
프론트엔드 개발에서 테스트 코드가 주는 이점은 무엇인지? 왜 중요한지?
사실 지금까지는 부끄럽지만 유저 주도 테스트를 (…) 주로 해왔던 것 같다. 추가로 프론트엔드 개발에서 테스트 코드가 주는 이점을 아직 잘 모르겠다는 점도 크다.
기능을 개발하는 경우에도 QA 문서를 작성하고 이를 요청하는 것이 나의 “테스트” 였기에 테스트 코드가 주는 이점을 명확하게 알고 싶었다.
테스트하기 좋은 코드는 무엇이며, 반드시 모든 경우에 대해 테스트를 해야 하는가?
“테스트하기 좋은 코드란 과연 무엇인지?”에 대한 의문은 지금도 유효하다.
지금까지는 “테스트를 위한 변경 없이도 쉽게 테스트 할 수 있는 코드”라고 생각했는데 추가로 고민할 만한 요소가 있을지도 궁금하다.
시간이 된다면 의존성이 강하게 결합된 코드와 이를 해결하는 과정을 서술해보자.
구두로 전달하는 것은 뭐든지 한계가 있기 마련이다.
직접 예시 코드를 작성해보면서 왜 의존성을 분리하는 것이 더 나은 결과를 가져오는지를 살펴보자.
위 내용은 Toss 의 모닥불 에피소드 3회차 영상을 많이 참고했다.
여기서는 고개를 절로 끄덕일 수밖에 없었던 대목이 많았다.
외부 의존성과 얽힌 기능에 대한 테스트를 작성하는 것은 그간 막막하다고 느꼈다.
이 내용을 들으면서 개인적으로는 아래와 같은 생각이 들었다.
새삼 느끼지만 테스트 코드를 하나의 스펙으로 여기려면 정말 코드를 잘 짜야겠다는 생각이 든다.
이 내용을 읽으면서 느낀 점은 테스트 코드의 가장 큰 허들이 “당장의 귀찮음”이라는 것이다.
위의 내용을 총합해서 요약하자면 테스트 코드를 작성함으로써 아래와 같은 이점을 누리게 된다.
개인적으로 가장 와닿았던 내용은 테스트 코드를 하나의 테크스펙으로 바라보는 관점이었다.
사내에서 디자인 리뷰를 하거나 기획 리뷰를 하는데, 이를 잘 활용하면 좋을 것 같았다.
결국은 프론트엔드 개발에서 가장 중요한 것은 사용자라는 것을 다시금 느낀다.
강결합된 두 로직을 분리하는 것이 클린 코드의 시작이라고 말할 만큼 중요하다.
아래 코드는 브라우저의 sessionStorage
를 사용하여 유저 설정을 저장하고 불러오는 로직이다.
// 유저 설정 가져오기
export function getUserSettings(): Record<string, any> {
const rawSettings = sessionStorage.getItem("userSettings") ?? "{}";
return JSON.parse(rawSettings);
}
// 유저 설정 저장하기
export function saveUserSettings(settings: Record<string, any>) {
const settingsString = JSON.stringify(settings);
sessionStorage.setItem("userSettings", settingsString);
}
이 코드는 간단하지만 다음과 같은 문제가 있다.
sessionStorage
에 강하게 결합되어 있어 저장소를 바꾸려면 관련 코드를 전부 수정해야 한다.sessionStorage
를 반드시 Mocking 해야 하므로 테스트 코드 작성이 번거롭다.이를 기반으로 테스트 코드를 작성한 경우는 아래와 같다.
describe("User Settings - Original Implementation", () => {
let mockSessionStorage: Record<string, string>;
beforeAll(() => {
// sessionStorage 모킹
mockSessionStorage = {};
Object.defineProperty(window, "sessionStorage", {
value: {
getItem: jest.fn((key: string) => mockSessionStorage[key] || null),
setItem: jest.fn((key: string, value: string) => {
mockSessionStorage[key] = value;
}),
clear: jest.fn(() => {
mockSessionStorage = {};
}),
},
writable: true,
});
});
beforeEach(() => {
// 초기화
mockSessionStorage = {};
jest.clearAllMocks();
});
it("should get user settings", () => {
const settings = { theme: "dark" };
sessionStorage.setItem("userSettings", JSON.stringify(settings));
const result = getUserSettings();
expect(result).toEqual(settings);
expect(sessionStorage.getItem).toHaveBeenCalledWith("userSettings");
});
it("should return an empty object if no settings are stored", () => {
const result = getUserSettings();
expect(result).toEqual({});
expect(sessionStorage.getItem).toHaveBeenCalledWith("userSettings");
});
it("should save user settings", () => {
const settings = { language: "en" };
saveUserSettings(settings);
expect(sessionStorage.setItem).toHaveBeenCalledWith(
"userSettings",
JSON.stringify(settings)
);
expect(mockSessionStorage["userSettings"]).toBe(JSON.stringify(settings));
});
});
sessionStorage
를 직접 모킹하여 테스트하므로, 테스트가 특정 브라우저 API에 강하게 의존하는 구조를 가진다.sessionStorage
에서 다른 저장소로 변경되면, 모든 테스트 코드도 일괄 수정해야 한다.mockSessionStorage
를 초기화하는 추가 코드가 필요하기에 구조를 복잡하게 만들고 테스트에 대한 신뢰도를 하락시키는 요인이 된다.getUserSettings
와 saveUserSettings
의 핵심 로직과 저장소(sessionStorage
)의 동작이 혼합되어 테스트되기에, 검증하고자 하는 로직에 집중하기 어려운 상태가 된다.위 문제를 해결하기 위해서는 유저 설정 로직과 저장소 접근 로직을 분리해야 할 필요가 있다.
SESSION_STORAGE
구현체를 생성하여 로직을 캡슐화한다.export interface Storage {
get: (key: string) => string | null;
set: (key: string, value: string) => void;
}
// Storage Interface를 구현하는 SESSION_STORAGE 제작.
export const SESSION_STORAGE: Storage = {
get: (key) => sessionStorage.getItem(key),
set: (key, value) => sessionStorage.setItem(key, value),
};
이제 저장소를 외부에서 주입받도록 기존 로직을 수정해보자.
UserSettingManager
Class를 생성하고, Storage Interface를 구현하는 구현체를 받도록 한다.export class UserSettingsManager {
private storage: Storage;
constructor(storage: Storage) {
this.storage = storage;
}
getUserSettings(): Record<string, any> {
const rawSettings = this.storage.get("userSettings") ?? "{}";
return JSON.parse(rawSettings);
}
saveUserSettings(settings: Record<string, any>) {
const settingsString = JSON.stringify(settings);
this.storage.set("userSettings", settingsString);
}
}
위 코드를 기반으로 작성된 테스트 코드는 아래와 같다.
import { UserSettingsManager } from "./UserSettingsManager";
import { type Storage } from "./Storage";
class MockStorage implements Storage {
private store: Record<string, string> = {};
get(key: string): string | null {
return this.store[key] || null;
}
set(key: string, value: string): void {
this.store[key] = value;
}
}
describe("UserSettingsManager", () => {
let mockStorage: MockStorage;
let settingsManager: UserSettingsManager;
beforeEach(() => {
mockStorage = new MockStorage();
settingsManager = new UserSettingsManager(mockStorage);
});
it("should get user settings", () => {
mockStorage.set("userSettings", JSON.stringify({ theme: "dark" }));
const settings = settingsManager.getUserSettings();
expect(settings).toEqual({ theme: "dark" });
});
it("should save user settings", () => {
const newSettings = { language: "en" };
settingsManager.saveUserSettings(newSettings);
expect(mockStorage.get("userSettings")).toBe(JSON.stringify(newSettings));
});
it("should update a specific user setting", () => {
mockStorage.set("userSettings", JSON.stringify({ theme: "light" }));
settingsManager.updateUserSetting("theme", "dark");
const settings = settingsManager.getUserSettings();
expect(settings).toEqual({ theme: "dark" });
});
});
sessionStorage
와의 의존성이 분리되었기에 저장소가 변경되어도 핵심 로직을 수정할 필요가 없어졌다.MockStorage
를 구현하여 외부 의존성 없이 핵심 로직만 테스트하도록 구조를 수정했기에 테스트 코드의 구조 자체가 간소화되었다.sessionStorage
를 더 이상 사용하지 않더라도 별도의 변경 없이 쉽게 의존성을 교체할 수 있다.이번 시간에는 테스트 코드가 과연 중요한지, 어떤 코드가 테스트 코드를 작성하기 쉬운지에 대한 개인적인 고찰을 진행했다. 다음에는 타 오픈소스 라이브러리들의 테스트 코드를 분석하며 각자의 TC 작성 방식에 대한 탐구를 진행하고자 한다.