Jest 핵심 문법 및 시니어 개발자 테스트 설계 전략

핵심 문법 완전 정리

1. 기본 테스트 구조

// 테스트 그룹화
describe('Calculator', () => {
  // 개별 테스트 케이스
  test('should add two numbers', () => {
    expect(add(2, 3)).toBe(5);
  });
  
  // it은 test의 별칭
  it('should subtract correctly', () => {
    expect(subtract(5, 3)).toBe(2);
  });
});

2. 핵심 Matchers

// 동등성 검사
expect(value).toBe(expected);              // Object.is() 사용 (참조 비교)
expect(value).toEqual(expected);           // 깊은 동등성 비교
expect(value).toStrictEqual(expected);     // undefined 필드까지 엄격 비교
 
// Truthiness
expect(value).toBeTruthy();               // if문에서 true로 평가
expect(value).toBeFalsy();                // if문에서 false로 평가
expect(value).toBeNull();                 // null만 매치
expect(value).toBeUndefined();            // undefined만 매치
expect(value).toBeDefined();              // undefined가 아님
 
// 숫자
expect(value).toBeGreaterThan(3);         // > 3
expect(value).toBeGreaterThanOrEqual(3);  // >= 3
expect(value).toBeCloseTo(0.3);           // 부동소수점 근사치
 
// 문자열
expect(string).toMatch(/pattern/);        // 정규식 매치
expect(string).toContain('substring');    // 부분 문자열 포함
 
// 배열/반복가능객체
expect(array).toContain(item);            // 요소 포함
expect(array).toHaveLength(number);       // 길이 확인
 
// 객체
expect(object).toHaveProperty('key');     // 프로퍼티 존재
expect(object).toHaveProperty('key', value); // 프로퍼티 값까지 확인
 
// 예외
expect(() => {
  throw new Error('Something bad');
}).toThrow();                             // 예외 발생 확인
expect(() => {
  throw new Error('Something bad');
}).toThrow('Something bad');              // 특정 메시지 확인
 
// 비동기
await expect(promise).resolves.toBe(value);   // Promise 성공
await expect(promise).rejects.toThrow();      // Promise 실패

3. 모킹 시스템

// Mock 함수 생성
const mockFn = jest.fn();
const mockFnWithReturn = jest.fn(() => 'mocked value');
const mockFnOnce = jest.fn()
  .mockReturnValueOnce('first call')
  .mockReturnValueOnce('second call');
 
// Mock 함수 검증
expect(mockFn).toHaveBeenCalled();                    // 호출됨
expect(mockFn).toHaveBeenCalledTimes(2);             // 호출 횟수
expect(mockFn).toHaveBeenCalledWith(arg1, arg2);     // 특정 인자로 호출
expect(mockFn).toHaveBeenLastCalledWith(arg);        // 마지막 호출 인자
 
// 모듈 모킹
jest.mock('./module', () => ({
  default: jest.fn(),
  namedExport: jest.fn()
}));
 
// 부분 모킹
jest.mock('./module', () => ({
  ...jest.requireActual('./module'),
  specificFunction: jest.fn()
}));
 
// 자동 모킹
jest.mock('./module'); // 모든 export를 jest.fn()으로 대체

4. 생명주기 훅

describe('Test Suite', () => {
  // 모든 테스트 전에 한 번
  beforeAll(async () => {
    await setupDatabase();
  });
  
  // 각 테스트 전에
  beforeEach(() => {
    jest.clearAllMocks();
    setupTestData();
  });
  
  // 각 테스트 후에
  afterEach(() => {
    cleanupTestData();
  });
  
  // 모든 테스트 후에 한 번
  afterAll(async () => {
    await teardownDatabase();
  });
});

5. 고급 테스트 패턴

// 매개변수화된 테스트
describe.each([
  [1, 2, 3],
  [2, 3, 5],
  [3, 4, 7]
])('add(%i, %i)', (a, b, expected) => {
  test(`returns ${expected}`, () => {
    expect(add(a, b)).toBe(expected);
  });
});
 
// 조건부 테스트
test.skipIf(process.env.NODE_ENV === 'production')('dev only test', () => {
  // 개발 환경에서만 실행
});
 
// 실패 예상 테스트
test.failing('known broken feature', () => {
  expect(brokenFunction()).toBe(expectedValue);
});
 
// 비동기 테스트
test('async operation', async () => {
  const result = await asyncFunction();
  expect(result).toBe(expected);
});
 
// 타임아웃과 재시도
jest.setTimeout(10000);                    // 글로벌 타임아웃
jest.retryTimes(3);                       // 실패 시 재시도
 
test('with custom timeout', async () => {
  // 개별 테스트 타임아웃
}, 15000);

시니어 개발자 테스트 설계 전략

1. 테스트 아키텍처 설계

