[번역] 프런트엔드 단위 테스트 모범 사례

Sonny·2022년 11월 17일
64

Article

목록 보기
5/21
post-thumbnail

원문: https://meticulous.ai/blog/frontend-unit-testing-best-practices/

이 가이드에서는 프런트엔드 단위 테스트에 대한 몇 가지 일반적인 모범 사례를 제시합니다. 먼저 각 권장 사항의 이점과 근거를 간략하게 설명한 다음, 테스트 사례들을 개선하기 위해 각 원칙을 실제로 어떻게 적용할 수 있는지에 대한 예시를 살펴보겠습니다.

아래 예제에서는 자바스크립트 및 Jest 테스트 프레임워크를 사용하지만 논의된 원칙은 모든 언어에 광범위하게 적용할 수 있습니다. 하지만 이런 사례들은 규칙이 아닌 모범 사례이므로 예외적인 것들을 염두 해 놓는 것이 중요하다 생각합니다.

1. 테스트에 린트 규칙 사용하기

eslint에서 제공하는 것과 같은 린트 또는 스타일 지정 규칙은 일반적으로 대부분의 최신 프런트엔드 코드 기반에서 표준으로 사용됩니다. 린트는 테스트 코드에서 실행 될 수 없는 코드가 있는 경우, 테스트를 실패로 이어지게 하는 코드들을 IDE에서 자동으로 찾아주는데 도움을 줍니다.

테스트 프레임워크에 사용할 수 있는 린트 규칙과 더 일반적인 테스트 실수를 방지하는 데 도움이 되는 방법을 고려하는 것이 좋습니다.

이유

  • 린트는 일반적인 실수를 자동으로 강조 표시하고 수정 사항을 제안할 수 있습니다.
  • 코드 기반 전체와 많은 기여자 간에 일관성을 보장합니다.
  • 대부분의 최신 IDE와의 통합은 컴파일 시간 전에 작성되는 린트 오류를 추적하고 수정하는 데 도움이 될 수 있습니다.
  • Husky와 같은 도구를 사용하여 pre-commit 훅의 일부로 린트 검사를 자동으로 실행할 수 있습니다. 모든 테스트 코드가 버전 관리에 저장되기 전에 린트 규칙을 통과하는지 확인합니다.

테스트할 예제 코드

// 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 규칙을 설정하는 방법

  • package.json에 아래의 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"]
        }
    ]
},

2. DRY 원칙을 지키세요

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([]);
  });
});

3. describe 블록에서 관련 테스트를 그룹화하기

이유

  • 제목이 좋은 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);
        });
    });
});

4. 단위 테스트는 실패할 이유가 하나만 있어야 합니다.

이유

  • 이름에서 알 수 있듯이 단위 테스트는 코드의 단일 "단위"만 테스트해야 합니다.
  • 하나의 실패 이유만 있는 경우, 테스트 실패의 근본 원인을 식별하는 데 소요되는 시간이 줄어듭니다.
  • SOLID 사례의 단일 책임 원칙을 따릅니다.
  • 더 짧고 읽기 쉬운 단위 테스트를 권장합니다.

테스트할 예제 코드

위에서 설명한 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);
    });
});

5. 테스트를 독립적으로 유지하기

이유

  • 한 테스트의 결과는 다른 테스트에 영향을 미치지 않아야 합니다.
  • 테스트 간에 상태 또는 mock 인스턴스를 공유하면 잘못 통과하는 취약하거나 "깨지기 쉬운(flaky)" 테스트로 이어질 수 있습니다.
  • 새 테스트가 추가되거나 테스트 순서가 변경되면 종속 테스트가 예기치 않게 실패할 수 있습니다.

테스트할 예제 코드

// 콜백 함수를 받고 설정한 밀리초 후에 콜백 함수를 실행하는 함수에 대한 간단한 예시

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();
    });
});

추가 참고 사항

"jest": {
    "resetMocks": true
}
  • create-react-app으로 리액트 프로젝트를 시작하면 내장된 jest 구성에 resetMocks: true가 자동으로 추가됩니다. (문서 참조) 이는 각 테스트 전에 자동으로 jest.resetAllMocks()를 호출하는 것과 같습니다.
  • 테스트가 무작위 순서로 실행되도록 하면 단위 테스트가 독립적이지 않은 경우를 식별하는 데 도움이 될 수 있습니다.

6. 다양하게 입력되는 매개변수 테스트하기

이유

  • 이상적으로 테스트는 테스트 중인 코드를 통해 가능한 모든 코드 경로를 확인해야 합니다.
  • 우리는 배포된 코드가 사용자가 입력할 수 있는 모든 입력을 처리할 수 있다고 확신하고 싶습니다.
  • 우리는 올바른 것으로 알고 있는 코드 경로를 트리거하는 입력을 사용하여 테스트하는 쪽으로 편향될 수 있습니다. 입력 범위를 테스트하면 우리가 고려하지 않았을 수도 있는 엣지 케이스를 확인하는 데 도움이 될 수 있습니다.

테스트할 예제 코드

// 특정 길이의 배열을 생성하고 주어진 값으로 채우는 함수의 예시

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의 예제 테스트에 적용하는 방법을 설명했습니다.

  1. 테스트에 린트 규칙 사용하기
  2. DRY 원칙을 지키세요
  3. describe 블록에서 관련 테스트를 그룹화하기
  4. 단위 테스트는 실패할 이유가 하나만 있어야 합니다.
  5. 테스트를 독립적으로 유지하기
  6. 다양하게 입력되는 매개변수 테스트하기

이것들은 테스트를 작성할 때 고려해야 할 몇 가지 규칙에 불과하므로 프런트엔드 모범 사례에 대해 자세히 알아보려면 Meticulous 블로그의 Frontend Testing Pyramid자바스크립트 UI 테스트 모범 사례에서 찾을 수 있습니다.

읽어 주셔서 감사합니다!

저자 Alex Langdon

profile
FrontEnd Developer

2개의 댓글

comment-user-thumbnail
2022년 12월 30일

각 권장 사항의 이점과 근거를 간략하게 설명하고, 이러한 테스트 사례들을 개선하기 위해 각 원칙을 실제로 어떻게 적용할 수 있는지 자바스크립트 및 Jest 프레임워크를 사용한 예시도 함께 나와 있습니다. official dunkinrunsonyou.com

답글 달기
comment-user-thumbnail
2024년 3월 23일

좋은 글 잘 읽고갑니다.

답글 달기