단위 테스트 검증

테스트명 짓기

export function add(a,b) {
  const sum = a + b;
  if (sum > 100) {
    return 100;
  }
  
  return sum;
}

//BAD test
test("50 +50은 100", () => {
  expect(add(50,50)).toBe(100);
)

test("70 + 80은 100", () => {
  expect(add(70,80)).toBe(100);
})
  • 테스트는 통과했지만 70 + 80이 100이 된다는 것은 이해할 수 없다.

  • 따라서 테스트 명을 함수 기능에 부합하는 더 적절한 이름을 변경해야 한다.

test("반환값은 첫 번째 매개변수와 두 번째 매개변수를 더한 값이다", () => {
  expect(add(50,50)).toBe(100);
)

test("반환값의 상한은 '100'이다.", () => {
  expect(add(70,80)).toBe(100);
})
  • test 함수를 작성할 때 테스트 코드가 어떤 의도로 작성됐으며, 어떤 작업이 포함됐는지 테스트명으로 명확하게 표현해야다 한다.

에지 케이스와 예외 처리

  • 모듈을 사용할 때 실수 등의 이유로 예상하지 못한 입력값을 보낼 때가 있다.

  • 만약 모듈에 예외 처리를 했다면 예상하지 못한 입력값을 받았을 때 실행 중인 디버거로 문제를 빨리 발견할 수 있다.

타입스크립트로 입력값 제약 설정

  • 타입스크립트를 사용한다면 함수의 매개변수에 타입을 붙여 다른 타입의 값이 할당되면 실행하기 전에 오류를 발생시킨다.

export function add(a:number, b: number) {
  const sum = a + b;
  if(sum > 100){
    return 100;
  }
  return sum
}
  • 하지만 정적 타입을 붙이는 것만으로는 부족할 때가 있다.

  • 예를 들어 특정 범위로 입력값을 제한하고 싶을 때는 런타임에 예외를 발생시키는 처리를 추가해야한다.

예외 발생시키기

  • ex: add 함수에 매개변수 a,b는 0에서 100까지 숫자만 받을 수 있다는 조건을 추가해보자.

  • 타입만으로는 커버할 수 없다.

export function add(a:number, b: number) {
  if (a < 0 || a > 100) {
    throw new Error("0~100 사이의 값을 입력해주세요");
  }
  
  if (b < 0 || b > 100) {
    throw new Error("0~100 사이의 값을 입력해주세요");  
  }
  const sum = a + b;
  if(sum > 100){
    return 100;
  }
  return sum
}
// 잘못된 예외 검증 코드 작성법
expect(add(-10, 110)).toThrow();
// 올바른 작성법 - 화살표 함수를 사용하면 함수에서 예외가 발생하는지 검증할 수 있다.
expect(() => add(-10, 110)).toThrow();

오류 메시지를 활용한 세부 사항 검증

  • 예외 처리용 매처인 toThrow에 인수를 할당하면 예외에 대해 더욱 상세한 내용을 검증할 수 있다.

  • Error 인스턴스를 생성하면서 메시지를 인수로 할당

test("인수가 '0~100'의 범위 밖이면 예외가 발생한다", () => {
  expect(() => add(110, -10)).toThrow('0~100 사이의 값을 입력해주세요");
})
  • 의도적으로 예외를 발생시키기도 하지만 의도치 않은 버그가 생겨서 발생할 때도 있다.

  • 의도했는지 아닌지 구분하기 위해 '의도한 대로 예외가 발생하고 있는가'라는 관점으로 접근하자.

instanceof 연산자를 활용한 세부 사항 검증

  • Error 클래스를 더욱 구체적인 상황에 맞춰 작성해보자.

    • 설계의 폭을 넓힐 수 있다.

  • Error 클래스를 상속받은 두 개의 클래스 HttpError, RangeError 로 생성된 인스턴스는 instanceof 연산자를 사용해서 다른 인스턴스와 구분할 수 있다.

export class HttpError extends Error {}
export class RangeError extends Error {}

if (err instanceof HttpError) {
  // 발생한 오류가 HttpError인 경우
}
if (err instanceof RangeError) { 
 // 발생한 오류가 RangeError인 경우
}
  • 상속받은 클래스들을 활용해 입력값을 체크하는 함수를 작성해보자.

function checkRange(value: number) {
  if (value < 0 || value > 100) {
    throw new RangeError('0~100 사이의 값을 입력해주세요');
  }
}
  • 기존 add 함수에서 a,b를 검증해서 예외를 발생시키는 부분을 checkRange 함수 한곳에서 처리할 수 있어서 더 좋은 코드가 된다.

export function add(a: number, b: number) {
  checkRange(a);
  checkRange(b);
  const sum = a + b;
  if(sum > 100){
    return 100;
  }
  return sum
}
  • toThrow 매처의 인수에는 메시지뿐만아니라 클래스도 할당이 가능하다.

  • 예외가 특정 클래스의 인스턴스인지 검증할 수 있다.

// 발생한 예외가 RangeError이므로 실패
expect(() => add(110,-10)).toThrow(HttpError);
// 발생한 예외가 RangeError이므로 성공
expect(() => add(110, -10)).toThrow(RangeError);
// 발생한 예외가 Error를 상속받은 클래스이므로 성공 - Error를 상속받은 클래스이므로 테스트 성공함 (주의)
expect(() => add(110, -10)).toThrow(Error);

용도별 매처

  • 단언문은 테스트 대상이 기댓값과 일치하는지 매처로 검증한다.

진릿값 검증

  • toBeTruthy 는 참인값과 일치하는 매처

    • toBeFalsy 는 거짓인값과 일치하는 매처

  • 각 매처 앞에 not 을 추가하면 진릿값을 반전시킬 수 있다.

test("참인 진릿값 검증" () => {
  expect(1).toBeTruthy();
  expect(false).not.toBeTruthy();
})

test("거짓인 진릿값 검증" () => {
  expect(0).toBeFalsy();
  expect(true).not.toBeFalsy();
})
  • null 이나 undefinedtoBeFalsy 와 일치한다.

  • 하지만 null 인지 undefined 인지 검증하고 싶을 떄는 toBeNull 이나 toBeUndefined 를 사용

수치 검증

  • 등가 비교나 대소 비교 관련 매처를 사용

  • toBe, toEqual, toBeGreaterThan, toBeLessThen

  • 자바스크립트는 소수 계산에 오차가 있다.

    • 10진수인 소수를 2진수로 변환할 때 발생

    • 소수를 정확하게 계산해주는 라이브러리를 사용하지 않고 계산한 소숫값을 검증할때는 toBeCloseTo 매처를 사용한다.

    • 두 번째 인수로 몇 자릿수까지 비교할것인지 지정 가능

test("소수 계산은 정확하지 않다.", () => {
  expect(0.1 + 0.2).not.toBe(0.3);
})
test("소수 계산 시 지정한 자릿수까지 비교한다.", () => {
  expect(0.1 + 0.2).toBeCloseTo(0.3); // 두번 째 인수의 기본값은 2
  expect(0.1 + 0.2).toBeCloseTo(0.3, 15);
  expect(0.1 + 0.2).not.toBeCloseTo(0.3, 16);
})

문자열 검증

  • 등가 비교 혹은 문자열 일부가 일치하는지 검증하는 toContain

  • 정규표현식을 검증하는 toMatch 같은 매처를 사용

  • 문자열 길이는 toHaveLength로 검증

const str = "Hello World';
test("검증값이 기댓값과 일치한다", () => {
  expect(str).toBe("Hello World");
  expect(str).toEqual("Hello World");
})
test("toContain", () => {
  expect(str).toContain("World");
  expect(str).not.toContain("Bye")
});
test("toMatch", () => {
  expect(str).toMatch(/World/);
  expect(str).not.toMatch(/Bye/);
})
test("toHaveLength", () => {
  expect(str).toHaveLength(11);
  expect(str).not.toHaveLength(12);
})
  • stringContaining 이나 stringMatching 은 객체에 포함된 문자열을 검증할 때 사용한다.

  • 검증할 객체의 프로퍼티중 기댓값으로 지정한 문자열의 일부가 포함됐으면 테스트가 성공한다.

const str = "Hello World";
const obj = { status: 200, message: str };
test("stringContaining", () => {
  expect(obj).toEqual({
    status: 200,
    message: expect.stringContaining("World");
  });
})

test("stringMatching", () => {
  expect(obj).toEqual({
    status: 200,
    message: expect.stringMatching(/World/),
  })
})

배열 검증

  • 배열에 원시형인 특정값이 포함됐는지 확인하고 싶다면 toContain

  • 배열 길이를 검증하고 싶을 때는 toHaveLength

const tags = ["jest", "storybook", "cypress"];
test("toContain", () => {
  expect(tags).toContain("Jest");
  expect(tags).toHaveLength(3);
});
  • 배열에 특정 객체가 포함됐는지 확인할 때는 toContainEqual

  • arrayContaining 을 사용하면 인수로 넘겨준 배열의 요소들이 전부 포함돼 있어야 테스트가 성공한다.

  • 두 매처 모두 등가 비교

test("toContainEqual", () => {
  expect(articles).toContainEqual(article1);
})
test("arrayContaining", () => {
  expect(articles).toEqual(expect.arrayContaining([article1, article3]));
})

객체 검증

  • 객체 검증은 toMatchObject 를 사용

  • 부분적으로 프로퍼티가 일치하면 테스트를 성공시키고 일치하지 않는 프로퍼티가 있으면 테스트 실패

  • 객체의 특정 프로퍼티가 있는지 검증할때는 toHaveProperty 를 사용

const author = { name: "taroyamada", age: 38 };
test("toMatchObject", () => {
  expect(author).toMatchObject({ name: 'taroymada", age: 38 });
  expect(author).toMatchObject({ name: 'taroymada' }); // 부분적으로 일치
  expect(author).not.toMatchObject({ gender: 'man' }); // 일치하지 않는 프로퍼티가 있다.
})
test('toHaveProperty', () => {
  expect(author).toHaveProperty("name");
  expect(author).not.toHaveProperty("gender");
})
  • objectContaining 은 객체 내부 또는 다른 객체를 검증할 떄 사용함

  • 테스트 대상의 프로퍼티가 기댓값인 객체와 부분적으로 일치하면 테스트 성공

const article = {
  title: "Testing with jest",
  author: { name: "taroyamada", age: 38 }
}
test("objectContaining", () => {
  expect(article).toEqual({
    title: "Testing with jest",
    author: expect.objectContaining({name: "taroyamda" })
  })
})

비동기 처리 테스트

  • 자바스크립트 프로그래밍에서 비동기 처리는 필수 요소이다.

  • 외부 API에서 데이터를 취득하거나 파일을 읽는 등 온갖 작업에서 비동기 처리가 필요하다.

export function wait(duration: number) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(duration);
    }, duration)
  })
}