1.1 테스트 피라미드 적용

// 🏗️ 레이어별 테스트 분배 전략
/**
 * E2E Tests (5-10%)     ← 느리지만 중요한 사용자 시나리오
 * Integration (15-25%)  ← 컴포넌트 간 상호작용
 * Unit Tests (70-80%)   ← 빠르고 안정적인 기초
 */
 
// 🎯 단위 테스트: 순수 함수, 유틸리티, 비즈니스 로직
describe('BusinessLogic', () => {
  test('calculateDiscount applies correct percentage', () => {
    const order = { total: 100, customerType: 'premium' };
    expect(calculateDiscount(order)).toBe(15);
  });
});
 
// 🔗 통합 테스트: 서비스 레이어, API 호출, 데이터베이스
describe('UserService Integration', () => {
  test('creates user with hashed password', async () => {
    const user = await userService.create('john', 'password123');
    expect(user.password).not.toBe('password123');
    expect(await bcrypt.compare('password123', user.password)).toBe(true);
  });
});
 
// 🌐 E2E 테스트: 핵심 사용자 플로우만
describe('Critical User Journey', () => {
  test('user can complete purchase flow', async () => {
    await loginAsUser();
    await addItemToCart('premium-product');
    await proceedToCheckout();
    await completePayment();
    await expect(page).toHaveURL(/order-confirmation/);
  });
});

1.2 테스트 조직화 패턴

// 📁 디렉토리 구조
/*
src/
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.test.tsx          ← 단위 테스트
│   │   └── Button.integration.test.tsx ← 통합 테스트
├── services/
│   └── __tests__/                   ← 서비스 테스트 집중
├── utils/
│   └── __tests__/                   ← 유틸리티 테스트
└── __tests__/
    ├── e2e/                         ← E2E 테스트
    ├── fixtures/                    ← 테스트 데이터
    └── helpers/                     ← 테스트 유틸리티
*/
 
// 🏗️ 테스트 빌더 패턴
class UserBuilder {
  private userData: Partial<User> = {};
  
  withEmail(email: string) {
    this.userData.email = email;
    return this;
  }
  
  withRole(role: UserRole) {
    this.userData.role = role;
    return this;
  }
  
  build(): User {
    return {
      id: faker.datatype.uuid(),
      email: faker.internet.email(),
      role: 'user',
      ...this.userData
    };
  }
}
 
// 사용법
const adminUser = new UserBuilder()
  .withEmail('admin@test.com')
  .withRole('admin')
  .build();

2. 모킹 전략

2.1 의존성 주입과 모킹

// ❌ 피해야 할 패턴: 하드코딩된 의존성
class UserService {
  async createUser(data: UserData) {
    const hashedPassword = await bcrypt.hash(data.password, 10); // 직접 의존
    return this.userRepository.save({ ...data, password: hashedPassword });
  }
}
 
// ✅ 권장 패턴: 의존성 주입
class UserService {
  constructor(
    private userRepository: UserRepository,
    private passwordHasher: PasswordHasher  // 추상화된 의존성
  ) {}
  
  async createUser(data: UserData) {
    const hashedPassword = await this.passwordHasher.hash(data.password);
    return this.userRepository.save({ ...data, password: hashedPassword });
  }
}
 
// 테스트에서 쉬운 모킹
describe('UserService', () => {
  let userService: UserService;
  let mockRepository: jest.Mocked<UserRepository>;
  let mockHasher: jest.Mocked<PasswordHasher>;
  
  beforeEach(() => {
    mockRepository = {
      save: jest.fn(),
      findById: jest.fn()
    } as jest.Mocked<UserRepository>;
    
    mockHasher = {
      hash: jest.fn().mockResolvedValue('hashed_password')
    } as jest.Mocked<PasswordHasher>;
    
    userService = new UserService(mockRepository, mockHasher);
  });
});

2.2 스마트 모킹 전략

// 🎯 레이어별 모킹 가이드라인
describe('Service Layer Testing', () => {
  // ✅ 외부 API는 항상 모킹
  beforeEach(() => {
    nock('https://api.external.com')
      .get('/users/123')
      .reply(200, { id: 123, name: 'John' });
  });
  
  // ✅ 데이터베이스는 통합 테스트에서만 실제 사용
  const mockDb = {
    user: {
      create: jest.fn().mockImplementation(async (data) => ({
        id: faker.datatype.number(),
        ...data
      }))
    }
  };
  
  // ✅ 시간 기반 로직은 가짜 타이머 사용
  test('schedules job correctly', () => {
    jest.useFakeTimers();
    const mockCallback = jest.fn();
    
    scheduleJob(mockCallback, 1000);
    jest.advanceTimersByTime(1000);
    
    expect(mockCallback).toHaveBeenCalledTimes(1);
    jest.useRealTimers();
  });
});

