Logo

Jest를 이용한 클래스 모킹과 테스팅

ES6에서 클래스(class)가 도입되고 타입스크립트가 대중화되면서 이제 클래스를 사용하는 자바스크립트 코드를 흔하게 볼 수 있게 되었습니다. 뿐만 아니라 Angular나 NestJS처럼 클래스를 기반으로 동작하는 라이브러리나 프레임워크도 점점 늘어나고 있지요. 하지만 아직 클래스를 모킹하거나 테스팅하시는데 어려움을 겪는 개발자 분들이 많은 것 같습니다.

이번 포스팅에서는 자바스크립트의 대표적인 테스팅 프레임워크인 Jest를 사용하여 클래스를 모킹(mocking)하고 테스트 코드를 작성해보겠습니다.

본 포스팅을 이해하시려면 jest.fn(), jest.spyOn(), jest.mock()과 같은 모킹과 관련된 Jest의 기본 지식이 필요합니다. 이 부분에 대해서 선수 학습이 필요하신 분들께는 아래 포스팅을 추천드리겠습니다.

테스트 대상 클래스 작성

간단한 실습을 위해서 UsersService 클래스와 AuthService 클래스, 이렇게 두 개의 자바스크립트 클래스를 작성하겠습니다.

먼저 사용자 관리를 담당하는 UsersService 클래스에는 findByEmail() 메서드가 있습니다. 실제 애플리케이션에서는 데이터베이스를 사용하거나 API 호출을 해야겠지만, 실습 프로젝트에서는 임의로 무작위 데이터를 반환하도록 구현하였습니다. 뒤에서 테스트를 작성할 때 이 클래스는 어차피 가짜 구현으로 모킹(mocking)할 것이 때문에 실제 구현은 그닥 중요하지 않기 때문입니다.

class-mock/users.service.ts
type User = {
  id: number;
  email: string;
  password: string;
};

export class UsersService {
  findByEmail(email: string): User | null {
    /* 임의로 가짜 데이터가 조회되도록 구현 */
    return {
      id: Math.floor(Math.random() * 1_000),
      email,
      password: "1234",
    };
  }
}

그 다음, 인증을 담당하는 AuthService 클래스에는 logIn() 메서드가 있습니다. 로그인 처리를 하려면 이메일과 비밀번호를 입력받아서 사용자를 조회해야하 때문에, AuthService 클래스는 UserService 클래스를 사용하고 있습니다. AuthService 클래스의 logIn() 메서드는 UserService 클래스의 findByEmail() 메서드가 사용자를 찾지 못하거나, 인자로 넘어온 비밀번호가 맞지 않으면 예외를 발생시킵니다. 사용자가 문제없이 조회된 경우에는 찾은 사용자를 그대로 반환합니다.

class-mock/auth.service.ts
import { UsersService } from "./users.service";

export class AuthService {
  constructor(private readonly usersService: UsersService) {}
  logIn(email: string, password: string) {
    const user = this.usersService.findByEmail(email);    if (!user) throw new Error("Not Found");
    if (user.password !== password) throw new Error("Wrong Password");
    return user;
  }
}

여기서 테스팅 측면에서 한 가지 주의깊게 볼 부분은 AuthService 클래스는 생성자를 통해서 UsersService 클래스의 인스턴스를 받을 수 있다는 것입니다. NestJS와 같은 DI(Dependency Injection, 의존성 주입) 프레임워크에서 많이 볼 수 있는 설계 패턴입니다.

이런 방식으로 애플리케이션 코드를 작성하면 테스트 코드를 작성할 때 모킹한 클래스의 가짜 인스턴스를 매우 유연하게 주입할 수 있는 이점이 있습니다.

클래스의 인스턴스 모킹

클래스를 모킹하는 가장 간단한 방법은 클래스의 인스턴스를 모킹하는 것입니다. 위와 같이 의존성을 주입할 수 있도록 애플리케이션 코드가 설계되어 있다면 테스트 코드에서 손쉽게 가짜 인스턴스를 만들어서 쓸 수 있습니다.

