4.Node.js/Vitest&TypeBox

[Vitest] 3편. Vitest의 Lifecycle · Async · Execution Control API

쿼드큐브 2026. 1. 28. 07:58
반응형
반응형

 

3편. Vitest의 Lifecycle · Async · Execution Control API

 

📚 목차
1. Lifecycle Hook API
2. 비동기 테스트 패턴(async/await)
3. 테스트 실행 제어 API
4. 테스트 컨텍스트(Test Context) API

 

vitest 3편 삽화 이미지
vitest 3편 삽화 이미지

 

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

 

1. Lifecycle Hook API

Lifecycle Hook은 테스트 실행 전후에 특정 작업을 수행하기 위한 API입니다.

서버 기동, DB 초기화, Mock 초기화 등 실무에서 매우 자주 사용됩니다.

 

1. 라이프사이클 실행 순서 시각화

🔸 beforeAll / afterAll

    - 테스트 파일 단위로 단 한 번 실행됩니다.
    - 공통 리소스 초기화 및 해제에 적합합니다.

🔸 beforeEach / afterEach

    - 각 테스트 케이스마다 반복 실행됩니다.

    - 테스트 간 상태 격리를 보장하는 역할을 수행합니다.

1. beforeAll (파일 시작 시 1회)
   2. beforeEach (각 테스트 시작 전)
      3. it/test (테스트 케이스 A 실행)
   4. afterEach (각 테스트 종료 후)
   5. beforeEach (각 테스트 시작 전)
      6. it/test (테스트 케이스 B 실행)
   7. afterEach (각 테스트 종료 후)
8. afterAll (파일 종료 시 1회)

일반적으로 “환경 준비는 beforeAll, 상태 초기화는 beforeEach” 로 분리하는 것이 안정적인 패턴입니다.

 

2. 예시

import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';

const mockDb = {
  users: [] as string[],
  connect: async () => console.log('DB 연결 성공'),
  disconnect: async () => console.log('DB 연결 종료'),
  clear: () => {
    mockDb.users = [];
  },
};

describe('라이프 사이클 테스트', () => {
  beforeAll(async () => {
    await mockDb.connect();
  });

  afterAll(async () => {
    await mockDb.disconnect();
  });

  beforeEach(() => {
    mockDb.clear();
    console.log('테스트 데이터 초기화.');
  });

  afterEach(() => {
    mockDb.clear();
    console.log('테스트 데이터 정리.');
  });

  it('새로운 사용자를 추가할 수 있다', () => {
    mockDb.users.push('Alice');
    expect(mockDb.users.length).toBe(1);
    expect(mockDb.users).toContain('Alice');
  });

  it('각 테스트는 독립적인 환경을 가진다(데이터는 비어 있어야 한다.)', () => {
    expect(mockDb.users.length).toBe(0);
  });
});

라이프사이클 예시
라이프사이클 예시

 

3. 중첩된 describe의 실행 순서 이해

describe 블록이 중첩된 경우, 라이프사이클 훅은 바깥 → 안쪽 → 테스트 → 안쪽 → 바깥 순으로 실행됩니다.

describe('outer', () => {
  beforeEach(() => console.log('outer beforeEach'));

  describe('inner', () => {
    beforeEach(() => console.log('inner beforeEach'));

    it('test', () => {
      console.log('test case body');
    });
  });
});

실행 순서

outer beforeEach
inner beforeEach
test case body

 

2. 비동기 테스트 패턴(async/await)

비동기 작업(DB 조회, API 호출 등)은 완료 시점을 예측할 수 없으므로, 테스트 프레임워크가 해당 작업이 끝날 때까지 기다려주도록 만드는 것이 핵심입니다.

 

1. async / await 패턴 (가장 권장됨)

가장 직관적이며 동기 코드와 유사한 흐름으로 작성할 수 있어 실무에서 표준으로 사용됩니다.

// 테스트 대상: 가상의 DB 조회 함수
async function getUserById(id: number): Promise<User> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id, name: 'Alice', role: 'admin' });
    }, 100);
  });
}

