4.Node.js/Vitest&TypeBox

[Vitest] 2편. Vitest 테스트 구조 설계와 Assertion, Matcher 이해하기

쿼드큐브 2026. 1. 27. 07:42
반응형
반응형

 

2편. Vitest 테스트 구조 설계와 Assertion, Matcher 이해하기

 

📚 목차
1. 테스트 선언 API : 테스트 구조 설계 이해하기
2. Assertion 진입점 API - expect() 이해하기
3. 주요 Matcher API 이해하기 - 값, 구조, 행위 검증

 

 

📂 [GitHub 예시 코드 보러가기] (https://github.com/cericube/nodejs-tutorials) /vitest

 

1. 테스트 선언 API : 테스트 구조 설계 이해하기

🔷 테스트 계층 구조 : describe와 it(test)

테스트는 크게 그룹(Context)과 사례(Case)로 나뉩니다.

 

1) describe

관련된 테스트 케이스들을 하나의 논리적 그룹으로 묶습니다.
테스트 대상의 범위(Context)를 명확하게 표현합니다.

▸ 특정 클래스
▸ 특정 함수 또는 메서드
▸ 특정 시나리오 (예: 로그인 실패 케이스, 권한 오류 처리 등)

describe('UserService', () => {
  // UserService 관련 테스트들
});

 

2) it (또는 test): 실제 검증이 수행되는 최소 단위

하나의 기대 동작(Assertion)을 검증하는 테스트 케이스입니다.
실제 실행과 검증 로직이 들어가는 부분입니다.

it('should return 200 status', () => {
  // assertion logic
});

🔸 it vs test : 기능적으로 완전히 동일하며, 가독성과 표현 스타일의 차이입니다.

 

🔷 중첩(Nesting) 전략

테스트를 단순히 나열하기보다는, 상태(Context) 중심으로 중첩 구조를 설계하는 것이 유지보수성과 가독성 측면에서 매우 유리합니다.

describe(테스트 대상)
 └─ describe(기능 / 메서드)
     └─ describe(특정 상황 / 조건)
         └─ it(기대 결과)

> Top-level: 서비스 또는 모듈
> Mid-level: 특정 메서드
> Low-level: 입력 조건, 상태, 예외 케이스
> it: 기대 결과

 

예시)

// ProductService 전체에 대한 테스트 그룹
describe('ProductService', () => {
  // ProductService 안의 calculateDiscount() 메서드를 검증하는 그룹
  describe('calculateDiscount()', () => {
    // [상황 A] 사용자가 VIP 회원일 때의 조건을 정의
    describe('when the user is a VIP', () => {
      // VIP 회원이면 20% 할인이 적용되어야 함을 검증
      it('20% 할인율을 적용해야 한다', () => {
        // Arrange: 테스트에 필요한 데이터 준비
        // Act: calculateDiscount() 실행
        // Assert: 결과가 20% 할인인지 검증
      });
    });

    // [상황 B] 사용자가 일반 회원일 때의 조건을 정의
    describe('when the user is a regular member', () => {
      // 일반 회원이면 10% 할인이 적용되어야 함을 검증
      it('10% 할인율을 적용해야 한다', () => {
        // Arrange: 테스트에 필요한 데이터 준비
        // Act: calculateDiscount() 실행
        // Assert: 결과가 10% 할인인지 검증
      });
    });
  });
});

 

🔷 병렬 실행(Parallel Execution)의 기본 원리

테스트 실행 속도는 개발 생산성과 직결됩니다.

Vitest와 Jest는 기본적으로 병렬 실행 구조를 채택하고 있습니다.

 

1) 서로 다른 테스트 파일은 별도의 워커(프로세스/스레드)에서 병렬로 실행됩니다.

병렬 실행 환경에서는 테스트 간 상태 공유가 절대 금물입니다.

auth.test.ts   ─▶ Worker A
user.test.ts   ─▶ Worker B
order.test.ts  ─▶ Worker C

 

2) 파일 내부는 기본적으로 순차 실행