예를 들어, AuthService 클래스에 대한 테스트를 작성하기 위해서 UsersService 클래스의 인스턴스를 모킹해볼까요? UsersService 클래스의 인스턴스는 결국에는 findByEmail() 메서드를 속성으로 갖는 객체이기 때문에, 자바스크립트 객체를 하나 만들고 findByEmail 속성에 jest.fn() 함수를 할당해주기만 하면 됩니다.

class-mock/auth.service1.test.ts
import { AuthService } from "./auth.service";
import { UsersService } from "./users.service";

describe("AuthService", () => {
  let usersService: UsersService;  let authService: AuthService;

  beforeEach(() => {
    usersService = { findByEmail: jest.fn() };    authService = new AuthService(usersService);
  });

  it("throws an error if user is not found", () => {
    (usersService.findByEmail as jest.Mock).mockReturnValue(null);
    expect(() => authService.logIn("test@email.com", "NOT_FOUND")).toThrow(
      "Not Found"
    );

    expect(usersService.findByEmail).toHaveBeenCalledTimes(1);
    expect(usersService.findByEmail).toHaveBeenCalledWith("test@email.com");
  });

  it("throws an error if password does not match", () => {
    (usersService.findByEmail as jest.Mock).mockReturnValue({      id: 200,      email: "test@email.com",      password: "PASSWORD",    });
    expect(() => authService.logIn("test@email.com", "WRONG_PASSWORD")).toThrow(
      "Wrong Password"
    );

    expect(usersService.findByEmail).toHaveBeenCalledTimes(1);
    expect(usersService.findByEmail).toHaveBeenCalledWith("test@email.com");
  });

  it("returns a user if password matches", () => {
    (usersService.findByEmail as jest.Mock).mockReturnValue({      id: 200,      email: "test@email.com",      password: "PASSWORD",    });
    expect(authService.logIn("test@email.com", "PASSWORD")).toHaveProperty(
      "id",
      200
    );

    expect(usersService.findByEmail).toHaveBeenCalledTimes(1);
    expect(usersService.findByEmail).toHaveBeenCalledWith("test@email.com");
  });
});

위 코드를 보시면 모킹된 findByEmail() 함수가 특정 사용자 객체를 반환하도록 설정할 때마다 as 키워드를 사용하여 jest.Mock 타입으로 타입을 강제해주는 것을 볼 수 있습니다. 따라서 이 방법은 타입 안전(type-safe)하지 않으며 타입스크립트로 테스트를 작성할 때는 권장되지 않는 방법입니다.

뿐만 아니라, 만약게 UsersService 클래스가 여러 개의 메서드로 이루어져 있다면, 일일이 인스턴스의 모든 메서드를 jest.fn()으로 할당해주는 것이 상당히 번거로웠을 것입니다.

더 나은 방법은 없을까요?

jest-mock-extended 패키지

위에서 설명드린 문제는 jest-mock-extended 패키지를 사용하여 해결할 수 있습니다. 우선 npm 저장소로 부터 jest-mock-extended 패키지를 내려 받아서 실습 프로젝트에 설치해야 합니다.

Node.js 프로젝트에서는 터미널에서 npm add 명령어로 패키지를 설치합니다.

$ npm add -D jest-mock-extended

Bun을 사용하는 프로젝트에서는 bun add 명령어로 패키지를 설치합니다.

$ bun add -D jest-mock-extended

jest-mock-extended 패키지에서 제공하는 mock() 함수를 사용하면 간편하게 가짜 인스턴스를 생성할 수 있습니다. 그리고 jest-mock-extended 패키지에서 제공하는 MockProxymock() 함수가 반환하는 가짜 인스턴스를 타입을 지정해줍니다.

class-mock/auth.service2.test.ts
import { type MockProxy, mock } from "jest-mock-extended";import { AuthService } from "./auth.service";
import { UsersService } from "./users.service";

