백엔드 계층에 맞는 실용적인 테스트 작성하기

Yg999999999·2026년 9월 9일

여는 말

wannabe-ketchup 서비스를 함께 개발하면서 기획 얘기는 정말 많이 나눴다. 이제 본격적으로 코드를 짜기 시작했는데 코드 작성 스타일이나 개발 방식에 대한 얘기는 아직 크게 나눠보지 못했다. 그래서, 테스트 코드 작성 방식에 대해 먼저 얘기를 꺼내보고 싶었다.

테스트 코드를 작성하는 방향이 잡히면 개발 속도를 높여주는 도구가 된다. 반대로 방향 없이 각자 다른 방식으로 작성하면, 테스트가 있어도 신뢰하기 어렵고 유지보수 부담만 늘어난다.

이 글은 내가 생각하는 테스트 코드의 역할, 테스트 코드 작성 팁을 설명하고 팀의 백엔드 테스트 전략이 될 수 있도록 설득하고 싶어 아래의 글을 작성했다.


테스트의 두 종류

테스트는 크게 두 가지로 나뉜다.

단위 테스트 (Unit Test)

특정 클래스의 기능을 독립적으로 검증하기 위한 테스트다.

핵심 특징은 외부 클래스를 Mock으로 대체한다는 것이다. 테스트 대상 클래스가 의존하는 외부 클래스를 실제로 사용하면 테스트 실패의 원인이 대상 클래스에 있는지 외부 클래스에 있는지 불분명해진다. Mock을 사용함으로써 테스트 범위를 명확하게 격리할 수 있다.

통합 테스트 (Integration Test)

여러 컴포넌트가 실제로 연결된 환경에서 함께 동작하는지 검증하기 위한 테스트다.

실제 DB, 의존 클래스를 사용하기 때문에 단위 테스트보다 실제 동작에 가깝다. 단, 실행 시간이 길고 설정이 복잡하다는 트레이드오프가 있다.


백엔드의 테스트 대상

백엔드의 테스트 대상은 크게 4가지다.

  1. Controller — HTTP 요청/응답 처리
  2. Service — 유스케이스 실행 조율
  3. Repository — DB 영속화
  4. Domain — 비즈니스 규칙 및 상태 변경

모든 계층에 모든 테스트를 작성하면 어떨까?

가장 직관적인 접근은 4가지 계층 각각에 대해 단위 테스트와 통합 테스트를 모두 작성하는 것이다.

이 방법은 각 파일의 기능을 꼼꼼하게 검증할 수 있어 이론적으로 가장 견고하다. 하지만 현실적으로는 두 가지 문제가 생긴다.

  • 테스트 실행 시간 증가: 통합 테스트가 많아질수록 전체 테스트 스위트의 실행 시간이 길어져 개발 사이클이 느려진다.
  • 유지보수 부담 증가: 테스트 파일이 많아지면 코드 변경 시 수정해야 할 테스트도 늘어나 팀의 생산성이 낮아진다.

실용적인 테스트 전략

테스트는 비용이다. 모든 계층을 무조건 촘촘하게 테스트하는 것은 견고할 수 있지만 테스트 비용이 매우 커질 수 있다. 각 계층이 맡은 책임에 따라 적절한 테스트 전략을 결정하는 것은 테스트 작성에 있어 더 효율적이고 유지보수에 있어 개발자의 관리부담을 줄어들게 할 수 있다.

모든 계층에 단위 테스트, 통합 테스트 작성을 하지말고 각 계층의 성격에 맞는 테스트를 진행해보자. 각 계층이 맡은 책임이 다르듯 계층별로 어울리는 테스트 종류도 다르다.

계층주요 책임추천 테스트
ControllerHTTP ↔ Application 변환E2E 테스트 (블랙박스 테스트)
Service유스케이스 orchestration통합 테스트
Domain비즈니스 규칙 / 상태 변경단위 테스트
RepositoryDB 영속화서비스 통합 테스트에 포함 ****

Domain — 단위 테스트

wannabe-ketchup의 비즈니스 로직은 도메인 계층에 집중되어 있다.

도메인 로직을 검증하는 데 HTTP 연결이나 DB 연결은 필요하지 않다. 중요한 것은 비즈니스 규칙 자체가 올바르게 동작하는지다. 외부 의존성 없이 순수하게 로직만 테스트할 수 있으므로, 도메인 계층은 단위 테스트가 가장 의미 있고 효율적이다.