하나의 파일 안에서 정의된 describe와 it는 선언된 순서대로 실행됩니다.

 

2. Assertion 진입점 API - expect() 이해하기

🔷 expect()는 무엇인가

expect()는 테스트에서 검증할 대상(Actual / Received)을 “Assertion 체인”에 올려두는 함수입니다.

▸ 검증하고 싶은 값(함수의 반환값, API 응답, 객체 상태 등)을 expect()에 넣습니다.
▸ 그 다음에 .toBe(), .toEqual(), .toThrow() 같은 matcher를 붙여서 “어떻게 검증할지”를 선언합니다.

expect(actual).toBe(expected);

1. actual (실제값): 검증하고 싶은 대상입니다. 함수 호출 결과, 변수 값 등이 들어갑니다.
2. expect(): 이 실제값을 테스트 환경이 이해할 수 있는 객체로 감쌉니다(Wrapping). 이제 이 객체는 다양한 'Matcher'를 사용할 수 있는 상태가 됩니다.
3. Matcher (.toBe 등): 실제값과 기대값을 비교하는 비교 규칙입니다.
4. expected (기대값): 우리가 "이럴 것이다"라고 예상하는 정답지입니다.

 

🔷 Assertion 실패 메시지 해석

테스트가 실패하면 러너는 왜 실패했는지 최소 단서를 제공합니다.

대표적으로 아래 3가지를 보여줍니다.
1. 어떤 assertion이 실패했는지
2. 어떤 matcher를 썼는지
3. Expected vs Received 차이

 

예시 코드

test('더하기 테스트', () => {
  const result = 1 + 1;
  expect(result).toBe(3); // 일부러 틀린 기댓값을 넣었습니다.
});

 

출력결과

테스트 실패 결과
테스트 실패 결과

(1) expect(received).toBe(expected)
어떤 형태의 assertion이 실패했는지 알려줍니다.
즉, “received와 expected를 toBe로 비교했다”는 뜻입니다.

 

(2) // Object.is equality
toBe가 어떤 비교 규칙을 쓰는지 힌트입니다.
여기서는 Object.is 기반 동일성 비교였음을 명확히 알려줍니다.

 

(3) Expected vs Received
Expected: 테스트 작성자가 기대한 값 (정답으로 가정한 값)
Received: 실제 코드 실행 결과 (현재 코드가 내놓은 값)

 

(4) 코드 위치 표시

 ❯ tests/ch01/math.test.ts:12:20
     10|   test('더하기 테스트', () => {
     11|     const result = 1 + 1;
     12|     expect(result).toBe(3); // 일부러 틀린 기댓값을 넣었습니다.
       |                    ^
     13|   });

반응형

 

3. 주요 Matcher API 이해하기 - 값, 구조, 행위 검증

API 참고 문서: https://vitest.dev/api/

 

1. 값의 일치 여부 확인 (Equality)

가장 기본이 되는 Matcher들입니다. "단순히 값이 같은가?" 혹은 "메모리 주소까지 같은가?"에 따라 선택이 달라집니다

Matcher 설명 예시
toBe() 원시값(Primitive) 비교 및 참조 동일성 비교 (Object.is 기반) 숫자, 문자열, boolean 비교, 동일 객체(메모리 주소) 비교
toEqual() 객체·배열의 값(구조) 비교. 모든 필드를 재귀적으로 검사 API 응답 객체 검증, DTO 구조 검증
toStrictEqual() toEqual()보다 엄격함. 타입, undefined 필드, 클래스 구조까지 검사 클래스 인스턴스 비교, 정밀한 데이터 정합성 검증

 

it('Equality Matchers 실습', () => {
  const user = { name: 'Gemini' };
  const sameUser = user;
  const anotherUser = { name: 'Gemini' };

  // 1. toBe: 참조가 같아야 성공
  expect(user).toBe(sameUser); 
  // expect(user).toBe(anotherUser); // ❌ 실패 (내용은 같으나 메모리 주소가 다름)

  // 2. toEqual: 내용(구조)만 같으면 성공
  expect(user).toEqual(anotherUser); // ✅ 성공

  // 3. toStrictEqual: 타입과 undefined 여부까지 확인
  class User { name = 'Gemini'; }
  expect(new User()).toEqual({ name: 'Gemini' }); // ✅ 성공
  // expect(new User()).toStrictEqual({ name: 'Gemini' }); // ❌ 실패 (class와 일반 object의 차이)
});

 