describe("AuthService", () => {
  let usersService: MockProxy<UsersService>;  let authService: AuthService;

  beforeEach(() => {
    usersService = mock<UsersService>();    authService = new AuthService(usersService);
  });

  it("throws an error if user is not found", () => {
    usersService.findByEmail.mockReturnValue(null);
    expect(() => authService.logIn("test@email.com", "NOT_FOUND")).toThrow(
      "Not Found"
    );

    expect(usersService.findByEmail).toHaveBeenCalledTimes(1);
    expect(usersService.findByEmail).toHaveBeenCalledWith("test@email.com");
  });

  it("throws an error if password does not match", () => {
    usersService.findByEmail.mockReturnValue({      id: 200,      email: "test@email.com",      password: "PASSWORD",    });
    expect(() => authService.logIn("test@email.com", "WRONG_PASSWORD")).toThrow(
      "Wrong Password"
    );

    expect(usersService.findByEmail).toHaveBeenCalledTimes(1);
    expect(usersService.findByEmail).toHaveBeenCalledWith("test@email.com");
  });

  it("returns a user if password matches", () => {
    usersService.findByEmail.mockReturnValue({      id: 200,      email: "test@email.com",      password: "PASSWORD",    });
    expect(authService.logIn("test@email.com", "PASSWORD")).toHaveProperty(
      "id",
      200
    );

    expect(usersService.findByEmail).toHaveBeenCalledTimes(1);
    expect(usersService.findByEmail).toHaveBeenCalledWith("test@email.com");
  });
});

이제 테스트 코드로 부터 as jest.Mock이 모두 사라져 타입 안전해진 것을 볼 수 있습니다. 🎉

의존성 주입이 불가능 하다면?

만약에 애플리케이션 코드가 의존성을 주입할 수 없는 구조로 설계되어 있다면 어떻게 할까요?

예를 들어서, AuthService 클래스의 생성자를 아래와 같이 변경해보겠습니다. 이 전에는 클래스의 생성자를 통해서 UsersService 클래스의 인스턴스가 넘길 수 있었는데, 이 번에는 생성자 안에서 UsersService 클래스의 인스턴스를 직접 생성하고 있습니다.

module-mock/auth.service.ts
import { UsersService } from "./users.service";

export class AuthService {
  usersService: UsersService;

  constructor() {
    this.usersService = new UsersService();  }

  logIn(email: string, password: string) {
    const user = this.usersService.findByEmail(email);    if (!user) throw new Error("Not Found");
    if (user.password !== password) throw new Error("Wrong Password");
    return user;
  }
}

아직 자바스크립트 생태계에서 의존성 주입이 대세가 된지가 오래되지 않았기 때문에 이런 방식으로 작성된 레거시 코드가 많을 거에요. 이런 경우에는 어쩔 수 없이 UsersService 클래스를 담고 있는 ./users.service 모듈을 모킹해야 되서 테스트 코드를 작성하기 좀 더 까다로워집니다.

클래스의 가짜 인스턴스 접근

Jest에서 모듈을 모킹할 때는 jest.mock() 함수를 사용하며, 해당 모듈이 내보내는 모든 클래스의 생성자는 가짜 함수로 대체됩니다.

테스트가 실행되는 동안 이 가짜 생성자로 생성된 인스턴스는 해당 클래스의 mock.instances 배열에 저장이 됩니다. 따라서 우리는 이 배열을 통해서 원하는 인스턴스에 접근 후에 특정 메서드가 원하는 방식으로 동작하도록 설정해줄 수 있습니다.

예를 들어, 실습 프로젝트에서 AuthService 클래스는 생성자 내에서 UsersService 클래스의 인스턴스가 딱 한 번 생성되므로, UsersService.mock.instances[0]을 통해서 해당 인스턴스에 접근할 수 있을 것입니다.

여기서 UsersService.mock.instances 배열에 UsersService 클래스의 인스턴스가 누적되지 않도록 주의해야합니다. 이를 위해서 beforeEach() 함수 내에서 UsersService 클래스를 상대로 mockClear() 함수를 호출해줍니다.

module-mock/auth.service1.test.ts
import { AuthService } from "./auth.service";
import { UsersService } from "./users.service";

