Back to the Basics

[TEST]테스트 코드를 어떻게 시작하면 좋을까 본문

Backend Development/General

[TEST]테스트 코드를 어떻게 시작하면 좋을까

9Jaeng 2024. 11. 10. 14:34
728x90

이전 포스팅 [TEST] 테스트 원칙에 대하여 - 소프트웨어 테스트의 7원칙과 FIRST원칙  에서는 효과적인 테스트 코드 작성을 위해 참고할만한 원칙들을 알아보았다.  

이 두 가지 원칙을 요약해 보았다.

1. 테스트의 가장 큰 목적은 문제점을 찾는 것이다. 이를 위해 문제가 될 수 있는 다양한 케이스에 대해 수행되어야 한다.

2. 모든 것을 테스트하는 것은 불가능하므로 중요한 부분(비즈니스!)에 집중되어야 한다. 

3. 테스트는 구현이 아닌 명확한 결과로 성공 여부를 판단할 수 있어야 한다(True or False)

4. 테스트는 빠르고 독립적이며, 실행할 때마다 동일한 결과는 제공해야 한다

5. 테스트코드는 코드작성과 동시에 또는 그전에 설계되는 것이 이상적이다

이번 포스팅에서는 이런 원칙들을 기반으로 테스트코드를 어떻게 시작하면 좋을까에 대해서 알아봤던 내용에 대해서 간략하게 정리하고자 한다.

참고로 예시 코드는 typescript와 jest로 작성하였다

목차는 아래와 같다

1. 구조를 어떻게 잡아야 할까?

2. 어떤 케이스를 테스트해야 할까?

3. 읽기 좋은 테스트 코드는 무엇일까?

4. 마치며

1. 구조를 어떻게 잡아야 할까?

당연한 이야기겠지만 코드든 글이든 명확한 구조가 필요하다. 그래야 코드나 글이 무엇을 이야기하려는지 쉽게 이해하고 예측할 수 있다. 이는 가독성과도 관련이 있다.

코드를 작성할 때 구조를 고민하듯, 테스트 코드도 큰 틀을 잡아 작성해야 한다. 그래야 테스트코드를 읽을 때 맥락을 이해하기 쉽다.

이를 위한 대표적인 패턴들이 있다. 바로 아마 한 번쯤은 들어봤을 법한 AAA 패턴과 Given-When-Then 패턴이다.

둘 다 비교해 보면 비슷한 느낌이 있기 하다.

1. AAA 패턴

Arrange-Act-Assert의 약자이다.  각각은 아래와 같은 의미를 갖는다

Arrange: 테스트에 필요한 초기 상태를 설정한다. mocking 또는 입력값 등이 해당된다.

Act: 테스트하려는 동작을 수행한다. 보통 테스트 타깃을 호출한다

Assert: 결과가 예상대로 나오는지 확인한다. 

it('정확한 사용자 정보를 제공받으면 성공적으로 사용자를 생성해야한다', async () => {
  // Arrange
  const newUser = { name: 'Jaeng', email: 'Jaeng@example.com' };
  jest.spyOn(userRepository, 'save').mockResolvedValue(newUser);

  // Act
  const result = await userService.createUser(newUser);

  // Assert
  expect(result).toEqual(newUser);
  expect(userRepository.save).toHaveBeenCalledWith(newUser);
});

이렇게 주석 등으로 3개의 영역을 미리 만들어놓고 테스트코드를 작성하면 된다.

이 방법은 테스트 대상을 설정과 검증 단계로부터 명확하게 분리하고 테스트 메서드가 한 번에 많은 것을 검증하려고 할 때 그 냄새를 감지하기 쉽다는 장점이 있다고 한다. 

2. Given-When-Then

마틴 파울러도 언급했던 방식이다. 행동중심개발(BDD - Behavior-Driven Development)의 한 부분이라고 한다. 

Given-When-Then의 의미는 아래와 같다

Given:  테스트 동작을 실행하기 전의 초기 상태를 설정한다. 테스트의 사전 조건