2. 참/거짓 및 상태 검증 (Truthiness)

Matcher 설명
toBeNull() 오직 null인 경우에만 통과합니다.
toBeUndefined() 오직 undefined인 경우에만 통과합니다.
toBeDefined() toBeUndefined()의 반대 개념으로, 값이 정의되어 있으면 통과합니다.
toBeTruthy() if 문에서 true로 평가되는 모든 값이 통과합니다.예: 1, "hello", [], {}
toBeFalsy() if 문에서 false로 평가되는 모든 값이 통과합니다.예: 0, "", null, undefined, NaN
describe('1. Truthiness 실습', () => {
  test('null 값의 상태 검증', () => {
    const value = null;

    expect(value).toBeNull();          // ✅ null인지 확인
    expect(value).toBeDefined();       // ✅ 정의는 되어 있음 (undefined가 아님)
    expect(value).not.toBeUndefined(); // ✅ undefined가 아님
    expect(value).not.toBeTruthy();    // ❌ null은 참이 아님
    expect(value).toBeFalsy();         // ✅ null은 거짓 같은 값(Falsy)임
  });

  test('undefined 값의 상태 검증', () => {
    const value = undefined;

    expect(value).toBeUndefined();     // ✅ undefined인지 확인
    expect(value).not.toBeNull();      // ❌ null은 아님
    expect(value).not.toBeTruthy();    // ❌ 참이 아님
    expect(value).toBeFalsy();         // ✅ undefined는 거짓 같은 값(Falsy)임
  });

  test('일반 값들의 Truthy/Falsy 판별', () => {
    // Falsy 예시
    expect("").toBeFalsy();      // 빈 문자열은 Falsy
    expect(0).toBeFalsy();       // 숫자 0은 Falsy
    
    // Truthy 예시
    expect("Hello").toBeTruthy(); // 문자열이 있으면 Truthy
    expect(1).toBeTruthy();       // 0이 아닌 숫자는 Truthy
    expect([]).toBeTruthy();      // 빈 배열은 Truthy (JS 특징)
    expect({}).toBeTruthy();      // 빈 객체는 Truthy
  });
});

 

3. 숫자 검증 (Numbers)

matcher 기준 설명
toBeGreaterThan(n) > 값이 n보다 큰지 검증
toBeGreaterThanOrEqual(n) 값이 n보다 크거나 같은지 검증
toBeLessThan(n) < 값이 n보다 작은지 검증
toBeLessThanOrEqual(n) 값이 n보다 작거나 같은지 검증
toBeCloseTo(expected, precision?) 반올림 비교 소수점 오차를 허용하여 비교
describe('2. Numbers 실습', () => {
  it('숫자 크기 비교 검증', () => {
    const weight = 75 + 5; // 80

    expect(weight).toBeGreaterThan(70);           // 80 > 70
    expect(weight).toBeGreaterThanOrEqual(80);    // 80 >= 80
    expect(weight).toBeLessThan(100);             // 80 < 100
    expect(weight).toBeLessThanOrEqual(80);       // 80 <= 80

    // 정확히 일치하는지 확인 (두 개 모두 사용 가능)
    expect(weight).toBe(80);
    expect(weight).toEqual(80);
  });

  it('부동 소수점 오차 검증 (가장 중요)', () => {
    const result = 0.1 + 0.2; // 실제 값: 0.30000000000000004

    // expect(result).toBe(0.3); 
    // ❌ 위 코드는 실패합니다. JS의 부동 소수점 오차 때문입니다.

    // 해결책: toBeCloseTo를 사용합니다.
    // 두 번째 인자 '5'는 소수점 5째 자리까지 반올림해서 확인하겠다는 뜻입니다.
    expect(result).toBeCloseTo(0.3, 5); 
  });

  it('금융/수량 관련 경계값 테스트 예시', () => {
    const balance = 1500.55;
    
    // 최소 잔액 기준 확인
    expect(balance).toBeGreaterThan(1000);
    // 정확한 소수점 금액 확인
    expect(balance).toBeCloseTo(1500.55, 2);
  });
});

 