Promise를 반환하는 방법

  • Promise 를 반환하면서 then 에 전달할 함수에 단언문을 작성하는 방법

  • wait 함수를 실행하면 Promise 인스턴스가 생성된다.

  • 해당 인스턴스를 테스트 함수의 반환값으로 리턴하면 Promise 가 처리 중인 작업이 완료될 떄까지 테스트 판정을 유예한다.

test("지정 시간을 기다린 뒤 경과 시간과 함께 resolve 된다", () => {
  return wait(50).then((duration) => {
    expect(duration).toBe(50)
  })
})
  • 두 번째 방법은 resolve 매처를 사용하는 단언문을 리턴하는 것

  • wait 함수가 resolve 됐을 때의 값을 검증하고 싶다면 첫번째 방법보다 간편하다.

test("지정 시간을 기다린 뒤 경과 시간과 함께 resolve된다", () => {
  return expect(wait(50)).resolves.toBe(50);
})

async/await을 활용한 방법

  • 테스트 함수를 async 함수로 만들고 나서 함수 내에서 Promise가 완료될 때까지 기다리는 방법

  • resolves 매처를 사용하는 단언문도 await 로 대기시킬 수 있다.

test("지정 시간을 기다린 뒤 경과 시간과 함께 resolve 된다", async () => {
  await expect(wait(50)).resolves.toBe(50);
})
  • 네 번째 방법은 검증값인 Promise가 완료되는 것을 기다린 뒤 단언문을 실행하는 것

  • 가장 간단한 방법

