원문: https://meticulous.ai/blog/frontend-unit-testing-best-practices/
이 가이드에서는 프런트엔드 단위 테스트에 대한 몇 가지 일반적인 모범 사례를 제시합니다. 먼저 각 권장 사항의 이점과 근거를 간략하게 설명한 다음, 테스트 사례들을 개선하기 위해 각 원칙을 실제로 어떻게 적용할 수 있는지에 대한 예시를 살펴보겠습니다.
아래 예제에서는 자바스크립트 및 Jest 테스트 프레임워크를 사용하지만 논의된 원칙은 모든 언어에 광범위하게 적용할 수 있습니다. 하지만 이런 사례들은 규칙이 아닌 모범 사례이므로 예외적인 것들을 염두 해 놓는 것이 중요하다 생각합니다.
eslint에서 제공하는 것과 같은 린트 또는 스타일 지정 규칙은 일반적으로 대부분의 최신 프런트엔드 코드 기반에서 표준으로 사용됩니다. 린트는 테스트 코드에서 실행 될 수 없는 코드가 있는 경우, 테스트를 실패로 이어지게 하는 코드들을 IDE에서 자동으로 찾아주는데 도움을 줍니다.
테스트 프레임워크에 사용할 수 있는 린트 규칙과 더 일반적인 테스트 실수를 방지하는 데 도움이 되는 방법을 고려하는 것이 좋습니다.
// Promise를 사용한 비동기 GET 요청의 예시
function asyncRequest() {
return fetch("some.api.com")
.then(resp => resp.json())
.catch(() => {
throw new Error("Failed to fetch from some.api.com");
})
}
// 잘못된 예시 - jest/valid-expect-in-promise 린트 오류 발생
// 비동기 함수가 resolve 되지 않으면 expect 함수도 resolve 되지 않습니다.
it("should resolve a successful fetch - bad", () => {
asyncRequest().then((data) => {
expect(data).toEqual({ id: 123 });
});
});
// 잘못된 예시 - jest/no-conditional-expect 린트 오류 발생
// 비동기 함수가 오류를 발생시키지 않으면 실행되지 않습니다.
it("should catch fetch errors - bad", async () => {
try {
await asyncRequest();
} catch (e) {
expect(e).toEqual(new Error("Failed to fetch from some.api.com"));
}
});
// 더 나은 예시 - 린트 오류 없음
// promise가 resolve된 이후, expect 함수가 호출되는 것을 보장하기 위해 async와 await을 사용
it('should resolve fetch - better', async () => {
const result = await asyncRequest();
expect(result).toBeDefined();
})
// 더 나은 예시 - 린트 오류 없음
// expect 함수가 항상 호출되는 것을 보장하기 위해 async await을 사용
it("should catch fetch errors - better", async () => {
await expect(asyncRequest()).rejects.toThrow(
new Error("Failed to fetch from some.api.com")
);
});
Jest로 테스트를 작성하기 위한 몇 가지 권장되는 기본 린트 규칙
인기 있는 프런트엔드 JS 테스트 라이브러리에 대한 다른 린트 규칙을 추가하는 것을 고려하세요.
create-react-app
을 사용하여 생성된 일반적인 JS/리액트 프로젝트에 eslint 규칙을 설정하는 방법
eslintConfig
섹션을 추가합니다..jsx
와 .tsx
파일에 대해 권장되는 eslint 규칙이 실행됩니다."eslintConfig": {
"extends": [
"react-app",
"react-app/jest",
"eslint:recommended"
],
"overrides": [
{
"files": ["**/*.js?(x)", "**/*.ts?(x)"],
"plugins": ["jest"],
"extends": ["plugin:jest/recommended"]
}
]
},
beforeEach/afterEach
를 사용해서 여러 테스트에서 반복되는 코드 블록과 유틸리티 기능에 대한 로직을 캡슐화합니다.
beforeEach/afterEach
코드 블록 내에서 자동으로 호출되는 경우, 일반적인 설정/해제(setup/teardown) 논리를 잊어버릴 가능성이 적습니다.// booking 객체를 검증하는 함수의 예시
function validateBooking(booking) {
const validationMessages = [];
if (booking.startDate >= booking.endDate) {
validationMessages.push(
"Error - Booking end date should be after the start date"
);
}
if (!booking.guests) {
validationMessages.push(
"Error - Booking must have at least one guest"
);
}
return validationMessages;
}
// 잘못된 예시 - 유사한 booking 객체에 대한 반복적인 초기화
describe("ValidateBooking - Bad", () => {
it("should return an error if the start and end date are the same", () => {
const mockBooking = {
id: "12345",
userId: "67890",
locationId: "ABCDE",
guests: 2,
startDate: new Date(2022, 10, 10),
endDate: new Date(2022, 10, 10),
};
expect(validateBooking(mockBooking)).toEqual([
"Error - Booking end date should be after the start date",
]);
});
it("should return an error if there are fewer than one guests", () => {
const mockBooking = {
id: "12345",
userId: "67890",
locationId: "ABCDE",
guests: 0,
startDate: new Date(2022, 10, 10),
endDate: new Date(2022, 10, 12),
};
expect(validateBooking(mockBooking)).toEqual([
"Error - Booking must have at least one guest",
]);
});
it("should return no errors if the booking is valid", () => {
const mockBooking = {
id: "12345",
userId: "67890",
locationId: "ABCDE",
guests: 2,
startDate: new Date(2022, 10, 10),
endDate: new Date(2022, 10, 12),
};
expect(validateBooking(mockBooking)).toEqual([]);
});
});
// 더 나은 예시
// 재사용 가능한 createMockValidBooking이라는 팩토리 함수에 중복된 booking 객체 생성이 위임되었습니다.
describe("ValidateBooking - Better", () => {
function createMockValidBooking() {
return {
id: "12345",
userId: "67890",
locationId: "ABCDE",
guests: 2,
startDate: new Date(2022, 10, 10),
endDate: new Date(2022, 10, 12),
};
}
it("should return an error if the start and end dates are the same", () => {
const mockBooking = {
...createMockValidBooking(),
startDate: new Date(2022, 10, 10),
endDate: new Date(2022, 10, 10),
};
expect(validateBooking(mockBooking)).toEqual([
"Error - Booking end date should be after the start date",
]);
});
it("should return an error if there are fewer than one guests", () => {
const mockBooking = {
...createMockValidBooking(),
guests: 0,
};
expect(validateBooking(mockBooking)).toEqual([
"Error - Booking must have at least one guest",
]);
});
it("should return no errors if the booking is valid", () => {
const mockBooking = createMockValidBooking();
expect(validateBooking(mockBooking)).toEqual([]);
});
});
describe
블록은 관련 테스트를 그룹으로 분리하여 테스트 파일을 구성하는 데 도움이 됩니다.describe
블록의 경우, beforeEach/afterEach
를 추가하여 테스트의 하위 집합에 특정한 설정/해제(setup/teardown) 로직을 캡슐화하기 더 쉽습니다. (위의 "DRY 원칙을 지키세요" 규칙 참조)describe
블록은 외부 describe
블록에서 설정 로직을 확장할 수 있습니다.// 단순화된 스택 데이터 구조 클래스의 예시
class Stack {
constructor() {
this._items = [];
}
push(item) {
this._items.push(item);
}
pop() {
if(this.isEmpty()) {
throw new Error("Error - Cannot pop from an empty stack");
}
return this._items.pop();
}
peek() {
if(this.isEmpty()) {
throw new Error("Error - Cannot peek an empty stack");
}
return this._items[this._items.length-1];
}
isEmpty() {
return this._items.length === 0;
}
}
// 잘못된 예시 - 테스트를 그룹화하는 데 사용되는 내부 "describe" 블록이 없습니다.
// 각 테스트 제목의 "on a non-empty stack" 또는 "on an empty stack"의 반복 사용에 대해 유의하세요
describe("Stack - Bad", () => {
it("should return isEmpty as true if the stack is empty", () => {
const stack = new Stack();
expect(stack.isEmpty()).toBe(true);
});
it("should return isEmpty as false if the stack is non-empty", () => {
const stack = new Stack();
stack.push(123);
expect(stack.isEmpty()).toBe(false);
});
it("should throw error when peeking on an empty stack", () => {
const stack = new Stack();
expect(() => stack.peek()).toThrowError(
"Error - Cannot peek an empty stack"
);
});
it("should return the top item when peeking a non-empty stack", () => {
const stack = new Stack();
stack.push(123);
expect(stack.peek()).toEqual(123);
});
it("should throw an error when popping from an empty stack", () => {
const stack = new Stack();
expect(() => stack.pop()).toThrowError(
"Error - Cannot pop from an empty stack"
);
});
it("should return the top item when popping a non-empty stack", () => {
const stack = new Stack();
stack.push(123);
expect(stack.pop()).toEqual(123);
});
});
// 더 나은 예시 - 내부 "describe" 블록을 사용하여 관련 테스트를 그룹화합니다.
// 또한 beforeEach를 사용하여 각 테스트 내에서 반복적인 초기화를 줄입니다.
describe("Stack - Better", () => {
let stack;
beforeEach(() => {
stack = new Stack();
});
describe("empty stack", () => {
it("should return isEmpty as true", () => {
expect(stack.isEmpty()).toBe(true);
});
it("should throw error when peeking", () => {
expect(() => stack.peek()).toThrowError(
"Error - Cannot peek an empty stack"
);
});
it("should throw an error when popping", () => {
expect(() => stack.pop()).toThrowError(
"Error - Cannot pop from an empty stack"
);
});
});
describe("non-empty stack", () => {
beforeEach(() => {
stack.push(123);
});
it("should return isEmpty as false", () => {
expect(stack.isEmpty()).toBe(false);
});
it("should return the top item when peeking", () => {
expect(stack.peek()).toEqual(123);
});
it("should return the top item when popping", () => {
expect(stack.pop()).toEqual(123);
});
});
});
위에서 설명한 Stack
클래스가 이 섹션의 예제로 사용됩니다.
// 잘못된 예시
// 단일 테스트에서 "pop" 함수에 대한 두 개의 개별적인 논리 분기를 확인하고 있습니다.
// 두 가지 expect 함수에서 "stack.pop()"을 두 번 호출합니다.
describe("Stack - Bad", () => {
let stack;
beforeEach(() => {
stack = new Stack();
});
it("should only allow popping when the stack is non-empty", () => {
expect(() => stack.pop()).toThrowError();
stack.push(123);
expect(stack.pop()).toEqual(123);
});
});
// 더 나은 예시
// 각 테스트는 "pop" 함수에 대해 하나의 논리적 분기를 거칩니다.
// 테스트당 하나의 "stack.pop()"를 호출하고 하나의 "expect"를 호출합니다.
describe("Stack - Better", () => {
let stack;
beforeEach(() => {
stack = new Stack();
});
it("should throw an error when popping from an empty stack", () => {
expect(() => stack.pop()).toThrowError();
});
it("should return the top item when popping a non-empty stack", () => {
stack.push(123);
expect(stack.pop()).toEqual(123);
});
});
// 콜백 함수를 받고 설정한 밀리초 후에 콜백 함수를 실행하는 함수에 대한 간단한 예시
function callInFive(callback) {
setTimeout(callback, 5000);
}
// 잘못된 예시 - 아래 테스트는 독립적이지 않습니다.
// mockCallback 함수는 테스트 사이에 재설정되지 않습니다.
// 한 테스트의 타이머는 다음 테스트를 시작하기 전에 지워지지 않습니다.
describe("callInFive - Bad", () => {
beforeEach(() => {
// jest 라이브러리를 사용하여 각 테스트 내 시간 경과를 제어
jest.useFakeTimers();
})
const mockCallback = jest.fn();
it("should not call callback before five seconds elapse", () => {
callInFive(mockCallback);
jest.advanceTimersByTime(5000 - 1);
expect(mockCallback).not.toHaveBeenCalled();
});
it("should call callback after five seconds elapse", () => {
callInFive(mockCallback);
jest.advanceTimersByTime(5000);
expect(mockCallback).toHaveBeenCalled();
});
});
// 더 나은 예시
// 테스트 간에 모킹하는 함수와 진행 중인 타이머를 재설정합니다.
describe("callInFive - Better", () => {
beforeEach(() => {
jest.useFakeTimers();
// 각 테스트를 시작하기 전에 모든 모킹 및 스파이를 재설정하는 것을 잊지마세요.
jest.resetAllMocks();
})
// 남아있는 타이머를 모두 제거하고, 실제 시간 기능이 제대로 동작하도록 복원하는 것을 잊지 마세요.
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
})
const mockCallback = jest.fn();
it("should not call callback before five seconds elapse", () => {
callInFive(mockCallback);
jest.advanceTimersByTime(5000 - 1);
expect(mockCallback).not.toHaveBeenCalled();
});
it("should call callback after five seconds elapse", () => {
callInFive(mockCallback);
jest.advanceTimersByTime(5000);
expect(mockCallback).toHaveBeenCalled();
});
});
resetMocks: true
를 추가하거나 package.json
에 다음을 추가하여 각 테스트 전에 자동으로 모킹을 재설정하도록 Jest 테스팅 라이브러리를 구성할 수 있습니다."jest": {
"resetMocks": true
}
resetMocks: true
가 자동으로 추가됩니다. (문서 참조) 이는 각 테스트 전에 자동으로 jest.resetAllMocks()를 호출하는 것과 같습니다.// 특정 길이의 배열을 생성하고 주어진 값으로 채우는 함수의 예시
function initArray(length, value) {
// 이 if 조건에 오류가 있음을 유의하세요.
// 길이가 0이면 오류가 발생합니다.
if (!length) {
throw new Error(
"Invalid parameter length - must be a number greater or equal to 0"
);
}
return new Array(length).fill().map(() => value);
}
// 잘못된 예시 - 아래의 테스트는 모두 통과하고, 이는 마치 이 테스트들이 모든 코드 경로를 고려한 것처럼 보일 수 있습니다.
// 하지만 JS에서 0은 falsy임을 유의하세요.
// 아래 테스트는 길이가 0 또는 -1인 배열을 생성하려는 경우를 확인하지 않습니다.
describe("initArray - Bad", () => {
it("should create an array of given size filled with the same value", () => {
expect(initArray(3, { id: 123 })).toEqual([
{ id: 123 },
{ id: 123 },
{ id: 123 },
]);
});
it("should throw an error if the array length parameter is invalid", () => {
expect(() => initArray(undefined, { id: 123 })).toThrowError();
});
});
// 더 나은 예시 - 배열을 생성할 때, 길이가 0 또는 -1이라는 엣지 케이스에 대한 테스트를 추가했습니다.
describe("initArray - Better", () => {
it("should create an array of given size filled with the same value", () => {
expect(initArray(3, { id: 123 })).toEqual([
{ id: 123 },
{ id: 123 },
{ id: 123 },
]);
});
it("should handle an array length parameter of 0", () => {
expect(initArray(0, { id: 123 })).toEqual([]);
});
it("should throw an error if the array length parameter is -1", () => {
expect(() => initArray(-1, { id: 123 })).toThrowError();
});
it("should throw an error if the array length parameter is invalid", () => {
expect(() => initArray(undefined, { id: 123 })).toThrowError();
});
});
또한 fast-check와 같은 테스트 프레임워크를 사용하여 처리되지 않은 입력으로 실수를 잡을 수도 있습니다. 이것은 자동으로 코드 커버리지를 개선하고 버그를 찾을 가능성을 높일 수 있는 임의의 값 범위에 대해 기능을 테스트합니다.
// 더 나은 예시 - fast-check와 같은 프레임워크를 사용하여 입력 범위에 대한 테스트 케이스를 생성할 수 있습니다.
// 기본적으로 각 asset 메서드는 임의의 값으로 100번 실행됩니다.
describe("initArray - Better - Using fast-check", () => {
it("should return an array of specified length", () =>
fc.assert(
fc.property(
fc.integer({ min: 0, max: 100 }),
fc.anything(),
(length, value) => {
expect(initArray(length, value).length).toEqual(length);
}
)
));
it("should throw an error if initialising array of length < 0", () =>
fc.assert(
fc.property(
fc.integer({ max: -1 }),
fc.anything(),
(length, value) => {
expect(() => initArray(length, value)).toThrowError();
})
));
});
요약하자면, 이 블로그에서는 프런트엔드 테스트에서 다음과 같은 방법의 이점과 자바스크립트/Jest의 예제 테스트에 적용하는 방법을 설명했습니다.
이것들은 테스트를 작성할 때 고려해야 할 몇 가지 규칙에 불과하므로 프런트엔드 모범 사례에 대해 자세히 알아보려면 Meticulous 블로그의 Frontend Testing Pyramid와 자바스크립트 UI 테스트 모범 사례에서 찾을 수 있습니다.
읽어 주셔서 감사합니다!
저자 Alex Langdon
각 권장 사항의 이점과 근거를 간략하게 설명하고, 이러한 테스트 사례들을 개선하기 위해 각 원칙을 실제로 어떻게 적용할 수 있는지 자바스크립트 및 Jest 프레임워크를 사용한 예시도 함께 나와 있습니다. official dunkinrunsonyou.com