it('[ASYNC] 존재하는 사용자의 정보를 반환한다', async () => {
  const user = await getUserById(1);
  expect(user.name).toBe('Alice');
  expect(user.role).toBe('admin');
});

 

2. resolves / rejects

Promise의 상태 변화를 직접 검증할 때 유용하며, 코드가 매우 간결해집니다.

it('[RESOLVES] 성공시 사용자 이름을 포함한다', async () => {
  await expect(getUserById(1)).resolves.toMatchObject({ name: 'Alice' });
});

it('[RESOLVES] 잘못된 ID 입력시 에러를 던진다', async () => {
  const failJob = () => Promise.reject(new Error('Invalid ID'));
  await expect(failJob()).rejects.toThrow('Invalid ID');
});

 

3. Timeout(타임아웃) 제어 : Default Timeout(= 5초)

네트워크 지연이나 무한 루프 등으로 인해 테스트가 무한정 대기하는 것을 방지합니다.
▸ 개별 테스트 설정: 특정 API 호출이 평소보다 느릴 것으로 예상될 때 사용합니다.
▸ 전역 설정: 프로젝트 전체의 기본 성능 가이드라인을 정할 때 사용합니다.

// 1. 테스트 단위 설정
it('[TIMEOUT] 3초 이내에 외부 API 응답을 받아야 한다.', async () => {
  const user = await getUserById(1);
  expect(user.name).toBe('Alice');
}, 3000);

// 2. vitest.config.ts (전역 설정)
export default defineConfig({
  test: {
    // 4. 타임아웃 (유지)
    testTimeout: 10_000,
  },
});

 

4. Promise 반환 패턴

async/await가 도입되기 전 주로 사용되던 방식입니다.

테스트 함수에서 Promise를 직접 return 하면 Vitest가 해당 Promise가 처리될 때까지 기다립니다.

it('[PROMISE] Promise를 return하여 비동기를 제어한다', () => {
  // return이 없으면 expect가 실행되기 전에 테스트가 종료되어 버림
  return getUserById(1).then(user => {
    expect(user.id).toBe(1);
  });
});
반응형

 

3. 테스트 실행 제어 API

개발 생산성을 높이기 위해서는 전체 테스트를 매번 돌리는 것이 아니라, 필요한 부분만 선택적으로 제어할 줄 알아야 합니다.

 

1. only와 skip: 특정 테스트 집중 실행 및 제외

복잡한 버그를 수정하거나, 하나의 기능에만 집중하여 디버깅하고 싶을 때는 전체 테스트를 실행하는 것보다 특정 테스트만 선택적으로 실행하는 것이 훨씬 효율적입니다.

Vitest에서는 it.only, describe.only, it.skip, describe.skip API를 통해 이를 손쉽게 제어할 수 있습니다.

describe('장바구니 기능 테스트', () => {
  // .only: 이 테스트 파일 내에서 이 테스트만 실행합니다.
  it.only('장바구니에 상품을 추가하면 수량이 증가해야 한다', () => {
    const cart = new Cart();
    cart.add('Apple', 1);
    expect(cart.items['Apple']).toBe(1);
  });

  it('상품 수량은 음수가 될 수 없다', () => {
    const cart = new Cart();
    // .only가 위에 있으므로, 이 테스트는 무시됩니다.
    expect(() => cart.add('Apple', -1)).toThrow();
  });

  // .skip: 구현이 미완성되었거나 잠시 꺼두어야 할 때 사용합니다.
  it.skip('할인 쿠폰 적용 로직 (다음 스프린트 구현 예정)', () => {
    // 로직 미구현 상태에서도 전체 테스트 결과에 영향을 주지 않습니다.
  });
});

📌 주의:

it.only를 사용한 채로 코드를 커밋하면 CI(지속적 통합) 환경에서 다른 중요한 테스트들이 누락될 수 있습니다.