3. 테스트 품질 관리

3.1 AAA 패턴 엄격 적용

// ✅ 명확한 AAA 구조
describe('OrderCalculator', () => {
  test('applies senior discount correctly', () => {
    // Arrange - 테스트 데이터 준비
    const customer = createCustomer({ age: 70, membershipYears: 5 });
    const items = [
      createItem({ price: 100, category: 'electronics' }),
      createItem({ price: 50, category: 'books' })
    ];
    const calculator = new OrderCalculator();
    
    // Act - 테스트할 동작 실행
    const result = calculator.calculateTotal(items, customer);
    
    // Assert - 결과 검증 (여러 어설션도 OK, 같은 동작에 대한 것이라면)
    expect(result.subtotal).toBe(150);
    expect(result.discount).toBe(15); // 10% 시니어 할인
    expect(result.total).toBe(135);
    expect(result.appliedDiscounts).toContain('SENIOR_DISCOUNT');
  });
});

3.2 의미 있는 테스트 작성

// ❌ 구현 세부사항 테스트
test('calls setState with correct parameters', () => {
  const component = render(<Counter />);
  fireEvent.click(screen.getByText('Increment'));
  // setState 호출을 테스트하는 것은 의미없음
});
 
// ✅ 사용자 관점에서 테스트
test('increments counter when increment button is clicked', () => {
  render(<Counter initialValue={5} />);
  const incrementButton = screen.getByRole('button', { name: /increment/i });
  
  fireEvent.click(incrementButton);
  
  expect(screen.getByText('6')).toBeInTheDocument();
});
 
// ✅ 비즈니스 규칙 테스트
test('prevents overdraft when insufficient balance', () => {
  const account = new BankAccount(100);
  
  expect(() => account.withdraw(150)).toThrow('Insufficient balance');
  expect(account.getBalance()).toBe(100); // 잔액 변경 없음 확인
});

4. 고급 테스트 패턴

4.1 상태 기반 vs 상호작용 기반 테스트

// 🎯 상태 기반 테스트 (권장: 리팩토링에 강함)
describe('ShoppingCart', () => {
  test('adds item correctly', () => {
    const cart = new ShoppingCart();
    const item = createItem({ id: '1', price: 10 });
    
    cart.addItem(item, 2);
    
    expect(cart.getTotalItems()).toBe(2);
    expect(cart.getTotalPrice()).toBe(20);
    expect(cart.getItems()).toContainEqual(
      expect.objectContaining({ id: '1', quantity: 2 })
    );
  });
});
 
// ⚠️ 상호작용 기반 테스트 (필요한 경우에만)
describe('OrderService', () => {
  test('sends confirmation email after successful order', async () => {
    const mockEmailService = jest.mocked(emailService);
    const orderService = new OrderService(mockEmailService);
    
    await orderService.createOrder(validOrderData);
    
    expect(mockEmailService.sendConfirmationEmail).toHaveBeenCalledWith(
      expect.objectContaining({
        to: validOrderData.customerEmail,
        orderNumber: expect.any(String)
      })
    );
  });
});

4.2 에러 시나리오 철저 테스트

describe('PaymentProcessor', () => {
  // ✅ 다양한 실패 시나리오 커버
  test.each([
    ['invalid card', { cardNumber: '1234', error: 'Invalid card number' }],
    ['expired card', { cardNumber: '4111111111111111', expiryDate: '01/20', error: 'Card expired' }],
    ['insufficient funds', { cardNumber: '4111111111111111', error: 'Insufficient funds' }]
  ])('handles %s gracefully', async (scenario, { error, ...cardData }) => {
    const processor = new PaymentProcessor();
    
    await expect(processor.processPayment({
      amount: 100,
      ...cardData
    })).rejects.toThrow(error);
  });
  
  // ✅ 네트워크 에러 처리
  test('retries on network failure', async () => {
    const mockApiCall = jest.fn()
      .mockRejectedValueOnce(new NetworkError('Connection timeout'))
      .mockRejectedValueOnce(new NetworkError('Connection timeout'))
      .mockResolvedValueOnce({ success: true, transactionId: '123' });
    
    const processor = new PaymentProcessor({ apiCall: mockApiCall, maxRetries: 3 });
    
    const result = await processor.processPayment(validPaymentData);
    
    expect(result.success).toBe(true);
    expect(mockApiCall).toHaveBeenCalledTimes(3);
  });
});

5. 성능 및 안정성

5.1 테스트 성능 최적화

