Logo

Vitest로 테스트 전/후 처리하기

테스트를 작성하다 보면 모든 테스트 케이스에 적용되어야 하는 공통 로직이 생기기 마련인데요. 이러한 모든 테스트 케이스에 중복되어 있으면 테스트 코드를 유지보수하는 것이 힘들어집니다.

이번 포스팅에서는 Vitest를 이용해서 이렇게 테스트 전이나 후에 항상 실행되야 하는 코드를 효과적으로 작성하는 방법에 대해서 알아보겠습니다.

테스트 대상 코드

데이터베이스에 접근하는 코드에 대한 테스트를 작성한다는 가정 하에 예제 코드를 작성해보겠습니다. 최대한 간단한 예제를 위해서 자바스크립트 배열로 데이터베이스를 대신하도록 할께요.

data.ts
export interface User {
  no: number;
  email: string;
}

export const users: User[] = [];

사용자 데이터를 조회/생성/삭제하는 기능을 제공하는데 UserService 클래스를 작성합니다. 내부적으로 users 배열을 사용하고 있습니다.

userService.ts
import { type User, users } from './data'

export class UserService {
  findAll() {
    return users;
  }

  findOne(no: number) {
    return users.find(user => user.no === no)
  }

  create(user: User) {
    users.push(user);
  }

  delete(no: number) {
    users.splice(
      users.findIndex(user => user.no === no),
      1
    );
  }
}

자바스크립트 배열에 원소를 추가하고 제거하는 방법에 대해서는 아래 관련 포스팅을 참고 바랍니다.

UserService.findAll() 테스트

먼저 UserService 클래스의 findAll() 메서드에 대한 테스트부터 작성해보겠습니다. users 배열이 비어있으므로 세 건의 데이터를 추가 후에 findAll()을 호출합니다. 그 다음, 리턴 결과의 길이가 3인지, 그리고 각 사용자 데이터를 정확히 담고 있는지 검증합니다.

userService.test.ts
import { expect, test } from "vitest";
import { users } from "./data";
import { UserService } from "./userService";

test("findAll()", () => {
  const userService = new UserService();

  users.push(
    { no: 1, email: "user1@test.com" },
    { no: 2, email: "user2@test.com" },
    { no: 3, email: "user3@test.com" }
  );

  const foundUsers = userService.findAll();

  expect(foundUsers).toHaveLength(3);
  expect(foundUsers).toContainEqual({ no: 1, email: "user1@test.com" });
  expect(foundUsers).toContainEqual({ no: 2, email: "user2@test.com" });
  expect(foundUsers).toContainEqual({ no: 3, email: "user3@test.com" });
});

테스트를 실행해보면 잘 통과하는 것을 볼 수 있습니다.

$ npx vitest run src/userService.test.ts

 RUN  v1.4.0 /home/projects/vitest-before-after

 ✓ src/userService.test.ts (1)
   ✓ findAll()

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  12:45:37
   Duration  1.47s (transform 73ms, setup 0ms, collect 59ms, tests 1ms, environment 0ms, prepare 177ms)

UserService.create() 테스트

다음으로 UserService 클래스의 create() 메서드에 대한 테스트를 작성해보겠습니다. create() 메서드에 사용자 한 건의 데이터를 인자로 넘겨서 호출합니다. 그 다음, users 배열이 데이터를 총 한 건을 저장하고 있고, 정확한 사용자 데이터를 저장하고 있는지 검증합니다.

userService.test.ts
import { expect, test } from 'vitest';
import { users } from './data';
import { UserService } from './userService';

test('create()', () => {
  const userService = new UserService();

  const user = { no: 4, email: 'user4@test.com' };
  userService.create(user);

  expect(users).toHaveLength(1);
  expect(users).toContainEqual(user);
});

하지만 위 테스트를 실행해보면 실제 users 배열의 길이가 4이었기 때문에 실패함을 알 수 있습니다.

$ npx vitest run src/userService.test.ts

 RUN  v1.4.0 /home/projects/vitest-before-after

 ❯ src/userService.test.ts (2)
   ✓ findAll()
   × create()

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/userService.test.ts > create()
AssertionError: expected [(4) ] to have a length of 1 but got 4

- Expected
+ Received

- 1
+ 4eval src/userService.test.ts:43:17
     41|   userService.create(user);
     42|
     43|   expect(users).toHaveLength(1);
       |                 ^
     44|   expect(users).toContainEqual(user);
     45| });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)
   Start at  12:51:10
   Duration  1.25s (transform 72ms, setup 0ms, collect 63ms, tests 6ms, environment 0ms, prepare 120ms)

왜 이런 상황이 발생하는 것일까요? 그 이유는 첫번째 테스트에서 users 배열에 추가되었던 3건의 데이터가 두번째 테스트에도 영향을 주기 때문입니다.

afterEach()로 데이터 정리하기

위와 같은 예상치 못한 문제를 피하기 위해서는 각 테스트를 실행 후에 테스트 용 데이터를 정리해주는 작업이 필요합니다. 이럴 때 사용할 수 있는 함수가 Vitest의 afterEach()입니다. 각각의 테스트 케이스가 실행 후에 수행해야하는 공통 로직이 있다면 afterEach() 함수를 사용하면 됩니다.

afterEach() 함수를 통해서 통해서 users 배열의 모든 원소를 삭제해주겠습니다.

userService.test.ts
import { afterEach, expect, test } from "vitest";
import { users } from "./data";