test("지정 시간을 기다린 뒤 경과 시간과 함께 resolve 된다", async () => {
  expect(await wait(50)).toBe(50);
})
  • async/await 함수를 사용하면 비동기 처리가 포함된 단언문이 여럿일 때 한 개의 테스트 함수 내에서 정리할 수 있는 장점이있음

Reject 검증 테스트

  • 반드시 reject 되는 코드의 함수를 사용해 reject 된 경우를 검증하는 테스트를 작성

export function timeout(duration: number) {
  return new Promise((_, reject) => {
    setTimeout(() => {
      reject(duration);
    }, duration)
  })
}
// Promise를 리턴하는 방법 - catch 메서드에 전달할 함수에 단언문을 작성
test("지정 시간을 기다린뒤 경과 시간과 함께 reject 된다.", () => {
  return timeout(50).catch((duration) => {
    expect(duration).toBe(50);
  })
})

// rejects 매처를 사용하는 단언문 활용 - 단언문을 리턴하거나 async/await을 사용
test("지정 시간을 기다린뒤 경과 시간과 함께 reject 된다.", () => {
  return expect(timeout(50)).reject.toBe(50);
})

test("지정 시간을 기다린뒤 경과 시간과 함께 reject 된다.", async () => {
  await expect(timeout(50)).reject.toBe(50);
})