// ✅ 테스트 실행 속도 개선
describe('Large Test Suite', () => {
  // 비용이 큰 설정은 describe 레벨에서
  let expensiveResource: ExpensiveResource;
  
  beforeAll(async () => {
    expensiveResource = await createExpensiveResource();
  });
  
  afterAll(async () => {
    await expensiveResource.cleanup();
  });
  
  // 각 테스트마다 리셋만
  beforeEach(() => {
    expensiveResource.reset();
  });
  
  // 병렬 실행 가능한 테스트 설계
  test.concurrent('independent test 1', async () => {
    const result = await independentOperation1();
    expect(result).toBeDefined();
  });
  
  test.concurrent('independent test 2', async () => {
    const result = await independentOperation2();
    expect(result).toBeDefined();
  });
});

5.2 플레이키 테스트 방지

// ❌ 플레이키 테스트 패턴들
test('flaky test', async () => {
  setTimeout(() => expect(something).toBe(true), 100); // 타이밍 의존
  expect(Math.random() > 0.5).toBe(true); // 랜덤 결과
  expect(new Date().getTime()).toBeGreaterThan(startTime); // 시간 의존
});
 
// ✅ 안정적인 테스트 패턴
test('stable test', async () => {
  // 명확한 대기 조건
  await waitFor(() => {
    expect(screen.getByText('Loaded')).toBeInTheDocument();
  });
  
  // 가짜 타이머 사용
  jest.useFakeTimers();
  const callback = jest.fn();
  scheduleCallback(callback, 1000);
  jest.advanceTimersByTime(1000);
  expect(callback).toHaveBeenCalled();
  jest.useRealTimers();
  
  // 고정된 시드 사용
  const seededRandom = new SeededRandom('test-seed');
  const result = generateWithRandom(seededRandom);
  expect(result).toBe(expectedValue);
});

안티패턴 및 베스트 프랙티스

피해야 할 안티패턴12

// ❌ 1. 과도한 모킹
describe('OverMockedTest', () => {
  test('everything is mocked', () => {
    const mockA = jest.fn();
    const mockB = jest.fn();
    const mockC = jest.fn();
    // 실제 비즈니스 로직은 테스트되지 않음
  });
});
 
// ❌ 2. 테스트에서 로직 사용
test('conditional assertions', () => {
  const result = calculate(input);
  if (result > 0) {
    expect(result).toBeGreaterThan(0);
  } else {
    expect(result).toBeLessThanOrEqual(0);
  }
});
 
// ❌ 3. 구현 세부사항 의존
test('tests implementation details', () => {
  const spy = jest.spyOn(component, 'privateMethod');
  component.publicMethod();
  expect(spy).toHaveBeenCalled();
});

권장 베스트 프랙티스34

// ✅ 1. 명확한 테스트 이름
describe('UserRegistration', () => {
  describe('when user provides valid data', () => {
    test('creates user account and sends welcome email', () => {
      // 테스트 구현
    });
  });
  
  describe('when email is already taken', () => {
    test('returns validation error without creating duplicate', () => {
      // 테스트 구현
    });
  });
});
 
// ✅ 2. 테스트 격리
describe('IsolatedTests', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    resetTestDatabase();
  });
  
  test('test 1 does not affect test 2', () => {
    // 독립적인 테스트
  });
});
 
// ✅ 3. 적절한 추상화
const createTestUser = (overrides = {}) => ({
  id: faker.datatype.uuid(),
  email: faker.internet.email(),
  name: faker.name.fullName(),
  ...overrides
});
 
test('user can update profile', () => {
  const user = createTestUser({ name: 'Original Name' });
  const result = updateUserProfile(user, { name: 'New Name' });
  expect(result.name).toBe('New Name');
});

결론

시니어 개발자의 테스트 설계는 단순히 코드를 검증하는 것을 넘어서 시스템의 안정성과 유지보수성을 보장하는 아키텍처를 구축하는 것이다.

핵심 원칙:

  1. 테스트 피라미드 준수로 비용 효율적인 테스트 전략 수립
  2. 의존성 주입스마트 모킹으로 테스트 가능한 코드 설계
  3. AAA 패턴사용자 관점 테스트로 의미 있는 검증
  4. 에러 시나리오엣지 케이스 철저 커버리지
  5. 플레이키 테스트 방지성능 최적화로 안정적인 테스트 스위트 구축

이러한 전략을 통해 개발팀은 빠른 피드백, 높은 코드 신뢰성, 안전한 리팩토링을 실현할 수 있으며, 궁극적으로 품질 높은 소프트웨어 제품을 지속적으로 배포할 수 있다.

Footnotes

  1. https://dzone.com/articles/testing-the-untestable-and-other-anti-patterns

  2. https://builders.travelperk.com/recipes-to-write-better-jest-tests-with-the-react-testing-library-part-1-670aaf3451d1

  3. https://www.linkedin.com/pulse/best-practices-testing-jest-part-1-lucas-mendonça-5bref

  4. https://geekyants.com/blog/writing-effective-unit-tests-best-practices