북마켓 백엔드에 단위 테스트 코드 추가하면서 단위 테스트에 익숙해지기 1편

May 29, 2025

최근에 지피티한테 내 북마켓 백엔드 코드를 분석해서 개선 사항들을 알려달라고 했더니,

이녀석이 이렇게 말을 했었다...

  • "서비스는 뜰 수 있는" 단계

  • 사실상 테스트 공백

  • "개인 프로젝트 레벨"

난 백엔드 개발자가 아니기 때문에 개인 프로젝트 레벨인건 그럴 수 있다 생각하긴 하지만,,, 그래도 속상하긴 했다.

그래서 지피티가 얘기헤준 개선 가능한 사항들에 대해서 조금씩 북마켓 백엔드를 개선해나가려고 한다.

단위 테스트에 익숙하지 않은 3년차 개발자

사실 나는 프론트엔드 개발을 하면서도 테스트 코드와는 좀 거리가 먼 사람이었다. 테스트 코드를 엄청나게 중요시 여기는 사람이 있는 반면에, 테스트 코드는 꼭 필요하지 않다고 생각하는 사람들이 존재한다.

나는 둘 다 아니었다. 테스트 코드가 중요하다는 사실은 동의하지만, 취준생 시절에는 당장 그보다 더 중요한 능력들을 키워야한다고 생각했었고, 실무에서 일 할 때는 우리 팀 자체가 그냥 테스트와는 거리가 멀었다. 그래서 나는 프론트엔드 개발 약 3년 경력동안 테스트 작성하는데 보낸 시간이 약 5%도 안된다.

테스트를 중요시 여기는 사람과 그렇지 않은 사람들 사이에서 나는 아무 곳에도 속하지 않는다고 생각한 이유는, 그저 내가 테스트 코드에 대한 필요성을 판단할 만큼의 경험이 없기 때문이라고 생각했다.

중요한지 안한지는 그만큼 많이 사용해보고 부딪혀봐야 아는건데, 난 그러지 않았다. 그래서 나는 내가 어느쪽에 속하는지 모른다.

근데 이제 백엔드 공부를 본격적으로 하려고 하니, 프론트엔드 개발 공부를 하면서 아쉬웠던 부분들을 보충해서 공부를 해보려고 하는데, 그 중 하나가 테스트 코드에 익숙해지기이다!

테스트 코드 작성하기

그러므로 오늘은 테스트 코드를 작성해서 북마켓 백엔드의 코드 신뢰성을 개선해보려고 한다!

오늘만 할 건 아니고, 앞으로 지속적으로 테스트를 중요시 여기면서 개발을 해보려고 한다. 여차하면 TDD를 통해서 새로운 기능도 만들어보면 좋을 것 같은데, 일단은 단위 테스트 도입부터 시작하려고 한다.

일단 시작은 북마켓 서비스의 주요 로직을 담당하는 bookmarks.service.ts의 단위 테스트를 작성해보자.

근데 어떻게 시작을 하고, 어떤 구조를 갖고 뭘 mocking 해야하는지 조차 잘 몰랐기에, 이건 클로드의 도움을 좀 받아봤다.

그랬더니 클로드가 이런 구조를 잡아줬고, 뭘 채워나가면서 단위 테스트를 작성하는지에 대한 가이드까지 작성해주었다. 일단 첫 단위 테스트는 이 가이드를 따라서 작성해보자!

// TODO: Create a mockBookmark object
// Look at the Bookmark entity to see what properties it needs
// You can keep it simple - just the required fields

// TODO: Create mockBookmarksRepository
// For findOneBookmark, you only need the findOne method
// Hint: const mockBookmarksRepository = { findOne: jest.fn() };

describe('BookmarksService', () => {
  let service: BookmarksService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        BookmarksService,
        {
          provide: getRepositoryToken(Bookmark),
          useValue: mockBookmarksRepository, // TODO: Replace with your mock
        },
        // For now, we'll use empty mocks for the other services since findOneBookmark doesn't use them
        {
          provide: CategoriesService,
          useValue: {},
        },
        {
          provide: UsersService,
          useValue: {},
        },
      ],
    }).compile();

    service = module.get<BookmarksService>(BookmarksService);
  });

  // TODO: Let's test findOneBookmark method
  describe('findOneBookmark', () => {
    // TODO: Write test case 1 - should return bookmark if found
    // TODO: Write test case 2 - should throw NotFoundException if bookmark not found
  });
});

1. Mock Bookmark 객체 생성하기

Bookmark를 찾는 메서드인 findOneBookmark에 대한 테스트 코드를 먼저 작성할건데, 그 전에 테스트를 위해 필요한 객체들을 생성해야한다.

const mockUser: User = {
  id: '111111-1111-1111-111111',
  email: 'test@test.com',
  username: 'test',
  firstName: 'test',
  lastName: 'test',
  password: 'test',
  isPublic: false,
  auth_provider: AuthProvider.EMAIL,
  bookmarks: [],
  categories: [],
};