커밋 전 반드시 제거했는지 확인하세요!

 

2. describe.concurrent: 테스트 병렬 실행으로 속도 최적화

Vitest는 기본적으로 파일 단위 병렬 실행을 지원하지만, describe.concurrent를 사용하면 하나의 테스트 파일 내부에서도 개별 테스트를 병렬로 실행할 수 있습니다.

병렬 실행 환경에서는 Race Condition, 데이터 충돌, 테스트 비결정성(Flaky Test) 문제가 발생할 수 있으므로, 테스트 간 독립성이 보장되는 경우에만 사용하는 것이 안전합니다.

describe.concurrent('병렬 실행 테스트', () => {
  it('테스트 A', async () => {
    console.log('[A] start:', Date.now()); // 시작 시각 기록
    await delay(1000); // 1초 대기
    console.log('[A] end:', Date.now()); // 종료 시각 기록
    expect(true).toBe(true);
  });

  it('테스트 B', async () => {
    console.log('[B] start:', Date.now()); // A와 거의 동시에 시작됨
    await delay(500); // 0.5초 대기
    console.log('[B] end:', Date.now()); // B가 A보다 먼저 끝남
    expect(true).toBe(true);
  });
});

 

3. 파일 단위 및 키워드 기반 실행

CLI 환경에서는 특정 파일이나 키워드를 기준으로 테스트를 실행하는 방식이 가장 많이 활용됩니다.

 

▸ 특정 파일만 실행

npx vitest tests/services/user.service.test.ts

 

▸ 파일 이름 패턴으로 실행 (예: 'auth'가 들어간 모든 파일):

npx vitest auth

 

▸ UI 모드 활용

npx vitest --ui

 

4. Tag 기반 실행 전략: 테스트 성격 분류

프로젝트 규모가 커질수록 테스트를 성격별로 분류하여 실행하는 전략이 중요해집니다.

Vitest는 기본적으로 테스트 이름 패턴 매칭을 통해 태그 기반 실행을 구현할 수 있습니다.

import { it, expect } from 'vitest';

it('[SMOKE] 결제 승인 API 호출', () => {
  // 핵심 비즈니스 로직 검증
});

it('[SLOW] 대량의 결제 내역 엑셀 다운로드', () => {
  // 시간이 오래 걸리는 작업
});
# 핵심 테스트만 빠르게 실행
npx vitest -t SMOKE

# 느린 테스트 제외 실행 (정규표현식 활용)
px vitest --testNamePattern='^(?!.*SLOW).*'

-t 또는 --testNamePattern 옵션을 활용하면 코드 수정 없이 CLI에서 즉시 실행 대상을 제어할 수 있습니다.

태그 옵션 실행 예시
태그 옵션 실행 예시

 

태그예시

태그 목적
[SMOKE] 핵심 기능 최소 검증
[SLOW] 장시간 실행 테스트
[E2E] 통합 시나리오 테스트
[DB] 데이터베이스 의존 테스트
[FLAKY] 불안정 테스트 관리

 

4. 테스트 컨텍스트(Test Context) API

Vitest는 각 테스트 케이스가 실행될 때마다 고유한 Context 객체를 주입합니다.

이를 통해 테스트 내부에서 동적으로 정보를 추출하거나, 테스트 간의 설정을 공유할 수 있습니다.

 

1. Test Context 기본 구조

테스트 함수의 첫 번째 인자를 구조 분해 할당하여 task, expect 등을 가져올 수 있습니다. 특히 task 객체에는 현재 테스트의 이름, 실행 상태 등 유용한 정보가 담겨 있습니다.

# [실습 예제: 테스트 정보 출력하기]
# task.name: 현재 테스트 케이스의 제목.
# task.type: 테스트 유형 (test, suite 등)
# task.suite: 현재 테스트가 속한 상위 Suite 정보.
# task.id: 테스트의 고유 식별자.