jest.mock("./users.service");
describe("AuthService", () => {
  let usersService: UsersService;
  let authService: AuthService;

  beforeEach(() => {
    (UsersService as jest.Mock).mockClear();    authService = new AuthService();
    usersService = (UsersService as jest.Mock).mock.instances[0];
  });

  it("throws an error if user is not found", () => {
    (usersService.findByEmail as jest.Mock).mockReturnValue(null);
    expect(() => authService.logIn("test@email.com", "NOT_FOUND")).toThrow(
      "Not Found"
    );

    expect(usersService.findByEmail).toHaveBeenCalledTimes(1);
    expect(usersService.findByEmail).toHaveBeenCalledWith("test@email.com");
  });

  it("throws an error if password does not match", () => {
    (usersService.findByEmail as jest.Mock).mockReturnValue({      id: 200,      email: "test@email.com",      password: "RIGHT",    });
    expect(() => authService.logIn("test@email.com", "WRONG")).toThrow(
      "Wrong Password"
    );

    expect(usersService.findByEmail).toHaveBeenCalledTimes(1);
    expect(usersService.findByEmail).toHaveBeenCalledWith("test@email.com");
  });

  it("returns a user if password matches", () => {
    (usersService.findByEmail as jest.Mock).mockReturnValue({      id: 200,      email: "test@email.com",      password: "RIGHT",    });
    expect(authService.logIn("test@email.com", "RIGHT")).toHaveProperty(
      "id",
      200
    );

    expect(usersService.findByEmail).toHaveBeenCalledTimes(1);
    expect(usersService.findByEmail).toHaveBeenCalledWith("test@email.com");
  });
});

위 코드를 보시면 모킹된 findByEmail() 함수를 설정하거나 정리(clear)할 때 마다 as 키워드로 타입을 jest.Mock으로 강제해주고 있습니다. jest.mock() 함수로 모듈 모킹을 해주었지만 여전히 타입스크립트 컴파일러는 usersService 변수의 타입이 UsersService라고 생각하기 때문입니다.

클래스의 프로토타입 스파이

클래스를 위한 테스트 코드를 작성할 때 의존성 주입이 불가능한 상황에서 클래스를 모킹할 수 있는 두 번째 방법은 클래스의 프로토타입을 jest.spyOn() 함수를 사용하여 스파이(spy)하는 것입니다.

예를 들어, UsersService 클래스의 프로토타입의 findByEmail() 함수를 스파이하여 테스트 용 사용자 객체를 반환하도록 만들어주겠습니다.

module-mock/auth.service2.test.ts
import { AuthService } from "./auth.service";
import { UsersService } from "./users.service";

jest.mock("./users.service");
describe("AuthService", () => {
  let authService: AuthService;

  beforeEach(() => {
    (UsersService.prototype.findByEmail as jest.Mock).mockReset();    authService = new AuthService();
  });

  it("throws an error if user is not found", () => {
    const mockFindByEmail = jest      .spyOn(UsersService.prototype, "findByEmail")      .mockReturnValue(null);
    expect(() => authService.logIn("test@email.com", "NOT_FOUND")).toThrow(
      "Not Found"
    );

    expect(mockFindByEmail).toHaveBeenCalledTimes(1);
    expect(mockFindByEmail).toHaveBeenCalledWith("test@email.com");
  });

  it("throws an error if password does not match", () => {
    const mockFindByEmail = jest      .spyOn(UsersService.prototype, "findByEmail")      .mockReturnValue({        id: 200,        email: "test@email.com",        password: "PASSWORD",      });
    expect(() => authService.logIn("test@email.com", "WRONG_PASSWORD")).toThrow(
      "Wrong Password"
    );

    expect(mockFindByEmail).toHaveBeenCalledTimes(1);
    expect(mockFindByEmail).toHaveBeenCalledWith("test@email.com");
  });

  it("returns a user if password matches", () => {
    const mockFindByEmail = jest      .spyOn(UsersService.prototype, "findByEmail")      .mockReturnValue({        id: 200,        email: "test@email.com",        password: "PASSWORD",      });
    expect(authService.logIn("test@email.com", "PASSWORD")).toHaveProperty(
      "id",
      200
    );

    expect(mockFindByEmail).toHaveBeenCalledTimes(1);
    expect(mockFindByEmail).toHaveBeenCalledWith("test@email.com");
  });
});