afterEach(() => {
  users.splice(0);
});

그러면 첫 번째 테스트 케이스가 실행된 직후에 배열이 비워져서, 두 번째 테스트가 실행될 때는 빈 배열을 상대로 검증이 이뤄집니다. 따라서 테스트 간에 데이터로 인한 영향을 최소화되는 것이지요.

자바스크립트 배열의 splice() 메서드에 대한 좀 더 자세한 설명은 관련 포스팅를 참고바랍니다.

UserService.delete() 테스트

다음으로 UserService 클래스의 delete() 메서드에 대한 테스트를 작성해보겠습니다. findAll() 함수에서 했던 것과 비슷하게 users 배열에 초기 데이터를 적재해주고, 한 건의 사용자 데이터가 삭제되는지 확인합니다.

userService.test.ts
test("delete()", () => {
  const userService = new UserService();

  users.push(
    { no: 1, email: "user1@test.com" },
    { no: 2, email: "user2@test.com" },
    { no: 3, email: "user3@test.com" }
  );

  const no = 3;
  userService.delete(no);

  expect(users).toHaveLength(2);
  expect(users).not.toContainEqual(users.find((user) => user.no === no));
});

그런데 위 테스트 코드와 findAll() 함수에 대한 테스트를 비교해보면 users 배열에 초기 데이터를 적재하는 코드를 중복해서 작성한 것을 알 수 있습니다.

beforeEach()로 중복 코드 제거하기

Vitest의 beforeEach() 함수 사용하면 여러 테스트에 걸쳐서 중복된 코드를 쉽게 제거할 수 있는데요. beforeEach() 함수의 인자로 넘어간 코드는 각각의 테스트 케이스가 실행되기 전에 매번 실행됩니다.

본 예제에서는 users 배열에 테스트 용 데이터를 적재하는 코드를 beforeEach() 함수로 옮길 수 있겠습니다.

userService.test.ts
import { afterEach, beforeEach, expect, test } from "vitest";
import { users } from "./data";

beforeEach(() => {
  users.push(
    { no: 1, email: "user1@test.com" },
    { no: 2, email: "user2@test.com" },
    { no: 3, email: "user3@test.com" }
  );
});

참고로 이렇게 테스트 전에 적재하는 데이터를 seed data라고 하며, 테스트 용 데이터 적재 작업을 seeding이라고도 합니다.

모든 테스트 케이스를 자세히 관찰해보시면 UserService 클래스의 인스턴스도 매번 생성하는 것을 볼 수 있습니다. 따라서 인스턴스를 생성하는 코드도 원한다면 beforeEach() 함수로 옮길 수 있겠죠?

userService.test.ts
import { afterEach, beforeEach, expect, test } from "vitest";
import { users } from "./data";
import { UserService } from './userService';

let userService: UserService;

beforeEach(() => {
  userService = new UserService();
  users.push(
    { no: 1, email: "user1@test.com" },
    { no: 2, email: "user2@test.com" },
    { no: 3, email: "user3@test.com" }
  );
});

이렇게 모든 중복 코드를 beforeEach() 함수로 옮겨주면, UserService.findAll()UserService.delete() 함수에 대한 테스트는 다음과 같이 간결해집니다.

userService.test.ts
test("findAll()", () => {
  const foundUsers = userService.findAll();

  expect(foundUsers).toHaveLength(3);
  expect(foundUsers).toContainEqual({ no: 1, email: "user1@test.com" });
  expect(foundUsers).toContainEqual({ no: 2, email: "user2@test.com" });
  expect(foundUsers).toContainEqual({ no: 3, email: "user3@test.com" });
});

test("delete()", () => {
  const no = 3;
  userService.delete(no);

  expect(users).toHaveLength(2);
  expect(users).not.toContainEqual(users.find((user) => user.no === no));
});

beforeAll(), afterAll()

Vitest는 beforeEach(), afterEach()와 매우 유사하게 생긴 beforeAll(), afterAll() 함수도 제공하는데요. 함수 이름에 유추할 수 있듯이 beforeAll(), afterAll()은 모든 테스트가 실행되기 전과 후에 딱 한 번씩만 호출됩니다.

대표적인 사용 사례로 데이터베이스에 접속할 필요한 연결(Connection) 객체를 생각해볼 수 있습니다. 테스트 함수 마다 매번 Connection을 맺고 끊는 것 보다는 맨 처음에 한 번 Connection을 맺어 놓고 여러 함수에 걸쳐서 사용 한 후 마지막에 Connection을 종료하는 것이 효율적일 것입니다.

import { afterAll, beforeAll } from "vitest";

let connection;

beforeAll(() => {
  connection = openConnection({ host: "...", port: "..." });
});

afterAll(() => {
  connection.close();
});

전체 코드

실습 프로젝트에서 작성한 테스트 코드는 아래에서 직접 확인하고 실행해볼 수 있습니다.

마치면서

지금까지 Vitest에서 제공하는 beforeEach(), afterEach() 함수를 사용하여 테스트 용 데이터를 어떻게 적재하고 정리하는지 알아보았습니다. 그리고 beforeAll(), afterAll() 함수를 사용하여 공통 코드를 모든 테스트의 맨 앞과 뒤에서 딱 한 번 실행하는 방법에 대해서도 살펴보았습니다.

Vitest에 연관된 포스팅은 Vitest 태그를 통해서 쉽게 만나보세요!