When: 특정 이벤트나 동작을 발생시킨다. (지정한 동작)

Then: 지정된 동작으로 인해 예상되는 변경 상황을 설명

it('정확한 사용자 정보가 제공되면 성공적으로 유저를 생성해야한다', async () => {
    // Given
    const newUser = { name: 'Jaeng', email: 'Jaeng@example.com' };
    jest.spyOn(userRepository, 'save').mockResolvedValue(newUser);

    // When
    const result = await userService.createUser(newUser);

    // Then
    expect(result).toEqual(newUser);
    expect(userRepository.save).toHaveBeenCalledWith(newUser);
  });

Given-When-Then 링크에서 언급했듯이 AAA와 기본적인 아이디어 - "테스트 단계를 명확하게 나누고, 가독성을 높이며 테스트 의도를 쉽게 파악할 수 있도록 하는 것"은 같다.

이런 패턴들을 활용하여 테스트코드들의 구조를 유지하도록 해보자

 

2. 어떤 케이스를 테스트해야 할까?

이전에 포스팅에서 이야기했던 7원칙에 따르면 완벽한 테스팅은 불가능하며 중요한 부분에 집중되어야 한다고 했다. 또한, 테스트는 문맥에 의존한다고 했다. 이를 종합하면, 테스트는 비즈니스 로직에 집중해야 한다는 의미로 해석할 수 있다.

테스트의 목적은 잠재적인 결함을 찾아내어 오류 발생 가능성을 줄이는 것 이다. 따라서 긍정 케이스보다 다양한 부정 케이스를 테스트 코드로 확인하는 것이 중요하다. 이렇게 함으로써 예기치 않은 오류를 미리 방지할 수 있다.

이를 고려하여 아래와 같은 비즈니스 로직이 있다고 해보자.

아래의 코드는 결제서비스의 결제 기능에 대한 간단한 테스트케이스이다.

 it('잔액이 충분할 때 결제를 성공적으로 처리한다', async () => {
    // Given: 충분한 잔액, 활성 계정, 적절한 거래 한도 설정
    const user = { id: 1, balance: 100, isActive: true, transactionLimit: 50 };
    const amount = 50;

    // When: 결제 시도
    const result = await paymentService.processPayment(user, amount);

    // Then: 결제가 성공적으로 처리됨을 확인
    expect(result).toEqual({ success: true });
  });

위 테스트 코드는 긍정 케이스로, 잔액이 충분하고 계정이 활성화되어 있으며 거래 한도 내에서 결제가 성공적으로 처리되는 경우를 보여준다. 그러나 이러한 긍정적인 결과는 한 가지뿐이지만, 사용자 입장에서의 부정적 상황은 매우 다양할 수 있다.

부정적인 케이스들을 살펴보면, 결제를 하려는 사용자의 잔액이 부족한 경우가 있을 수 있고 해당 사용자가 비활성화된 계정일 수도 있다. 또, 그날의 거래 한도가 초과된 경우도 존재할 수 있다. 이러한 경우들은 모두 “결제”라는 비즈니스 로직에서 발생 가능한 부정적 상황이며, 각각 예외 처리가 필요한 케이스이다.

이런 여러 부정 케이스들을 아래와 같이 테스트코드로 담아내고 테스트를 하면서 코드를 작성할 수 있다.

  // 부정 케이스 1: 잔액이 부족한 경우
  it('잔액이 부족할 때 InsufficientBalanceException 예외를 발생시킨다', async () => {
    // Given: 잔액이 부족한 설정
    const user = { id: 1, balance: 20, isActive: true, transactionLimit: 50 };
    const amount = 50;

    // When & Then: 결제 시도 시 예외 발생 확인
    await expect(paymentService.processPayment(user, amount)).rejects.toThrow(InsufficientBalanceException);
  });

  // 부정 케이스 2: 계정이 비활성화된 경우
  it('계정이 비활성화된 경우 InactiveAccountException 예외를 발생시킨다', async () => {
    // Given: 비활성화된 계정 설정
    const user = { id: 1, balance: 100, isActive: false, transactionLimit: 50 };
    const amount = 50;

    // When & Then: 결제 시도 시 예외 발생 확인
    await expect(paymentService.processPayment(user, amount)).rejects.toThrow(InactiveAccountException);
  });

  // 부정 케이스 3: 거래 한도를 초과한 경우
  it('거래 한도를 초과할 때 ExceededLimitException 예외를 발생시킨다', async () => {
    // Given: 거래 한도가 낮은 설정
    const user = { id: 1, balance: 100, isActive: true, transactionLimit: 30 };
    const amount = 50;

    // When & Then: 결제 시도 시 예외 발생 확인
    await expect(paymentService.processPayment(user, amount)).rejects.toThrow(ExceededLimitException);
  });