4. 문자열과 배열 검증 (Strings & Arrays)

Matcher 용도 설명
toMatch(regex) 문자열 패턴 검사 정규표현식을 사용하여 문자열이 특정 형식(이메일, 전화번호 등)을 따르는지 확인합니다.
toContain(item) 배열·문자열 포함 여부 검사 배열에 특정 요소가 존재하는지,
또는 문자열에 특정 부분 문자열이 포함되어 있는지 확인합니다.
toContainEqual(obj) 배열 내 객체 배열 안에 특정 '값을 가진 객체'가 있는지 확인 (참조 무관)
toHaveLength(len) 길이(Size) 검사 배열의 요소 개수나 문자열의 길이를 직접 검증합니다.
import { it, expect, describe } from 'vitest';

describe('데이터 컬렉션 및 문자열 검증', () => {
  
  it('배열(Array) 검증: 목록에 아이템이 포함되어 있는가?', () => {
    const shoppingList = ['Apple', 'Banana', 'Orange'];

    // 1. 특정 아이템이 포함되어 있는지 확인
    expect(shoppingList).toContain('Banana');

    // 2. 배열의 전체 크기가 예상과 일치하는지 확인
    expect(shoppingList).toHaveLength(3);
    
    // 3. (심화) 객체 배열의 경우
    const users = [{ id: 1, name: 'Kim' }, { id: 2, name: 'Lee' }];
    expect(users).toContainEqual({ id: 1, name: 'Kim' });
  });

  it('문자열(String) 검증: 형식이 올바른가?', () => {
    const welcomeMessage = '안녕하세요, Vitest의 세계에 오신 것을 환영합니다!';
    const email = 'test-user@google.com';

    // 1. 특정 문구가 포함되어 있는지 확인
    expect(welcomeMessage).toContain('Vitest');

    // 2. 정규표현식을 이용한 패턴 매칭 (이메일 형식이 맞는지)
    expect(email).toMatch(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/);

    // 3. 특정 단어로 끝나는지 확인
    expect(email).toMatch(/com$/);
  });
  
});

 

5. 객체의 구조 매칭 (Partial Matching)

matcher 설명
toMatchObject(obj) 원본 객체에 기대 객체의 키/값 쌍이 모두 포함되어 있으면 통과합니다.
원본 객체에 다른 속성이 더 많아도 무관합니다.
toHaveProperty(path, val?) 객체 내부에 특정 키가 존재하는지 확인합니다.
점 표기법(a.b.c)으로 중첩된 속성도 탐색할 수 있습니다.
val을 지정하면 해당 값까지 함께 검증합니다.
import { it, expect, describe } from 'vitest';

describe('객체 부분 매칭 및 속성 검증', () => {

  const userResponse = {
    id: 1,
    name: 'Gemini',
    email: 'ai@google.com',
    settings: {
      theme: 'dark',
      notifications: true,
    },
    lastLogin: new Date().toISOString() // 매번 변하는 값
  };

  it('toMatchObject: 중요한 필드만 골라서 검증', () => {
    // lastLogin이나 id가 무엇이든, 이름과 이메일만 맞으면 통과!
    expect(userResponse).toMatchObject({
      name: 'Gemini',
      email: 'ai@google.com'
    });

    // 중첩된 객체의 일부도 검증 가능
    expect(userResponse).toMatchObject({
      settings: { theme: 'dark' }
    });
  });

  it('toHaveProperty: 특정 속성의 존재와 값을 확인', () => {
    // 1. 단순히 속성이 존재하는지 확인
    expect(userResponse).toHaveProperty('id');

    // 2. 중첩된 경로의 값을 확인 (Dot notation 사용)
    expect(userResponse).toHaveProperty('settings.theme', 'dark');
    
    // 3. 값의 타입만 확인하고 싶을 때 (비대칭 매칭 활용)
    expect(userResponse).toHaveProperty('lastLogin', expect.any(String));
  });

});

 