const currentTime = new Date();

const mockBookmark: Bookmark = {
  id: '111111-1111-1111-111111',
  title: 'This is a bookmark',
  url: 'www.naver.com',
  user: mockUser,
  createdAt: currentTime,
  updatedAt: currentTime,
  description: 'Description',
  faviconUrl: 'www.naver.com/favicon.ico',
};

이렇게 mockUsermockBookmark를 생성해주자!

2. Mock BookmarksRepository 생성하기

우선 난 findOneBookmark 메서드를 테스트할거기 때문에, findOneBookmark 메서드에서 사용하는 repository의 메서드는

const bookmark = await this.bookmarksRepository.findOne({
  where: { user: { id: userId }, id },
});

findOne밖에 없기 때문에, 이 메서드만 mocking 해주자.

const mockBookmarksRepository = { findOne: jest.fn() };

이렇게 mockBookmarksRepository의 findOne 메서드를 호출하면 jest의 mock function을 호출하도록 해주자.

3. findOneBookmark 메서드 테스트 작성하기

자 이제 그럼 테스트 코드를 작성하기 위한 사전 작업은 다 끝났으니, 이제 본격적으로 메서드들을 테스트 해보자.

async findOneBookmark(userId: string, id: string) {
  const bookmark = await this.bookmarksRepository.findOne({
    where: { user: { id: userId }, id },
  });

  if (!bookmark) {
    throw new NotFoundException('Bookmark not found');
  }

  return bookmark;
}

이 메서드는 특정 유저의 id와, 특정 북마크의 id를 인자로 받아서, 알맞는 북마크를 반환하는 메서드이다.

여기서 이 메서드는 크게 두가지 종료점이 존재하는데,

  1. 북마크가 존재하는 경우
  2. 북마크가 존재하지 않는 경우

이렇게 있다.

이 두개의 종료점을 테스트해보자.

describe('findOneBookmark', () => {
  it('should return a bookmark if found', async () => {});

  it('should throw a NotFoundException if bookmark not found', async () => {});
});

먼저 시작은 이렇게 빈 테스트들을 작성을 해놓는다.

그 다음 첫번째 종료점인 "북마크가 존재하는 경우"에 맞는 테스트를 작성해보자.

it('should return a bookmark if found', async () => {
  mockBookmarksRepository.findOne.mockResolvedValue(mockBookmark);

  const foundBookmark = await service.findOneBookmark(mockUser.id, mockBookmark.id);

  expect(foundBookmark).toEqual(mockBookmark);
});

mockBookmarksRepositoryfindOne 메서드가 호출되면 mockBookmark를 반환하도록 하고,

servicefindOneBookmark 메서드를 호출하면, 해당 mockBookmark가 반환이 되는지를 확인하는 코드이다.

일반적인 단위 테스트의 Arrange, Act, Assert 법칙을 따라서,

  • mockBookmark가 반환되도록 Arrange를 하고,

  • service의 findOneBookmark 메서드를 호출 (Act) 하고,

  • mockBookmarkfindOneBookmark의 반환값이 동일한지 Assert 한다.

그럼 이렇게 테스트가 잘 통과하는 것을 볼 수 있다.

그러면 다음 종료점을 테스트해보자.

존재하지 않는 북마크를 찾는 경우, NotFoundException이 throw 되어야한다.

처음에는,

it('should throw a NotFoundException if bookmark not found', async () => {
  mockBookmarksRepository.findOne.mockResolvedValue(null);

  expect(await service.findOneBookmark('nouserid', 'nobookmarkid')).toThrow(NotFoundException);
});

이렇게 expect 안에서 findOneBookmark를 호출하고, .toThrow()를 통해서 에러가 throw 되는지를 감지하는 방식으로 코드를 작성했다.

그랬더니 NotFoundException이 throw된 것은 맞지만, 예상과는 다르게

아예 에러가 발생해버렸다.

그래서 무엇이 문제일까 검색을 좀 해봤더니, 동기 함수의 경우 현재 방식으로 테스트 코드를 작성해도 되지만, 비동기 함수는 .rejects가 필요하다는 것을 알 수 있었다.

현재 코드를 익숙한 try catch로 표현을 하면 아래와 같다.

try {
  const result = await service.findOneBookmark();
  expect(result).toThrow();
} catch (error) {
  // 실제로 에러를 잡을 수 있는 위치
}

따라서 const result = await service.findOneBookmark();에서 에러가 발생하는 것은 맞지만, 에러 발생 즉시 catch 문으로 이동하기 때문에, 에러가 jest에서 감지되지 않는 것이다.

그러므로 비동기함수에서의 에러를 테스트 하기 위해선

it('should throw a NotFoundException if bookmark not found', async () => {
  mockBookmarksRepository.findOne.mockResolvedValue(null);

  await expect(service.findOneBookmark('nouserid', 'nobookmarkid')).rejects.toThrow(NotFoundException);
});