즉, 테스트는 핵심 비즈니스에 집중되어야 하고 그 비즈니스가 실패할 수 있는 케이스들을 사용자 관점에서 고민해 보아야 한다.

그리고 두 번째로 "Self-Validating : 테스트는 스스로 결과물이 옳은지 그른지 판단할 수 있어야 한다"는 원칙에서와 같이 테스트는 내부 구현사항에 의존하는 것이 아니라 True or False , 성공 또는 실패와 같은 명확한 결과를 검증해야 한다.

가령 아래와 같이 배송비를 계산하는 로직에서 구매 상품 가격에 따라 무료배송이 적용될 수 도 있고 특정 지열에 따라 할증이 부가된다고 가정해 보자.

export class ShippingService {
  calculateShippingCost(orderAmount: number, region: string): number {
    const baseCost = this.calculateBaseShipping(orderAmount);
    const regionalSurcharge = this.calculateRegionalSurcharge(region);
    return baseCost + regionalSurcharge;
  }

  private calculateBaseShipping(orderAmount: number): number {
    //
  }

  private calculateRegionalSurcharge(region: string): number {
    //
  }
}

 

내부 구현사항인 calcuateBaseShipping과 calculateRegionalSurcharge를 고려하여 테스트케이스를 작성한다면 아래와 같을 것이다. (간단한 코드이지만 조금 더 복잡한 내부 구현사항이라고 생각해 보자)

// calculateShippingCost 테스트코드
it('주문 금액이 100 이상이면 기본 배송비가 무료여야 한다.', () => {
    const orderAmount = 120;
    const region = 'urban';

    const baseShippingSpy = jest.spyOn(shippingService, 'calculateBaseShipping').mockReturnValue(0);
    const regionalSurchargeSpy = jest.spyOn(shippingService, 'calculateRegionalSurcharge').mockReturnValue(0);

    const cost = shippingService.calculateShippingCost(orderAmount, region);

    expect(baseShippingSpy).toHaveBeenCalledWith(orderAmount);
    expect(regionalSurchargeSpy).toHaveBeenCalledWith(region);
    expect(cost).toBe(0);
  });

 

위의 테스트코드의 타깃은  "calculateShippingCost"의 결과인 '배송비'이지 'baseShippingSpy'과 'regionalSurchargeSpy'이 아니다. 그리고 만약 이 내부 구현들이 제거되거나 변경된다면 테스트코드 또한 수정해야 하는 번거로움이 생긴다. 이런 케이스들이 복잡해지고 많아지면 테스트코드 유지보수 비용이 증가하게 된다. 좋지 않은 케이스이다.

이를 아래와 같이 수정해 볼 수 있다.

  it('주문 금액이 100 이상이면 기본 배송비가 무료여야 한다.', () => {
    const orderAmount = 120;
    const region = 'urban';

    const cost = shippingService.calculateShippingCost(orderAmount, region);

    expect(cost).toBe(0); // 결과 검증
  });

 

위의 코드는 내부 메서드 호출 여부에 의존하지 않고 마지막 결과만 검증한다. 내부 구현사항이 변경되어도 테스트에 영향을 미치지 않아 리팩토링에 용이한 구조이다.