6. 비대칭 매칭 (Asymmetric Matchers)

비대칭 매칭이란, 전체 객체를 비교할 때 특정 필드에 대해서는 "정확한 값" 대신 "특정 조건"만 만족하면 통과시키는 기법입니다.

matcher 의미 사례
expect.any(Type) 특정 타입 또는 생성자의 인스턴스인지 검증 Number, String, Date, Function 등 타입 검사
expect.anything() null, undefined가 아닌 값인지 검증 값이 존재하기만 하면 되는 경우 (존재 여부 확인)
expect.stringContaining(str) 문자열에 특정 문구가 포함되어 있는지 검증 로그 메시지, 에러 문구 일부 확인
expect.stringMatching(regex) 문자열이 정규식 패턴과 일치하는지 검증 토큰, UUID, 해시 등 복잡한 문자열 패턴 검사
expect.arrayContaining(items) 배열이 특정 요소들을 포함하는지 검증 순서와 무관하게 핵심 아이템 존재 여부 확인
expect.objectContaining(obj) 객체가 특정 프로퍼티 집합을 포함하는지 검증 대형 API 응답에서 필요한 필드만 부분 검증
import { it, expect, describe } from 'vitest';

describe('비대칭 매칭 (Asymmetric Matchers) 실습', () => {
  
  it('API 응답 결과 검증 (랜덤 데이터 처리)', () => {
    // 실제 서버에서 올 법한 무작위 데이터
    const apiResponse = {
      id: 42,
      uuid: '550e8400-e29b-41d4-a716-446655440000',
      username: 'vitest_tester',
      roles: ['admin', 'editor', 'viewer'],
      metadata: {
        lastLogin: new Date(),
        ip: '127.0.0.1'
      }
    };

    expect(apiResponse).toEqual({
      // id는 어떤 숫자든 OK
      id: expect.any(Number),
      
      // uuid는 정규식 패턴만 맞으면 OK
      uuid: expect.stringMatching(/^[a-f0-9-]{36}$/),
      
      // username은 정확히 일치해야 함 (일반 값)
      username: 'vitest_tester',
      
      // roles 배열 안에 'admin'과 'editor'가 포함되어 있다면 OK (순서 무관)
      roles: expect.arrayContaining(['admin', 'editor']),
      
      // metadata 객체 내부에 lastLogin이 Date 객체이기만 하면 OK
      metadata: expect.objectContaining({
        lastLogin: expect.any(Date),
        ip: expect.anything() // null/undefined만 아니면 됨
      })
    });
  });

  it('Partial Match (부분 일치) 활용', () => {
    const user = { id: 1, name: 'John', email: 'john@example.com' };
    
    // 전체 객체가 아닌, 내가 관심 있는 필드만 포함되어 있는지 검사
    expect(user).toEqual(expect.objectContaining({
      email: 'john@example.com'
    }));
  });

});

 

7. 예외 및 비동기 처리 (Errors & Promises)

상황 패턴 matcher
동기 함수가 에러를 발생시키는 경우 expect(() => fn()) .toThrow()
비동기 함수가 정상적으로 성공하는 경우 await expect(fn()) .resolves.toBe(...)
비동기 함수가 에러를 발생시키는 경우 await expect(fn()) .rejects.toThrow()

 

📌 핵심 주의사항: 함수로 감싸기

expect(failGracefully())처럼 직접 실행하면, Vitest가 검사하기도 전에 코드가 멈춰버립니다. 반드시 () => ...와 같이 익명 함수로 한 번 감싸서 전달해야 Vitest가 해당 함수를 실행하며 에러를 가로챌 수 있습니다.

 

예시: 에러 발생 검증 (toThrow)

