TDD를 직접 경험해보진 않았지만 어떤 방법론인지, 왜 중요한지는 요즘 뼈저리게 느끼고있다🥹 점점 프로젝트가 커지다보니 feat/fix 이후 배포시 두려움이 날로 커져간다.. 지금부터 개발하는 기능이라도 TDD를 적용하고싶은 마음에 한번 살펴봤다.
얼마전 webpack -> vite
로 변경해서 vitest
로 진행해볼까 했지만. 어차피 테스트러너+어설션이고 사용방법은 크게 다르지않을거라 판단해 Jest + @testing-library
의 내용을 살짝 정리해봤다.
테스트 바운더리
지금은 TDD가 정석으로 자리잡아서 TDD자체를 모르는사람은 많이 없을거다 보통 테스트피라미드는 단위 - 통합 - E2E
고 tdd를 말할땐 단위 - 통합
테스트 단계에서 적용된다(적어도 내생각엔 그렇다.)
테스트는 참.. 피곤한 일이다🥹
특히 모종의 이유로 개발 -> 테스트 코드 작성의 흐름으로 진행된다면 정말 지옥이다 (E2E 테스트였긴 하지만 저런 흐름을 겪어봤기때문에 적어도 난 지옥이었다.)
그렇기 때문에 동작되어야 하는 기능, 발생할 수 있는 에러케이스 등을 정리해 Test를 먼저 작성하고, 거기에 맞춰 기능을 개발한다. 그 기능이 살아있다면 테스트 코드역시 평생 유지보수된다. 즉 테스트 바운더리가 넓으면 그만큼 테스트코드가 많아지기때문에 유지보수를 저해한다. 그렇기때문에 테스트 바운더리 설정역시 중요하다.
그래서 보통 User 입장에서 실직적으로 사용되는 핵심기능만 테스트코드가 작성되어야 좋고, FIRST 원칙이라 불리는 좋은 테스트 작성 원칙도 존재한다.
Jest + 설정
예전에는 테스트 러너(Mocha
등)와 어설션(Chai
등)이 별도의 라이브러리로 제공됐었다고 한다. Jest는 테스트러너 + 어설션
이 합쳐진 라이브러리고 Matcher
가 어설션에 해당한다.
import type { Config } from "jest";
const config: Config = {
clearMocks: true,
coverageProvider: "v8",
testEnvironment: "jsdom",
preset: "ts-jest",
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
testPathIgnorePatterns: ["<rootDir>/build"],
};
module.exports = config;
// jest.setup.ts
import "@testing-library/jest-dom";
테스트를 위한 간단한 jest + typescript 설정이고 각 설정은 다음과 같다. 추가적인 설정은 공식문서를 확인하면 좋다.
clearMock
:default = false
테스트전 mock데이터를 지울지 말지 여부다.coverageProvider
:default = "babel"
Jest
가 테스트코드의 커버리지가 올바른지 자동으로 측정해주는 기능이 있는데, 그 측정도구를 선택하는 설정이다. 별도의 babel설정을 안했기때문에 네이티브인v8
을 선택했다.testEnvironment
:default = "node"
테스트에 사용될 환경을 정의한다. 난 web환경에서 테스트할거기 때문에 브라우저환경과 비슷한jsdom
을 선택했다.preset
:default = undefined
jest-config
의 사전설정을 세팅해준다. typescript를 사용할거기 때문에 ts-jest를 별도 설치후 세팅해줬다.setupFilesAfterEnv
:default = []
각 테스트시에 반복되어야 하는 코드의 모듈경로를 받는 옵션이다. jest 기본제공 외 받아와야하는 소스가 있다면 별도의 모듈로 만들어 해당 모듈에 설정하면 된다. 나같은 경우엔@testing-library
가 있겠다.testPathIgnorePatterns
:default = []
이름에서 유추할 수 있듯이 어떤 테스트를 ignore할지 정규식 pattern을 받는다. 테스트 환경에서 어떤 모듈의 import를 무시할지 정하는modulePathIgnorePatterns
옵션도 존재한다.
Basic
계산기기능을 만들고 테스트한다고 생각해보자. 그럼 테스트 -> 로직 순이니까 먼저 계산기로써 갖춰야할 기능과 숫자가 아닌 문자가 들어왔을때 등의 예외처리를 생각해봐야 할것이다. 아래의 코드는 계산기의 "더하기" 기능에 대한 테스트다.
import { Calculator } from "./default";
describe("calculator",() => {
const calculator = new Calculator();
afterEach(() => {
calculator.clear();
});
describe("sum-test",() => {
test("sum",() => {
calculator.set(10);
expect(calculator.sum(10)).toBe(20);
})
test("sum can not be greater than 100",() => {
calculator.set(10);
expect(calculator.sum(300)).toThrow(/error/);
})
})
})
먼저 sum 테스트를 위해 sum-test
라는 그룹을 만들었고, 실제 기능 테스트 + 익셉션을 테스트 총 2개의 테스트를 만들었다. 각 메서드에 설명은 다음과 같다.
describe(name, fn)
: 연관있는 테스트를 그룹화하는 함수다. 반드시 필요한건 아니고 중첩해서 테스트 계층을 만들어 나갈 수 있다.test(name, fn, timeout)
: 테스트를 진행한다. 사실 테스트파일에는 이녀석만 있으면 되긴 하다. 3번째 인자인 timeout은 기본값이 5s이며 해당시간 안에 테스트가 완료되지 않으면 실패처리 된다.expect
: 실직적으로 "테스트"의 의미를 부여해주는 녀석이다. matcher에 접근할 수 있게 해주고, matcher를 이용해 인자로 받은 값의 validation을 체크한다.
calculator 인스턴스가 생성되고 각 테스트마다 calculator.set(10)
을 해준다. calculator는 테스트마다 무결해서 예상가능한 범위에 값이 있어야하는데 테스트가 끝나고 calculator 인스턴스를 초기화해주지 않으면 무결성이 깨지게 될거다.
그렇기 때문에 afterEach 메서드를 이용해서 각 테스트가 끝날때의 행동을 명시해준다. 물론 beforeEach
도 있고 이런 헬퍼 메서드가 많으니 마찬가지로 공식문서를 참조하자.
expect
함수가 특정 값이 맞는값인지 체크하기위해 matcher에 접근할 수 있게 해준다. 값을 인자로받아 matcher(toBe)
로 체크한다. matcher는 다양하게있으니 공식문서를 참조하면 좋다.
중요한건 테스트 -> 로직이라는 거다.
async 테스트
그렇다면 비동기 테스트는 어떻게할까? Promise가 pending에서 벗어난 결과를 테스트해야 할 것이기 때문에 async/await 방식을 쓰거나 then 체이닝 두가지 방식을 사용하게 될건데, 사용법은 모두 직관적이다.
describe("Async", () => {
test("resolve", async () => {
expect.assertions(1);
await expect(fetchProduct("success")).resolves.toEqual({ type: "success" });
});
test("reject", async () => {
expect.assertions(1);
await expect(fetchProduct("error")).rejects.toEqual({ type: "error" });
});
});
describe("Then", () => {
test("resolve", () => {
expect.assertions(1);
return fetchProduct("success").then((res) => {
expect(res).toEqual({ type: "success" });
});
});
test("reject", () => {
expect.assertions(1);
return fetchProduct("error").catch((err) => {
expect(err).toEqual({ type: "error" });
});
});
});
모두 테스트마다 expect.assertions(1)
이 존재하는걸 볼 수 있다. assertions메서드는 해당 테스트안에서 matcher가 1번 호출되어야 한다고 명시해준다. 종종 비동기 테스트에 쓰고 promise가 pending에서 벗어난 이후 callback이 제대로 실행됐는지를 보장하기 위해 사용한다.
then방식에서 유의해야 할 점은. Promise를 반드시 return해주어야한다. 그렇지 않으면 Promise가 실행되고 resolve,reject되기 전에 일단 함수실행은 성공했으니 테스트가 종료되어버린다.
Mock
mock은 다른 코드에서 불리는 함수의 동작을 감지할 수 있게 해준다. jest.fn()
으로 생성할 수 있으며 아무 구현도 없다면 undefined를 return한다.
// check.js
export function check(cb, onSuccess, onFailure) {
if (cb()) {
onSuccess("success");
} else {
onFailure("failure");
}
}
// check.test.js
describe("mock-basic", () => {
const onSuccess = jest.fn();
const onFailure = jest.fn();
test("첫번째 콜백의 리턴이 true일때", () => {
check(() => true, onSuccess, onFailure);
expect(onSuccess.mock.calls.length).toBe(1); // onSuccess가 호출된 횟수
// expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onSuccess.mock.calls[0][0]).toBe("success"); // onSuccess가 호출될때 받은 인자
// expect(onSuccess).toHaveBeenCalledWith("success");
expect(onFailure.mock.calls.length).toBe(0); // onFailure가 호출된 횟수
});
test("첫번째 콜백의 리턴이 false일때", () => {
check(() => false, onSuccess, onFailure);
expect(onFailure).toHaveBeenCalledTimes(1);
expect(onFailure).toHaveBeenCalledWith("failure");
expect(onSuccess).toHaveBeenCalledTimes(0);
});
});
check함수는 콜백함수를 받아 true한 값이면 onSuccess 콜백함수를 "success" 와 함께 호출하고 반대는 "failure"과 함께 onFailure 콜백함수를 실행한다.
onSuccess,onFailure를 mock함수로 만들어서 호출은 몇번됐는지(toHaveBeenCalledTimes
), 올바른 인자로 호출됐는지(toHaveBeenCalledWith
) 테스트 할 수 있다.
위처럼 mock 프로퍼티에 접근해 mock 함수 호출과 관련된 직접적인 값과 비교할건지(onSuccess.mock.calls.length
), 아니면 함수의 호출을 테스트하는 Matcher를 사용할건지(toHaveBeenCalledTimes
) 선택하면 된다.
테스트는 외부환경에 따라서 결과가 바뀌면 안된다. 즉 테스트의 결과는 언제 실행하던 항상 같아야한다. mock은 이런 네트워크나 외부서비스 등 외부환경에 영향을 받는 함수를 테스트할때 용이하다. 대표적으로 api함수 호출이 그러하다.
ES6 Class 테스트
// fetchItem.js
class FetchItem {
fetch(){
return fetch("url").then(item => item);
}
}
// fetchFilteredItem.js
class FetchFilteredItem {
fetchItem;
constructor(){
this.fetchItem = new FetchItem();
}
fetch(){
return this.fetchItem.fetch().then(items => items.filter(item => item.isActive));
}
}
// fetchFilteredItem.test.js
import { FetchItem } from "./fetchItem.js"
jest.mock("./fetchItem.js");
test("fetchFilteredItem",async () => {
FetchItem.mockImplementation(() => ({
fetch:jest.fn().mockResolvedValue([
{id:1,isActive:true,item:"snack"},
{id:2,isActive:false,item:"meet"},
{id:3,isActive:false,item:"egg"}
])
}))
const fetchFilteredItem = new FetchFilteredItem()
expect(fetchFilteredItem.fetch()).resolves.toBe([{id:1,isActive:true,item:"snack"}])
})
jest.mock(module)
은 module에 있는 클래스와 클래스의 모든 메서드를 자동으로 mocking한다.
FetchFilteredItem의 fetch메서드는 FetchItem의 fetch에 의존성을 갖고있기 때문에 fetchItem.js 모듈을 모킹해주고, 테스트코드 내부에서 FetchItem의 fetch메서드를 원하는 테스트밸류를 return하도록 모킹해준뒤 테스트를 진행한다.
mockImplementation메서드
는 mock의 세부 구현을 정의해주고, fetch가 promise기 때문에 mockResolvedValue
를 통해 value를 정해주는거다.
하지만 이러한 방법은 FetchItem과 FetchFilteredItem이 별도의 모듈로 존재하기 때문에 가능하다.
jest.mock()
은 모듈단위로 모킹하기 때문에 별도의 모듈이 아니라면 FetchFilteredItem는 모킹되기전에 이미 FetchItem를 참조하기 때문에 모킹된 FetchItem를 참조할 수 없는거다.
import { FetchItem } from "./fetchItem.js"
jest.spyOn(FetchItem.prototype, "fetch").mockResolvedValue(mockResult);
test("fetchFilteredItem",async () => {
const fetchFilteredItem = new FetchFilteredItem()
expect(fetchFilteredItem.fetch()).resolves.toBe(/*위와 동일*/)
})
그렇기때문에 jest.spyOn()
을 이용해 FetchItem의 prototype에 새로운 mock된 fetch메서드를 덮는 방식으로 진행해주면 FetchFilteredItem의 인스턴스가 fetch메서드를 실행시킬때 모킹된 FetchItem의 fetch 메서드를 실행할거다.
중요한건 prototype을 변경하기때문에 mockClear를 안해주면 다른테스트에도 영향이 가게된다.
@testing-library
UI 컴포넌트 테스트를 유저입장에서 하기 편하게 도와주는 package다. 컴포넌트 하나에는 보통 뷰 + 로직이 담겨있기때문에 위에서 언급한 테스트바운더리 설정이 매우 중요하다. 그래서 더욱더 유저 관점에서 테스트가 작성되어야 한다. 내부구현 로직이 리팩토링 되어도 user입장에서 보이는 컨텐츠가 변하지 않는다면 테스트는 유지될 수 있기 때문이다.
@testing-library
의 api역시 내부구현이 아닌 유저의 시점을 중심으로 개발되어 있다.
// Todos.tsx
const Todos = ({ todos: _todos }: TodosProps) => {
const [todos, setTodos] = useState<Todo[]>(_todos);
return (
<div>
{todos.map((todo) => (
<Todo key={todo.id} {...todo} setTodos={setTodos} />
))}
</div>
);
};
// Todo.tsx
type TodoProps = _Todo & {
setTodos: Dispatch<SetStateAction<_Todo[]>>;
};
export const Todo = ({ setTodos, ...todo }: TodoProps) => {
const isComplete = todo.completeYn === "y";
const deleteHandler = (id: number) => {
setTodos((todos) => todos.filter((todo) => todo.id !== id));
};
const completeToggleHandler = () => {
setTodos((todos) =>
todos.map((_todo) =>
_todo.id === todo.id
? {
..._todo,
completeYn: isComplete ? "n" : "y",
}
: _todo
)
);
};
return (
<div
style={{
backgroundColor: isComplete ? "gray" : "white",
textDecoration: isComplete ? "line-through" : "none",
}}
onClick={completeToggleHandler}
>
{todo.content}
<button onClick={() => deleteHandler(todo.id)}>delete</button>
</div>
);
};
Todos와 Todo는 setTodos를 props로 넘기기때문에 서로 강한 의존성을 가지고있다. 이때 테스트를 어떻게 진행해야 할지가 고민인데 경우의 수 2가지를 생각해봤다.
- 각각 컴포넌트 테스트코드를 작성 후 setTodos는 mock할지
- 의존성이 높으니 상위 컴포넌트인 Todos에서 전부 테스트를 할지
내생각엔 2가 좋을것 같다. 왜냐면 테스트코드도 유지보수대상에 포함되기 때문에 mock을 통한 테스트는 직관적이지 못하다고 생각하고. setter를 mock하는것도 별로 의미가 없어보이기 때문이다.
describe("Todos.tsx", () => {
let todos;
const fakeTodos: _Todo[] = [
{
id: 1,
content: "wash hand",
completeYn: "n",
},
{
id: 2,
content: "wash feet",
completeYn: "n",
},
];
beforeEach(() => {
todos = render(<Todos todos={fakeTodos} />);
});
test("render", () => {
expect(screen.getByText("wash hand")).toBeInTheDocument();
expect(screen.getByText("wash feet")).toBeInTheDocument();
});
test("toggle - style", () => {/* toggle-style 테스트 */});
test("delete - todo", () => {/* delete-todo 테스트 */});
});
우선 컴포넌트가 의도한 대로 렌더링이 됐는지 부터 확인한다. @testing-library
의 render메서드를 이용해 컴포넌트를 렌더링 한뒤 마찬가지로 제공하는 screen을 이용해
화면에 todo에 해당하는 텍스트를 가진 element를 가져오고 화면에 있는지를 확인한다. 이와같이 확인하는 이유는 @testing-library
가 유저입장에서의 테스트를 제공하기 때문이고 유저는 화면을 보기 때문이다.
test("toggle - style", async () => {
const user = userEvent.setup();
const todo = screen.getByText("wash hand");
expect(todo).toHaveStyle({
backgroundColor: "white",
textDecoration: "none",
});
await user.click(todo);
expect(todo).toHaveStyle({
backgroundColor: "gray",
textDecoration: "line-through",
});
});
Todo 컴포넌트는 유저가 클릭시 complete상태가 변하면서 스타일이 바뀌고 해당부분에 대한 테스트는 위와같다. 여기서 completeYn의 값의 변화까지 테스트 하려할 수 있는데. 이는 내부구현이지 고객입장에서는 전혀 필요없는 테스트다. 실직적으로 보이는건 스타일의 변화이기때문에 해당부분을 테스트한다.
userEvent
는 @testing-library
과 함께인 라이브러리를 사용했는데. 과거에는 내장된 fireEvent
를 이용해서 테스트 했다고하고, 아마 이를 기억하고 있는 사람도 있을수도 있다고본다.
간략하게 userEvent가 권장되는 이유는. fireEvent는 단순히 "해당" 이벤트만 발생시킨다. 즉 그 이벤트가 발생하기위한 전체적인 interaction 검사는 하지 않는다는 소리다
// MyTextField.jsx
function MyExtendedTextField() {
return (
<div
onKeyDown={(e) => {
e.preventDefault();
}}
>
<MyTextField />
</div>
);
}
// MyTextField.test.jsx
test('change the text field', () => {
render(<MyTextField/>)
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'bar' } });
expect(input).toHaveValue('bar');
})
MyTextField 컴포넌트는 keyDown이벤트에서 이벤트가 prevent됐기 때문에 실제로 change이벤트가 발생할 수 없다. 하지만 테스트를 보면 input에 직접 change 이벤트를 발생시키고 input의 값이 'bar'인지 테스트하기 때문에 테스트는 통과하는 현상이 발생한다.
이러한 문제들때문에 해당 이벤트에 대해 전체적인 이벤트 상호작용을 검사하는 userEvent
를 써야한다.
// fireEvent - 단순히 click 이벤트만 발생
fireEvent.click(button)
// user-event - 실제 사용자처럼 행동
// 1. 버튼에 포커스가 가고
// 2. 포커스 이벤트가 발생하고
// 3. 마우스가 hover되고
// 4. 클릭이 발생
await userEvent.click(button)
그리고 await이 들어가는걸 볼 수 있는데. userEvent는 다양한 상호작용을 테스트하기때문에 Promise체인으로 구현되어 있다. 실제로 userEvent.click()
의 return-type은 Promise<void>
다.
그렇기 때문에 await을 써줘야 순차적으로 진행되는 상호작용을 보장받을 수 있다.
test("delete - todo", async () => {
const user = userEvent.setup();
const todo = screen.getByText("wash hand");
const deleteBtn = within(todo).getByRole("button");
await user.click(deleteBtn);
expect(todo).not.toBeInTheDocument();
});
정상적으로 화면에서 사라졌는지 테스트까지 완료했다. 같은 toBeInTheDocument() 지만 not
으로 체이닝 되어있는걸 볼 수 있다.
E2E 테스트는 따로 훑어보진 않았다. 컴포넌트 테스트작성과 비슷한 면이 있고, 생략하는 경우도 많기 때문이다. TDD 방법론, jest + @testing-library
의 대략적인 사용 방법을 훑어보면서
핵심은 테스트바운더리 설정인것 같다고 계속 느낀다. 바운더리 설정을 잘해야 테스트코드 유지보수도 줄어들고. 세부적인 구현에대한 테스트작성을 방지할것같기 때문이다.
이제 다훑어봤으니 다음은 vitest
가 왜 주류로 자리잡았는지 알아보고 대략적인 작성방법을 알아볼 계획이다..🥲