// try/catch 문을 사용하는 방법 Unhandled Rejection을 try 블록에서 발생시키고, 
// 발생한 오류를 catch 블록에서 받아 단언문으로 검증
test("지정 시간을 기다린뒤 경과 시간과 함께 reject 된다.", async () => {
  expect.assertions(1);  // 단언문이 한 번 실행되는 것을 기대하는 테스트가 된다.
  try {
    await timeout(50);
  } catch (err) {
    expect(err).toBe(50);
  }
})

테스트 결과가 기댓값과 일치하는지 확인

  • 실수로 작성된 코드가 있을 때 실행하고 싶은 단언문에 도달하지 못한 채로 성공하며 종료된다.

test("지정 시간을 기다린뒤 경과 시간과 함께 reject 된다.", async () => {
  try {
    await wait(50); // timeout 함수를 사용할 생각이었지만 실수로 wait 함수를 사용함
    // 오류가 발생하지 않으므로 여기서 종료되면서 테스트는 성공
  } catch (err) {
    // 단언문은 실행되지 않음
    expect(err).toBe(50);
  }
})
  • 이와 같은 실수를 하지 않으려면 테스트 함수 첫 줄에 expect.assertions를 호출해야 한다.

  • 이 메서드는 실행되어야 하는 단언문의 횟수를 인수로 받아 기대한 횟수만큼 단언문이 호출됐는지를 검증한다.

test("지정 시간을 기다린뒤 경과 시간과 함께 reject 된다.", async () => {
  expect.assertions(1); // 단언문이 한 번 실행되는 것을 기대하는 테스트가 된다.
  try {
    await wait(50); 
    // 단언문이 한 번도 실행되지 않은채로 종료되므로 테스트는 실패
  } catch (err) {
    expect(err).toBe(50);
  }
})
  • 비동기 처리 테스트를 할 때는 첫 번째 줄에 expect.assertions 을 추가하면 사소한 실수를 줄일 수 있다.

  • 비동기 처리 테스트는 다양한 방식으로 작성할 수 있으니 자유롭게 선택하면 되지만 .resolves, .rejects 매처를 사용할 떄는 주의해야 한다.

  • 테스트가 성공한것이 아니라 (*주의)단언문이 한 번도 평가되지 않고 종료될 때에도 테스트는 성공한다.

test("return하고 있지 않으므로 Promise가 완료되기 전에 테스트가 종료된다.", () => {
  // 실패할 것을 기대하고 작성한 단언문
  expect(wait(2000)).resolves.toBe(3000);
  // 올바르게 고치려면 다음 주석처럼 단언문을 return 해야 한다.
  // return expect(wait(2000)).resolves.toBe(3000);
})
  • 비동기 처리를 테스트할 때 테스트 함수가 동기 함수라면 반드시 단언문을 return 해야 한다.

  • 테스트에 따라서는 단언문을 여러 번 작성해야 할 때가 있는데 단언문을 여러번 작성하다 보면 return 하는 것을 잊기 쉽다.

  • 이같은 실수를 하지 않으려면 비동기 처리가 포함된 테스트를 할때 다음 과 같은 규칙을 갖고 접근해야 한다.

    • 비동기 처리가 포함된 부분을 테스트할 때 테스트 함수를 async 함수로 만든다.

    • .resolves, .rejects 가 포함된 단언문은 await 한다.

    • try-catch 문의 예외 발생을 검증할 때는 expect.assertions 를 사용한다.

Last updated