it('Context 기본 구조 테스트', ({ task }) => {
  // console.log(`실행 중인 테스트:{task.name}`);
  console.log(task);
  expect(task.name).toBe('Context 기본 구조 테스트');
  expect(task.type).toBe('test');  // 'test' 또는 'suite'
  expect(task.id).toBeDefined();  // 테스트 고유 식별자
});

 

4. 실무 활용 시나리오

▸ 커스텀 Fixture를 통한 속성 부여

test.extend를 사용하면 테스트에 기능명, 중요도 등의 속성을 안정적으로 부여하고 로그 분류나 디버깅에 활용할 수 있습니다.

it.extend 활용:
it 함수를 확장하여 feature 와 severity 라는 고정값(Fixture)을 주입하는 방식은 매우 효율적입니다.
Vitest에서 it()은 내부적으로 test()와 동일한 동작을 수행하며, 단지 별칭(alias)입니다
import { describe, it, expect } from 'vitest';

describe('속성 주입 기본 구조', () => {
  // it.extend는 기존 it을 변경하지 않고, 설정이 추가된 '새로운 함수'를 반환합니다.
  const billingTest = it.extend({
    feature: 'billing',
    severity: 'critical',
  });

  billingTest('결제 실패 시 포인트 롤백 처리 확인', ({ task, feature, severity }) => {
    // 결과: [BILLING] Task: 결제 실패 시 포인트 롤백 처리 확인
    console.log(`[${feature.toUpperCase()}] Task: ${task.name}`);
    console.log(`[Severity] ${severity}`);

    expect(severity).toBe('critical');
  });
});

# 개별 속성 주입 실습

describe('개별 속성 주입 실습', () => {
  // 특정 테스트 하나에만 즉시 확장을 적용합니다.
  it.extend({
    feature: 'billing',
    severity: 'critical',
    owner: 'QA-Team',
  })('결제 실패 시 롤백 확인', ({ feature, severity, owner }) => {
    console.log(`[실습 A] Severity: ${severity}`); // 결과: critical
    expect(severity).toBe('critical');
  });

  /**
   * [실습 B] 상속 및 독립성 확인
   */
  it.extend({
    feature: 'auth',
    severity: 'medium', // 명시적으로 medium 설정
    owner: 'QA-Team',
  })('로그인 세션 만료 테스트', ({ feature, severity, owner }) => {
    console.log(`[실습 B] Severity: ${severity}`);

    expect(feature).toBe('auth');
    expect(severity).toBe('medium');
  });
});

 

공통 컨텍스트 확장 (hooks 활용)

beforeEach를 사용하면 모든 테스트에서 공통적으로 사용할 리소스(DB 객체, API 클라이언트 등)를 안전하게 주입할 수 있습니다. 이를 통해 코드 중복을 획기적으로 줄입니다.

import { beforeEach, it, expect } from 'vitest';

// 1. 컨텍스트 타입 정의
interface MyTestContext {
  db: {
    query: (sql: string) => Promise<string>;
    status: string;
  };
  userToken: string;
}

// 2. beforeEach에서 공통 리소스 주입
beforeEach<MyTestContext>((context) => {
  context.db = {
    query: async (sql) => `Result for ${sql}`,
    status: 'Connected',
  };
  context.userToken = 'mock-auth-token-123';

  // 테스트 종료 후 정리(Cleanup) 로직 반환
  return () => {
    context.db.status = 'Disconnected';
  };
});

// 3. 확장된 컨텍스트 사용
it<MyTestContext>('사용자 결제 정보 조회 테스트', async (ctx) => {
  const result = await ctx.db.query('SELECT * FROM payments');

  console.log(`DB Status: ${ctx.db.status}`);
  expect(result).toContain('Result for');
  expect(ctx.db.status).toBe('Connected');
});

 

커스텀 리포터 연계 및 대시보드 구축

Vitest 설정(vitest.config.ts)에서 커스텀 리포터를 구성하면, 테스트 결과와 메타데이터를 결합하여 QA 자동화 대시보드를 생성할 수 있습니다.


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

반응형

 

반응형