Service — 통합 테스트

서비스 계층의 역할은 여러 클래스의 메서드를 적절한 순서로 호출해 유스케이스를 완성하는 것이다. 서비스 자체에는 직접적인 비즈니스 로직이 존재하지 않는다.

따라서 서비스 테스트에서 중요한 것은 여러 컴포넌트가 함께 실행되어 원하는 결과(DB 저장, 조회 등)를 달성하는 것이다. 이는 통합 테스트가 더 적합하다.

Repository — 서비스 통합 테스트에 포함

레포지토리는 실제 DB와의 영속화가 핵심이며 통합 테스트가 어울린다. 하지만, 통합 테스트는 실행 비용이 크다. 간단한 CRUD 로직은 테스트 비용을 절약하기 위해 서비스 통합 테스트에 포함해보는 건 어떨까?

서비스는 목적에 따라 레포지토리를 호출한다. 레포지토리 조회가 실패하면 서비스의 통합 테스트도 함께 실패하며, 실패 지점을 추적하는 것도 크게 어렵지 않다. 따라서 레포지토리 검증은 서비스 통합 테스트 안에 자연스럽게 포함될 수 있다.

따라서 단순한 CRUD처럼 별도의 검증 가치가 크지 않은 레포지토리 로직은 서비스 통합 테스트를 통해 간접적으로 검증하고 복잡한 조회 조건이나 커스텀 쿼리처럼 해당 레포지토리의 동작 자체를 별도로 보장할 필요가 있는 경우에는 레포지토리 통합 테스트를 추가하는 방식을 고려할 수 있다.

예시: 엔티티 생성 서비스에서 레포지토리를 통해 엔티티를 저장한다고 가정하자. 이 서비스에 대해 통합 테스트를 작성하면, 서비스 로직과 레포지토리의 영속화를 한 번에 검증할 수 있다.

Controller — E2E 테스트 (블랙박스 테스트)

컨트롤러는 DTO를 검증하고, 적절한 서비스를 호출해 응답을 반환하는 역할을 한다. 컨트롤러는 서비스의 내부 동작을 알 필요가 없다.

컨트롤러 계층의 테스트 목표는 올바른 HTTP 요청이 올바른 HTTP 응답을 반환하는 것이다. 내부 구현에 의존하지 않는 블랙박스 관점의 E2E 테스트가 가장 잘 어울린다.

좋은 테스트 코드 만들기

테스트는 문서다

  • 테스트 코드는 프로덕션 기능을 설명하는 문서이다.
  • 다양한 테스트 케이스를 통해 프로덕션 코드를 이해하는 시각과 관점을 보완한다.
  • 조직의 한 사람의 고민의 결과물을 팀 차원으로 승격시켜 모두의 자산으로 공유할 수 있다.

의미있게 테스트 설명하기

  • 테스트 함수명으로 테스트가 어떤 역할을 하는지 명시하는 것은 한계가 있다. it()의 테스트 설명에 섬세하게 작성한다.
  • 명사의 나열보단 완성된 문장 형태로 작성하라. 테스트 행위에 대한 결과와 도메인 용어까지 기술하면 좋다.
    • 음료 1개 추가 테스트 → 음료 1개를 추가하면 주문 목록에 담긴다)
    • 특정 시간 이전에 주문을 생성하면 실패한다. → 영업 시작 이전에는 주문을 생성할 수 없다.

테스트 작성 순서

  1. Persistence Layer Test (복잡한 쿼리, 커스텀 쿼리에 대해서 테스트)
  2. Business Layer Test (Persistence Layer Test에서 보장되었기 때문에 통합 테스트 진행한다.)
  3. Presesntation Layer Test(외부 파라미터 검증하는 테스트 서비스부터는 모킹)

한 테스트에는 하나의 주제를 사용하라.

it('사용자 생성 결과를 테스트한다', () => {
  // given
  const cases = [
    { age: 20, expected: true },
    { age: 30, expected: true },
    { age: 10, expected: false },
  ];

  // when & then
  for (const { age, expected } of cases) {
    expect(isAdult(age)).toBe(expected);
  }

});