const withdrawMoney = (amount: number) => {
  if (amount < 0) throw new Error('음수 금액은 출금할 수 없습니다.');
};

it('출금 에러 검증', () => {
  // 1. 에러가 발생하는지만 확인
  expect(() => withdrawMoney(-100)).toThrow();

  // 2. 특정 에러 메시지가 포함되어 있는지 확인 (가장 많이 쓰임)
  expect(() => withdrawMoney(-100)).toThrow('음수 금액');

  // 3. 특정 에xtension(클래스) 타입의 에러인지 확인
  expect(() => withdrawMoney(-100)).toThrow(Error);
});

 

예시: 비동기 검증(resolves, rejects)

const fetchUser = async (id: number) => {
  if (id <= 0) throw new Error('Invalid ID');
  return { id, name: 'User' + id };
};

it('비동기 성공 및 실패 검증', async () => {
  // 성공(Resolve) 검증
  await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'User1' });

  // 실패(Reject) 검증
  await expect(fetchUser(0)).rejects.toThrow('Invalid ID');
});

 

예시: 비동기 검증(await)

it('await를 직접 활용한 검증', async () => {
  const result = await fetchUser(10);
  
  expect(result.id).toBe(10);
  expect(result.name).toContain('User');
});

 

예시: 비동기 에러의 트릭 (try-catch 패턴)

it('try-catch를 이용한 정밀한 에러 검증', async () => {
  try {
    await fetchUser(-1);
  } catch (error) {
    expect(error).toBeInstanceOf(Error);
    expect(error.message).toBe('Invalid ID');
  }
});

 

8. 호출 여부 검증 (Spies & Mocks)

상태(결과값)가 아닌 행위(함수가 어떻게 호출되었나)를 테스트합니다.

외부 API 호출, 로그 기록, 이벤트 핸들러 실행 여부 등을 확인할 때 사용합니다.

matcher 설명
toHaveBeenCalled() 함수가 최소 한 번이라도 실행되었는지 검증합니다.
toHaveBeenCalledTimes(n) 정확히 n번 실행되었는지 확인합니다.반복문 내부 로직 검증에 유용합니다.
toHaveBeenCalledWith(...args) 호출 시 전달된 인자들이 예상값과 일치하는지 검증합니다.
toHaveBeenLastCalledWith(...args) 여러 번 호출되었을 때, 가장 마지막 호출에 전달된 인자를 확인합니다.
import { it, expect, vi, describe } from 'vitest';

describe('함수 호출 행위 검증', () => {
  it('Spies & Mocks 실전 활용', () => {
    // 1. 가짜 함수(Mock) 생성
    const sendNotification = vi.fn((message: string, code?: number) => true);

    // 2. 함수 실행 (실제로는 특정 비즈니스 로직 내부에서 실행됨)
    sendNotification('결제가 완료되었습니다.', 200);
    sendNotification('배송이 시작되었습니다.', 300);

    // 3. 검증 시작
    // 최소 한 번은 실행되었는가?
    expect(sendNotification).toHaveBeenCalled();

    // 정확히 2번 실행되었는가?
    expect(sendNotification).toHaveBeenCalledTimes(2);

    // 첫 번째 호출 때 어떤 인자를 받았는가? (정밀 검증)
    // 숫자는 정확한 값 대신 '어떤 숫자든 상관없음'으로 유연하게 검증 가능
    expect(sendNotification).toHaveBeenCalledWith('결제가 완료되었습니다.', expect.any(Number));

    // 가장 마지막 호출의 인자는 무엇인가?
    expect(sendNotification).toHaveBeenLastCalledWith('배송이 시작되었습니다.', 300);
  });

  it('객체 인자 검증 (Partial Matching)', () => {
    const updateUser = vi.fn();

    updateUser({ id: 1, name: 'Alice', role: 'admin' });

    // 객체의 모든 속성을 다 적지 않고, 특정 속성이 포함되어 있는지만 확인
    expect(updateUser).toHaveBeenCalledWith(expect.objectContaining({ name: 'Alice' }));
  });
});

※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

반응형