이 테스트 코드에서는 각 테스트에서 모킹된 UsersService 클래스의 프로토타입의 findByEmail() 함수를 초기화(reset)하여 테스트 간에 서로 간섭이 일어나지 않도록 해주고 있습니다.

클래스의 생성자 모킹

또 다른 접근 방법으로 jest.mock() 함수로 모듈을 모킹할 때 클래스의 생성자를 모킹하는 것도 고려해볼 수 있습니다. jest.mock() 함수의 두 번째 인자로 가짜 구현을 반환하는 팩토리 함수를 넘기면, 해당 모듈이 가짜 구현으로 완전히 대체됩니다.

클래스의 생성자도 결국에는 정해진 메서드를 속성으로 갖는 객체를 반환하는 자바스크립트의 함수일 뿐입니다. 이 것을 착안하면 클래스를 내보내는 모듈을 가짜 구현으로 대체하는 테스트 코드를 작성할 수 있습니다.

예를 들어, UsersService 클래스를 내보내는 ./auth.service 모듈을 대체 구현해보겠습니다. findByEmail 속성의 값으로 jest.fn()이 반환하는 가짜 함수를 할당해줍니다. 그리고 각 테스트에서 모킹된 findByEmail() 함수가 테스트 용 사용자 객체를 반환하도록 설정합니다.

module-mock/auth.service3.test.ts
import { AuthService } from "./auth.service";

const mockFindByEmail = jest.fn();jest.mock("./users.service", () => {  return {    UsersService: jest.fn().mockImplementation(() => {      return { findByEmail: mockFindByEmail };    }),  };});
describe("AuthService", () => {
  let authService: AuthService;

  beforeEach(() => {
    mockFindByEmail.mockReset();    authService = new AuthService();
  });

  it("throws an error if user is not found", () => {
    mockFindByEmail.mockReturnValue(null);
    expect(() => authService.logIn("test@email.com", "NOT_FOUND")).toThrow(
      "Not Found"
    );

    expect(mockFindByEmail).toHaveBeenCalledTimes(1);
    expect(mockFindByEmail).toHaveBeenCalledWith("test@email.com");
  });

  it("throws an error if password does not match", () => {
    mockFindByEmail.mockReturnValue({      id: 200,      email: "test@email.com",      password: "RIGHT",    });
    expect(() => authService.logIn("test@email.com", "WRONG_PASSWORD")).toThrow(
      "Wrong Password"
    );

    expect(mockFindByEmail).toHaveBeenCalledTimes(1);
    expect(mockFindByEmail).toHaveBeenCalledWith("test@email.com");
  });

  it("returns a user if password matches", () => {
    mockFindByEmail.mockReturnValue({      id: 200,      email: "test@email.com",      password: "PASSWORD",    });
    expect(authService.logIn("test@email.com", "PASSWORD")).toHaveProperty(
      "id",
      200
    );

    expect(mockFindByEmail).toHaveBeenCalledTimes(1);
    expect(mockFindByEmail).toHaveBeenCalledWith("test@email.com");
  });
});

여기서 한 가지 주의할 점이 있는데 가짜 함수의 이름은 실습 코드의 mockFindByEmail처럼 반드시 mock으로 시작해야합니다. Jest가 jest.mock() 함수의 호출을 맨 위로 올릴(hoist) 때, mock으로 시작하는 함수만 같이 올려주기 때문입니다.

실습 코드

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

마치면서

지금까지 Jest를 이용하여 자바스크립트/타입스크립트의 클래스를 모킹하고 테스트하는 여러가지 접근 방법에 대해서 알아보았습니다.

클래스에 대한 테스트를 작성하실 때는 가급적 jest.mock()을 사용하여 모듈을 모킹하기 보다는 의존성 주입이 가능하도록 애플리케이션 코드를 리팩토링(refactoring)해보시라고 추천드리고 싶습니다. 애초에 클래스 간에 느슨하게 결합되도록 애플리케이션 코드의 구조가 잡혀 있으면 테스트 코드를 작성하고 수월해지고 테스트 코드를 유지보수하기도 좋을 것입니다.

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