테스트에 여러 행동, 유스케이스를 담고 있다. 이렇게 여러 주제를 담으니 테스트 설명이 직관적이지 못하다.
테스트 데이터, 행위, 검증이 한 눈에 들어오지 못해 리뷰어가 코드 읽기가 불편하다.

it('성인의 나이가 주어지면 true를 반환한다', () => {
  // given
  const age = 20;
  const expected = true;
  
  // when & then
  expect(isAdult(age)).toBe(expected);
});

it('미성년자의 나이가 주어지면 false를 반환한다', () => {
  // given
  const age = 10;
  const expected = false
  
  // when & then
  expect(isAdult(10)).toBe(expected);
});

테스트 하나가 하나의 행동을 설명한다. 테스트를 나누어 명확히하면 테스트 설명을 직관적으로 쓸 수 있다. 리뷰어는 테스트 코드를 읽자마자 이해된다. 테스트 이름만 읽어도 어떤 규칙을 검증하는지 알 수 있다.

하나의 테스트 코드에 분기 처리, 반복문을 사용하여 여러 주제의 테스트를 진행하지 말자.

테스트를 결정론적으로 만들어라.

// 함수 내부에서 현재 시간을 직접 참조
function isCouponValid(coupon: Coupon): boolean {
  return new Date() < coupon.expiresAt;
}

it('쿠폰 유효 기간이 남아있으면 true를 반환한다', () => {
  // given
  const coupon = new Coupon({ expiresAt: new Date('2026-12-31') });

  // when & then
  expect(isCouponValid(coupon)).toBe(true);
  // 2026-12-31 이후에 이 테스트를 실행하면 실패한다
});

테스트 내부에서 new Date()나 Math.random()을 직접 호출하면, 실행 시점에 따라 결과가 달라진다. 오늘은 통과했지만 내일은 실패하는 테스트가 만들어진다. 이런 테스트는 신뢰하기 어렵고, 실패 원인을 추적하기도 힘들다.

// 현재 시간을 매개변수로 받도록 변경
function isCouponValid(coupon: Coupon, now: Date): boolean {
  return now < coupon.expiresAt;
}

it('현재 시각이 만료 시각보다 이전이면 true를 반환한다', () => {
  // given
  const now = new Date('2026-01-01');
  const coupon = new Coupon({ expiresAt: new Date('2026-12-31') });
  const expected = true;

  // when & then
  expect(isCouponValid(coupon, now)).toBe(expected);
});

it('현재 시각이 만료 시각 이후이면 false를 반환한다', () => {
  // given
  const now = new Date('2027-01-01');
  const coupon = new Coupon({ expiresAt: new Date('2026-12-31') });
  const expected = false;

  // when & then
  expect(isCouponValid(coupon, now)).toBe(expected);
});

함수가 필요한 값을 외부로부터 주입받도록 만들면 테스트에서 그 값을 제어할 수 있다. 어떤 환경에서 몇 번을 실행해도 항상 같은 결과를 보장한다. 결정론적인 테스트를 위해 함수 실행에 필요한 시간값, 랜덤값은 매개변수로 부터 받자.

테스트 환경의 독립성을 보장하라.

given 절에서 팩토리 메서드나 비즈니스 로직 메서드를 호출하면 테스트 준비 단계에서 예외가 발생할 수 있다. 이 경우 테스트 실패의 원인이 given 에 있는지 when 에 있는지 불분명해진다.


it('사용자 포인트를 차감한다',()=>{
	// given
	const user = User.create({ name:'김철수', age:20, point:100});
	const deductAmount = 30;
	const expected = 70;
	
	// when
	user.deductPoint(deductAmount);
	
	// then
	expect(user.point).toBe(expected);
});

User.create()의 내부 로직이 변경되면 이 테스트도 영향을 받는다. 또한, 실패 원인이 deductPoint() 메서드가 아닌 User.create() 에서 발생하게 되어 테스트가 불분명해진다.


it('사용자 포인트를 차감한다',()=>{
	// given
	const initialPoint=100;
	const deductAmount=30;
	const expected=70;
	const user= new User({ name:'김철수', age:20, point: initialPoint});
	
	// when
	user.deductPoint(deductAmount);
	
	// then
	expect(user.point).toBe(expected);
});