이렇게 코드를 작성해야한다. 그러면

테스트가 통과한다!

4. findAllBookmarks 메서드 테스트 작성하기

다음은 모든 북마크들을 반환하는 메서드인 findAllBookmarks에 대한 테스트 코드를 작성해보자.

findAllBookmarks(userId: string, categoryName?: Category['name']) {
  const where: FindOptionsWhere<Bookmark> = {
    user: { id: userId },
  };

  if (categoryName) {
    where.category = { name: categoryName };
  }

  return this.bookmarksRepository.find({
    where,
    order: { createdAt: 'DESC' },
  });
}

이 메서드는 유저의 id를 required로, category를 optional 인자로 받고, 종료점은 크게 세개이다.

  1. 유저의 모든 북마크를 반환하는 경우

  2. 유저의 북마크를 카테고리 필터링해서 반환하는 경우

  3. 유저가 북마크가 하나도 없는 경우

describe('findAllBookmarks', () => {
  it('should return all bookmarks', async () => {});

  it('should return all bookmarks of a specific category', async () => {});

  it('should return an empty list of bookmarks', async () => {});
});

이번에도 차근차근 테스트 코드를 작성해보자.

우선 모든 북마크들을 반환하는 경우를 테스트해보자.

처음에는 이 종료점을 테스트하려면 기존 mockUser객체의 bookmarks에 새로운 북마크들을 추가해서 테스트해야하는건가 했는데, 단위 테스트의 목적은 대상 메서드를 테스트 하는 것이기 때문에, 유저 객체에 북마크들이 들어있는지 아닌지는 사실 상관이 없다.

실제로 상관 있는 것은

const mockBookmarks = [mockBookmark];
mockBookmarksRepository.find.mockResolvedValue(mockBookmarks);

이 코드를 통해서 repository로부터 데이터를 가져오는 것을 mocking 하는 것이다.

그러면 이전과 비슷하게 AAA 법칙을 따라서 이 테스트를 작성하면 아래와 같다.

describe('findAllBookmarks', () => {
  it('should return all bookmarks', async () => {
    // Arrange
    const mockBookmarks = [mockBookmark];
    mockBookmarksRepository.find.mockResolvedValue(mockBookmarks);

    // Act
    const bookmarks = await service.findAllBookmarks(mockUser.id);

    // Assert
    expect(bookmarks).toEqual(mockBookmarks);
  });

  it('should return all bookmarks of a specific category', async () => {
    // Arrange
    const mockBookmarks = [mockBookmark];
    mockBookmarksRepository.find.mockResolvedValue(mockBookmarks);

    // Act
    const bookmarks = await service.findAllBookmarks(mockUser.id, mockCategory.name);

    // Assert
    expect(bookmarks).toEqual(mockBookmarks);
  });

  it('should return an empty list of bookmarks', async () => {
    // Arrange
    const mockBookmarks: Bookmark[] = [];
    mockBookmarksRepository.find.mockResolvedValue(mockBookmarks);

    // Act
    const bookmarks = await service.findAllBookmarks('fake-user-id', 'fake-category-name');

    // Assert
    expect(bookmarks).toEqual(mockBookmarks);
  });
});

근데 사실 지금 테스트를 작성한 메서드들은 크게 로직이랄게 없는 메서드들이라서 단위 테스트가 하는 것이 크게 없다.

그냥 mock data를 제대로 반환하는지만 테스트하고 있는데, 크게 의미가 없게 느껴진다.

사실 단위 테스트가 더 필요한 메서드들은

async findAllBookmarksByUsername(username: User['username'], categoryName?: Category['name']) {
  const user = await this.usersService.findOneByUsername(username);

  if (!user) throw new NotFoundException('User does not exist');
  if (!user?.isPublic) throw new ForbiddenException("This user's profile is private");

  const where: FindOptionsWhere<Bookmark> = {
    user: { id: user.id },
  };

  if (categoryName) {
    where.category = { name: categoryName };
  }

  return this.bookmarksRepository.find({
    where,
    order: { createdAt: 'DESC' },
  });
}

이렇게 유저의 상태에 따른 비즈니스 로직이 존재하는 메서드나,

async createBookmark(createBookmarkDto: CreateBookmarkDto, userId: string) {
  let category: Category | undefined;

  if (createBookmarkDto.category) {
    category = await this.categoriesService.findOneByName(createBookmarkDto.category, userId);

    if (!category) {
      throw new NotFoundException('Category not found');
    }
  }

  return this.bookmarksRepository.save({
    ...createBookmarkDto,
    category,
    user: { id: userId },
  });
}

카테고리 존재 여부에 따른 분기처리가 존재하는 메서드들이다.

이번 글에서는 NestJS 백엔드에서 단위 테스트를 작성해야 하는 경우 어떤 구조를 갖추고 해야하는지를 배워보았으니, 다음 글에서는 본격적으로 좀 더 의미있는 단위테스트들을 작성해봐야겠다.