정리를 해보자면 테스트는 비즈니스로직에 집중해야 하고 사용자 관점에서 발생할 수 있는 비즈니스적인 부정케이스를 다양하게 담아내야 한다. 그리고 내부 구현사항이 아닌 테스트 타깃의 결과를 확인하는 방식으로 작성해야 한다.

3. 읽기 좋은 테스트 코드는 무엇일까?

다양한 테스트 코드를 열심히 작성하다 보면 복잡해지거나 길어질 수 있다. 테스트 코드는 결함을 발견하는 역할뿐 아니라, 비즈니스를 이해하기 위한 문서 역할도 한다. 또한, 테스트 코드는 지속적으로 수정되며, 여러 사람이 함께 다루게 된다.

따라서 유지보수 비용을 줄이고, 테스트 코드가 문서로서의 역할을 잘 수행하기 위해서도 읽기 좋은 테스트 코드를 작성하는 것이 중요하다.

이와 관련되어서 두 가지 다른 개념이 있다.

1. DRY (Don't Repeat Youtself)

코드를 작성할 때 자주 고려하게 되는 원칙 중 하나가 DRY이다. 리팩토링을 하다 보면 코드 중복을 줄이는 작업이 가장 흔한 작업 중 하나 이다. 마틴파울러의 리팩토링 책에서도 중복된 코드를 제거하는 방법으로 “함수 추출” 기법을 소개하기도 하였다.

DRY는 코드의 중복을 줄여 유지보수를 용이하게 하는 것을 목적으로 한다. 동일한 기능을 하나의 클래스, 함수로 묶어 관리함으로써 재사용성이 증가하고 코드 또한 깔끔해진다.

테스트코드에 이를 사용한 예시를 들어보자. 

테스트코드를 작성하다 보면 중복되는 코드들이 상당히 많다. 특히 하나의 테스트케이스마다 설정해 주는 기본 세팅이 비슷한 경우가 많은데 이런 공통적으로 필요한 설정, 데이터, 초기화 작업등을 하나의 함수나 변수로 정의할 수 있다. 그리고 이런 설정들을 beforeEach와 같이 각 테스트 실행 전에 수행되도록 설정할 수도 있다.

하지만 이를 과도하게 사용할 경우 너무 코드의 가동성을 해치고 불필요한 추상화로 이어질 수 있다.

아래와 같이 중복을 줄임으로써 가독성을 향상할 수 있지만

describe('AccountService - account status management', () => {
  let account;

  beforeEach(() => {
    const userId = 1;
    const status = AccountStatus.ACTIVE;
    account = Account.create(userId, status);
  });

  it('계정을 활성화한다.', () => {
    account.activate();
    expect(account.status).toBe(AccountStatus.ACTIVE);
  });

  it('계정을 비활성화한다.', () => {
    account.deactivate();
    expect(account.status).toBe(AccountStatus.INACTIVE);
  });

});

하지만 만약 INACTIVE 상태인 경우에만 만족하는 케이스가 추가된다면 

  // 비활성화된 계정만 삭제할 수 있는 규칙을 테스트
  it('비활성화된 계정만 삭제할 수 있다.', () => {
    account.delete();
    expect(account.isDeleted).toBe(true);
  });

beforeEach에 의해 실패를 하게 되고 테스트코드의 전반적인 수정이 불가피하게 된다. 한 개의 추가 케이스 때문에 전체 테스트코드를 손봐야 하는 상황이 올 수 있다.  도한 테스트가 길어지고 이 beforeEach에 결합되어 있는 테스트가 많아질수록 테스트코드를 추가 및 수정할 때마다 이 로직을 확인해봐야 하는 번거로움이 생긴다. 

사실 테스트코드를 작성하다 보면 위와 같은 경우가 종종 있던 것으로 기억한다. 이를 해결하기 위해 아래와 같은 방식을 고려해 볼 수도 있다.

2. DAMP (Descriptive and Meaningful Phrases)

DAMP는 묘사적이고 의미 있는 문구라는 의미로 DRY와 조금 반대되는 의미이다. 명확성과 가독성을 더욱 중시하여 필요한 경우 코드의 중복을 허용한다.

이 원칙에 따라 중복을 줄이면서 서술적이게 작성한다면 가독성 또한 좋아지므로 문서로서의 역할 또한 가능해진다.

테스트 픽스처(Fixture)를 사용하면 코드 중복을 줄이면서 각 테스트를 수행하기 전에 필요한 상태나 환결을 설정할 수 있다.

Fixture란? 

xUnit 테스트 패턴에서는 아래와 같이 정의한다
테스트 대상 시스템 SUT(System Under Test)를 실행하기 위해 필요한 모든 것을 테스트 픽스처라 부르고, 픽스처를 설치하기 위해 호출하는 테스트 로직 부분을 테스트의 픽스처 설치 단계라고 한다.

즉, 테스트 픽처스는 테스트가 제대로 수행되지 위해 준비해야 하는 모든 상태와 설정을 의미한다.

 

beforeEach의 경우 모든 테스트가 동일한 초기 설정을 공유하기 때문에 특정 테스트에 맞는 설정을 추가하기 위해 beforeEach를 수정해야 할 때가 생긴다.

하지만 Fixture는 파라미터를 통해 각 테스트가 원하는 설정을 직접 지정할 수 있어 각 테스트가 필요에 따라 초기 설정을 할 수 있도록 해준다. 이는 테스트 간의 결합도를 낮출 수 있다는 장점이 있다.

위의 예시를 DAMP를 따르면서 픽스처를 사용해서 수정해 보도록 하자

// 각 테스트가 필요로 하는 상태의 계정 객체를 생성하는 클래스
class AccountFixture {
  static create(status: AccountStatus, userId = 1) {
    return Account.create(userId, status);
  }
}

// 테스트 코드
describe('AccountService - account status management', () => {

  it('계정을 활성화한다.', () => {
    const account = AccountFixture.create(AccountStatus.INACTIVE); // 필요한 상태의 객체 생성
    account.activate();
    expect(account.status).toBe(AccountStatus.ACTIVE);
  });

  it('계정을 비활성화한다.', () => {
    const account = AccountFixture.create(AccountStatus.ACTIVE); // 필요한 상태의 객체 생성
    account.deactivate();
    expect(account.status).toBe(AccountStatus.INACTIVE);
  });

  it('비활성화된 계정만 삭제할 수 있다.', () => {
    const account = AccountFixture.create(AccountStatus.INACTIVE); // 비활성화 상태의 객체 생성
    account.delete();
    expect(account.isDeleted).toBe(true);
  });
});

AccountFixture를 통해 테스트마다 필요한 상태의 계정을 생성할 수 있다. 이를 통해 각 테스트가 독립적으로 동작하게 된다.

그리고 픽스처를 만들 때 인자로 받는 값은 테스트에 사용되는 값으로, 테스트에 필요하지 않은 값들은 기본값들로 구성함으로써 테스트의 가독성을 높일 수 있다. (테스트 픽스처 올바르게 사용하기 이 블로그의 글을 참고함)

4. 마치며

위의 내용을 간단하게 요약해 보자

1. 테스트 코드 작성을 구조화하여 가독성과 일관성을 높이자

2. 비즈니스로직과 부정적 케이스에 대한 테스트에 집중하자

3. DAMP 하게 테스트를 작성하여, 테스트가 독립적으로 실행되도록 하고 가독성을 높이자

사실 테스트코드에 대해 관심을 갖고 제대로 작성해 보기로 한지 얼마 되지 않았다. 그래서 이 세 가지 내용을 상기하며 테스트코드를 작성하는 습관을 들여보려고 한다.

그러고 나면 테스트 코드에 대한 퀄리티, 더 깊은 내용에 대해 궁금해질 것이고 다양한 서적을 찾아보게 될 것이고 

테스트코드에 대한 그다음 포스팅이 될 것이라 생각한다.

<참고블로그>

테스트 픽스처 올바르게 사용하기

테스트 픽스처를 사용한 테스트 코드 짜기

 

728x90
Comments