테스트 준비에 필요한 객체는 빌더, 생성자, 객체 리터럴 등으로 테스트용 엔티티를 만들어라. given 절은 순수한 데이터 설정 구간이어야 한다. 테스트가 실패했을 때 원인이 when 에 있어야한다.

테스트 간 독립성을 보장하라.

테스트 간에 자원을 공유하면 실행 순서에 따라 결과가 달라질 수 있다. 한 테스트가 공유 자원을 수정하면 이후 테스트가 오염된 상태에서 실행된다. 특정 테스트만 단독으로 실행했을 때와 전체 테스트를 실행했을 때 결과가 달라지는 불안정한 테스트가 만들어진다.

각 테스트는 필요한 자원을 스스로 준비하고, 다른 테스트의 영향을 받지 않아야 한다. 테스트 실행 순서에 무관하게 항상 같은 결과를 보장해야 한다.

두 개 이상의 테스트가 하나의 자원을 공유해서 사용하지말자.

beforeEach를 지양하자.

describe('CommentService', () => {
  let author: User;
  let post: Post;
  let comment: Comment;
  let commentService: CommentService;

  beforeEach(() => {
    author = new User({ id: 1, name: '김철수', role: 'USER' });
    post = new Post({ id: 1, title: '오늘의 기록', authorId: author.id });
    comment = new Comment({ id: 1, content: '좋은 글이에요', postId: post.id, authorId: author.id });
    commentService = new CommentService();
  });

  it('작성자는 본인의 댓글을 삭제할 수 있다', () => {
  
	  // when
    commentService.delete(comment.id, author.id);

		// then
    expect(commentService.findById(comment.id)).toBeNull();
  });

  it('관리자는 타인의 댓글을 삭제할 수 있다', () => {
	  // given
    const admin = new User({ id: 2, name: '관리자', role: 'ADMIN' });
		
		// when
    commentService.delete(comment.id, admin.id);
		
		// then
    expect(commentService.findById(comment.id)).toBeNull();
  });

});

beforeEach에 초기화 코드를 모아두면 처음엔 중복이 줄어든 것처럼 보인다. 하지만 beforeEach를 수정하는 순간 해당 블록 안의 모든 테스트에 영향을 주어 테스트간 결합도를 높인다.

또한, 테스트를 이해하려면 beforeEach와 테스트 본문을 함께 읽어야 해 테스트를 이해하는 데 필요한 맥락이 파편화된다. 테스트 규모가 커질수록 문서로서 테스트를 작성하는게 어렵다.

describe('CommentService', () => {
  it('작성자는 본인의 댓글을 삭제할 수 있다', () => {
    // given - 이 테스트에 필요한 것만 직접 준비
    const author = new User({ id: 1, name: '김철수', role: 'USER' });
    const post = new Post({ id: 1, title: '오늘의 기록', authorId: author.id });
    const comment = new Comment({ id: 1, content: '좋은 글이에요', postId: post.id, authorId: author.id });
    const commentService = new CommentService();

    // when
    commentService.delete(comment.id, author.id);

    // then
    expect(commentService.findById(comment.id)).toBeNull();
  });

  it('관리자는 타인의 댓글을 삭제할 수 있다', () => {
    // given - 관리자 시나리오에 맞게 명확하게 준비
    const author = new User({ id: 1, name: '김철수', role: 'USER' });
    const admin = new User({ id: 2, name: '관리자', role: 'ADMIN' });
    const post = new Post({ id: 1, title: '오늘의 기록', authorId: author.id });
    const comment = new Comment({ id: 1, content: '좋은 글이에요', postId: post.id, authorId: author.id });
    const commentService = new CommentService();

    // when
    commentService.delete(comment.id, admin.id);

    // then
    expect(commentService.findById(comment.id)).toBeNull();
  });
});

각 테스트가 필요한 데이터를 직접 선언하면 테스트 하나만 읽어도 흐름을 이해할 수 있다. beforeEach를 수정했을 때 다른 테스트가 영향을 받을 걱정을 할 필요가 없다.

단, 테스트를 봤을 때 테스트 내용을 이해하는데 문제 없고 수정했을 때 테스트에 영향을 주지 않는다면 사용해도 괜찮다.

profile
BackEnd developer

0개의 댓글