티스토리 뷰

테스트 코드란?

개발한 코드가 의도한대로 동작하는지 작성하는 코드

작성한 코드에 문제가 없는지 테스트하기 위해 작성하는 코드

일곱테스트원칙

더보기

■ 테스팅은 결함의 존재를 보여주는 것이다.


■ 완벽한 테스트는 불가능하다.


■ 테스트 구성은 가능한 빠른 시기에 시작한다.


결함은 군집되어 있다.


살충제 역설(Pesticide Paradox)

  - 비슷한 테스트가 반복되면 새로운 결함을 발견할 수 없다.


테스팅은 정황에 의존적이다.


오류 부재의 오해

  - 사용되지 않는 시스템이나 사용자의 기대에 부응하지 않는 기능의 결함을 찾고 수정하는 것은 의미가 없다.

테스트 코드의 종류

 - 단위 테스트

 - 통합 테스트

 - E2E 테스트

 - 시스템 테스트

 - 인수 테스트

 

jest를 사용하는 이유?

테스트 코드의 표현이 다른 프레임워크보다 훨씬 간결

jest.config.js 파일을 정의하거나,

CLI 환경에서 추가 옵션을 설정하여 커버리지를 출력하거나, 실시간 모니터링 등 다양한 기능을 사용할 수 있다.

더보기

모듈 설치

// yarn 프로젝트를 초기화합니다.
yarn init -y

// DevDependencies로 jest를 설치합니다.
yarn add -D jest

 

pakage.json 파일 수정

{
  "name": "node15",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
  },
  "devDependencies": {
    "jest": "^29.7.0"
  }
}

위와 같이 scripts를 추가해줍니다.

이렇게 설정을 하게 되면 yarn test로 테스트를 실행할수 있게됩니다.

( 이렇게 하는것이 모범 사례로 꼽힘 !!!)

 

 

테스트를 받아야되는 일반 파일

// validation.js

/*  정규 표현식
    /^ / : 문자열의 시작과 끝을 의미
    [a-z0-9.-] : 소문자 a부터z, 숫자 0부터1, .과-중 하나를 의미
    + : 앞의 패턴이 하나 이상 반복됨을 나타냄
    $ : 문자열의 끝을 의미함
    gi : gi플래그라고함. 
         • g : Global플래그, 문자열 내의 모든 패턴을 찾도록함
         • i : Case-insensitive플래그, 대소문자를 구별하지 않음
    .test() : 주어진 문자열이 정규표현식과 일치하는지 여부를 반환
    ! : 논리 부정 연산
*/

export const isEmail = (value) => {
  const email = value || "";
  const [localPart, domain, ...etc] = email.split("@");

  if (!localPart || !domain || etc.length) {
    return false;
  } else if (email.includes(" ")) {
    return false;
  } else if (email[0] === "-") {
    return false;
  } else if (!/^[a-z0-9+_-]+$/gi.test(localPart)) {
    return false;
  } else if (!/^[a-z0-9.-]+$/gi.test(domain)) {
    return false;
  }

  return true;
};

 

 

테스트 코드 파일

// validation.spec.js

// 테스트 코드는 위에서 에러가 나면 그 밑의 테스트로 내려가지 않음.
// 위에서 에러가 난것부터 하나씩 잡고 내려 가야함.

import { isEmail } from "./validation";

test('입력한 이메일 주소에는 "@" 문자가 1개만 있어야 이메일 형식이다.', () => {
  expect(isEmail("my-email@domain.com")).toEqual(true); // 하나만 있으면 true
  expect(isEmail("my-email@@@@domain.com")).toEqual(false); // @가 많으면 false
  expect(isEmail("my-emaildomain.com")).toEqual(false); // 없으면 false
});

test("입력한 이메일 주소에 공백(스페이스)이 존재하면 이메일 형식이 아니다.", () => {
  expect(isEmail("my-email@domain.com")).toEqual(true); // 하나만 있으면 true
  expect(isEmail("my email@domain.com")).toEqual(false); // @가 많으면 false
});

test("입력한 이메일 주소 맨 앞에 하이픈(-)이 있으면 이메일 형식이 아니다.", () => {
  expect(isEmail("my-email@domain.com")).toEqual(true); // 하나만 있으면 true
  expect(isEmail("-my-email@domain.com")).toEqual(false); // @가 많으면 false
});

test("입력한 이메일 주소중, 로컬 파트(골뱅이 기준 앞부분)에는 영문 대소문자와 숫자, 특수문자는 덧셈기호(+), 하이픈(-), 언더바(_) 3개 외에 다른 값이 존재하면 이메일 형식이 아니다.", () => {
  expect(isEmail("_good-Email+test99@domain.com")).toEqual(true);
  expect(isEmail("my$bad-Email9999@domain.com")).toEqual(false);
});

test("입력한 이메일 주소중, 도메인(골뱅이 기준 뒷부분)에는 영문 대소문자와 숫자, 하이픈(-) 외에 다른 값이 존재하면 이메일 형식이 아니다.", () => {
  expect(isEmail("my-email@my-Domain99.com")).toEqual(true);
  expect(isEmail("my-email@my_Domain99.com")).toEqual(false);
  expect(isEmail("my-email@my$Domain99.com")).toEqual(false);
});

 

위에서 보듯이 테스트할 코드를 테스트파일로 가져와서 그파일에서 일어날수있는 경우를 모두 테스트 합니다.

테스트 파일 이름을  테스트할파일이름.spec.js와 같은 형식으로 짓는 이유는 

일반적인 관례이기도 하고, jest에서도 해당 형식의 이름을 읽어들여 테스트 코드를 실행하도록 하였습니다.

또한 테스트 코드는 실패하는 코드가 최소 하나라도 있게 작성을 해야합니다.

 

문법

test() - 단위테스트를 묶어주는 함수

expect() - 특정값이 만족되는지 확인하기 위한 표현식을 작성할수 있도록해주는 함수

 

 

일단 테스트 코드를 작성할 폴더및 파일들을 만들어줍니다.

아래에는 테스트 하려는 3계층아이들이 있다.

 

모듈 설치

// DevDependencies로 jest, cross-env 를 설치
yarn add -D jest // Jest를 개발 종속성으로 추가

yarn add cross-env // 환경 변수를 설정하기 위한 플랫폼 간의 문제를 해결해주는 패키지

yarn add @jest/globals // Jest에서 전역으로 사용할 수 있는 헬퍼 함수와 유틸리티를 제공
			// Jest의 내장 기능에 액세스하고 사용자 지정 테스트 환경을 설정하는 데 유용

 

 

Jest Configs 설정

// jest.config.js

export default {
  // 해당 패턴에 일치하는 경로가 존재할 경우 테스트를 하지 않고 넘어감.
  testPathIgnorePatterns: ['/node_modules/'],
  
  // 테스트 실행 시 각 TestCase에 대한 출력을 해줍니다.
  verbose: true,
};

 

Jest CLI Script 설정

// package.json

{
  ...

  "scripts": {
    ...

    "test": "cross-env NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --forceExit",
    "test:silent": "cross-env NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --silent --forceExit",
    "test:coverage": "cross-env NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --coverage --forceExit",
    "test:unit": "cross-env NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest __tests__/unit --forceExit"
  },

  ...
}

cross-env

OS마다 다른 환경 변수 설정 방식을 일괄적으로 통일해주는 모듈

macOS는 NODE_ENV=test 형식으로 환경 변수를 설정하지만, Windows에서는 set NODE_ENV=test 로 설정

cross-env는 이러한 OS 종속적인 부분을 일관된 문법으로 사용할 수 있게 해줌.

 

--forceExit

• 테스트 코드 검사가 완료되었을떄, 강제로 jest를 종료

• express의 app 객체와 Prisma 연결이 Connect상태로 남아있어 테스트 코드가 종료되지 않을 때 사용

 

--silent

• 테스트 코드를 실행했을 때, console.log와 같은 메시지를 출력하지 않음

 

--coverage

• 테스트 코드 검사가 완료된 후 현재 프로젝트의 테스트 코드 커버리지를 출력

• 미설정일시

 

•설정일시

 

--verbose

• 테스트 코드의 개별 테스트 결과를 표시

• 미설정

• 설정

 

 

Jest문법

expect 결과값 검증

문법 설명
.mockReturnValue(value) • Mock 함수의 반환값을 지정합니다.
.toBe(value) • 입력받은 예상값과 결과값이 일치하는지 비교
객체 인스턴스를 비교하려 한다면, 인스턴스 ID까지 비교하므로 엄격하게 동일한지 검증
.toEqual(value) • 입력받은 예상값과 결과값이 일치하는지 비교
.toMatch(regexp | string) • 입력받은 문자열이 결과값과 같은지 검증
String 또는 정규표현식으로 검증
.toBeTruthy() • 결과값이 true인지 검증
.toBeInstanceOf(Class) • 입력받은 예상값과 Class가 동일한 Instance인지 검증
Error를 검증할 때 주로 사용
.toHaveProperty(keyPath, value?) • 입력받은 객체의 Key와 Value가 일치하는지 검증
.toMatchObject(object) • 입력받은 객체와 결과 객체가 일치하는지 검증
입력받은 객체에는 없지만, 결과 객체에 있는 속성이 있다면 이를 무시하고 일치 여부를 확인

 

Global Jest 문법

문법 설명
afterAll(fn, timeout) • 모든 test()가 완료된 이후에 수행
• 테스트가 완료된 이후 DB에 변경된 데이터를 삭제하거나 Mock을 초기화 하기 위해 사용
afterEach(fn, timeout) • 각 test()가 완료된 이후에 수행
• 테스트코드가 완료된 이후 Mock 또는 변경된 전역 변수를 초기화할 때 사용
beforeAll(fn, timeout) • 테스트 코드가 실행되기 전 최초로 수행
• DB의 데이터를 초기화하거나 전역 Mock을 초기화할 때 사용
beforeEach(fn, timeout) • 각 test()가 실행되기 전에 수행
• 테스트가 실행되기 전, 동일한 설정을 반복해야할 때 사용

 

 

테스트 코드에 사용하는 Mock Function - 가짜 객체

테스트에서 시간 또는 비용이 많이들거나,
의존성이 높은 코드를 직접 실행하지 않고 호출 여부,
입력한 값의 일치 여부와 같은 정보를 확인하기 위해 사용하는 가짜 객체

매번 DB에 접근하여 데이터를 수정하거나, SMS 전송과 같은 비용이 발생하는 작업을 반복할 수는 없다

따라서, 코드를 실행했을 때 문제가 발생하거나 로직을 검사하는데 방해가 되는 코드를 실제로 실행한 것처럼 만들기 위해 Mock이라는 가짜 객체를 사용

Mock 객체는 테스트 코드에서 특정 코드를 실행하지 않고, 원하는 부분만을 테스트하고 싶을 때 
실제로 존재하는 값처럼 사용할 수 있도록 만들어놓은 가짜 객체

 

 

예시

// mockPrisma라는 실제 DB를 Mocking하기 위한 Mock 객체를 생성
// Prisma 클라이언트에서는 아래 5개의 메서드만 사용합니다.
// Mock 객체는 PostsRepository 클래스가 사용하는 Prisma 클라이언트의 모든 메서드를 Mocking한 객체
let mockPrisma = {
  posts: {
    findMany: jest.fn(), // jest.fn()은 특정 메서드를 Mocking하는 Mock Function(모의 함수)
    findUnique: jest.fn(),
    create: jest.fn(),
    update: jest.fn(),
    delete: jest.fn(),
  },
};

 

Mock expect 문법

문법 설명
.toHaveBeenCalledTimes(number) • Mock이 몇번 호출되었는지 검증
.toHaveBeenCalledWith(arg1, arg2, ...) • 어떤 인자를 이용해 Mock이 호출되었는지 검사

 

 

프로젝트에 적용시키기

//users.repositories.spec.js

import { jest } from "@jest/globals";
import { UsersRepositories } from "../../../src/repositories/users.repositories";

// mockPrisma라는 실제 DB를 Mocking하기 위한 Mock 객체를 생성
// jest.fn()은 특정 메서드를 Mocking하는 Mock Function(모의 함수)
let mockPrisma = {
  users: {
    findFirst: jest.fn(),
    create: jest.fn(),
    update: jest.fn(),
  },
};

//  mockPrisma를 사용하여 의존성 주입
let usersRepositories = new UsersRepositories(mockPrisma);

// Jest에서 테스트 스위트(Test Suite)를 정의하는데 사용 (특정 기능 또는 모듈에 대한 관련된 테스트들의 집합)
// 즉, 테스트 스위트를 정의한뒤 그 안에 여러개의 테스트 케이스를 작성.
describe("Users Repository Unit Test", () => {
  // 테스트가 실행되기전에 실행되는 hook(훅)중 하나이며각 테스트 케이스가 실행되기 전에 실행되는 코드를 정의 할 떄 사용.
  /* jest.resetAllMocks()를 사용하여 초기화 하는 이유!! */
  // 테스트를 진행하다보면 Mock 함수를 여러 번 사용하게 되는데,
  // 각 테스트 케이스가 독립적으로 실행되어야 하기 때문에 이전 테스트에서 설정한 Mock 함수의 상태가 다음 테스트에 영향을 주지 않도록
  // 각 test가 실행되기 전에 실행하여 모든 Mock을 초기화 합니다..
  beforeEach(() => {
    jest.resetAllMocks();
  });

  test("findByEmail Method", async () => {
    const email = "aaa@naver.com";
    const mockReturn = {
      id: 1,
    };

    // findFirst의 mock(가짜 객체)에 값을 지정해줍니다.
    mockPrisma.users.findFirst.mockReturnValue(mockReturn);

    const user = await usersRepositories.findByEmail(email);

    // 해당 메서드가 한번만 호출되었는지 확인하는 이유
    // 메서드가 예상대로 동작하는지 확인하기 위한 테스트의 한 부분이기 때문.
    expect(usersRepositories.prisma.users.findFirst).toHaveBeenCalledTimes(1);

    // mockPrisma의 Return과 출력된 findMany Method의 값이 일치하는지 비교합니다.
    expect(user).toBe(mockReturn);
  });

  test("createUser Method", async () => {
    const mockReturn = "create Return String";
    mockPrisma.users.create.mockReturnValue(mockReturn);

    const createUserParams = {
      email: "create email",
      password: "create hashedPassword",
      userName: "create userName",
    };

    const user = await usersRepositories.createUser(
      createUserParams.email,
      createUserParams.password,
      createUserParams.userName
    );

    expect(usersRepositories.prisma.users.create).toHaveBeenCalledTimes(1);

    expect(user).toBe(mockReturn);

    expect(mockPrisma.users.create).toHaveBeenCalledWith({
      data: createUserParams,
    });
  });

  test("updateToken Method", async () => {
    const mockReturn = "upload refreshToken";
    mockPrisma.users.update.mockReturnValue(mockReturn);

    // id와 토큰 생성및 인자로 넣기
    const createUserParams = {
      id: "create id",
      refreshToken: "create token",
    };

    const user = await usersRepositories.updateToken(
      createUserParams.id,
      createUserParams.refreshToken
    );

    expect(usersRepositories.prisma.users.update).toHaveBeenCalledTimes(1);

    expect(user).toBe(mockReturn);
  });
  test("findById Method", async () => {
    const id = "create id";
    const mockReturn = "findById success";

    mockPrisma.users.findFirst.mockReturnValue(mockReturn);

    const user = await usersRepositories.findById(id);

    expect(usersRepositories.prisma.users.findFirst).toHaveBeenCalledTimes(1);

    expect(user).toBe(mockReturn);
  });
});

 

// users.services.spec.js

import { beforeEach, describe, expect, jest, test } from "@jest/globals";
import { UsersServices } from "../../../src/services/users.services";
import bcrypt from "bcrypt";
import dotenv from "dotenv";

dotenv.config();

// UsersRepositories를 대신하기 위한 가짜 객체 정의
let mockUsersRepository = {
  findByEmail: jest.fn(),
  createUser: jest.fn(),
  updateToken: jest.fn(),
  findById: jest.fn(),
};

// UserService의 repositories를 가짜 객체로 의존성 주입
let usersServices = new UsersServices(mockUsersRepository);

describe("User Service Unit test", () => {
  // 테스트 초기화
  beforeEach(() => {
    jest.resetAllMocks();
  });

  //회원가입 테스트
  test("createUser Method", async () => {
    const sampleUser = {
      id: 1,
      email: "hong@naver.com",
      userName: "홍길동",
    };

    // 실제코드에서 반환되는 return값에 맞춰야함.
    mockUsersRepository.createUser.mockReturnValue(sampleUser);

    const createdUser = await usersServices.createUser(
      "hong@naver.com",
      "1234",
      "홍길동"
    );

    // 해당하는 이메일이 있는지 확인
    expect(mockUsersRepository.findByEmail).toHaveBeenCalledTimes(1);
    expect(mockUsersRepository.findByEmail).toHaveBeenCalledWith(
      sampleUser.email
    );

    // 없다면 유저 생성
    expect(mockUsersRepository.createUser).toHaveBeenCalledTimes(1);
    expect(createdUser).toEqual(sampleUser);
  });

  // 로그인 테스트
  test("logInUser Method", async () => {
    const hashedPassword = await bcrypt.hash("123456", 10);
    //샘플데이터
    const sampleUser = {
      id: 1,
      email: "hong@naver.com",
      password: hashedPassword,
      userName: "홍길동",
      role: "USER",
    };
    // 샘플데이터 리턴값 저장
    mockUsersRepository.findByEmail.mockReturnValue(sampleUser);

    const user = await usersServices.logInUser("hong@naver.com", "123456");

    // 유저가 있는지 확인
    expect(mockUsersRepository.findByEmail).toHaveBeenCalledTimes(1);
    expect(mockUsersRepository.findByEmail).toBeCalledWith(sampleUser.email);

    // 데이터베이스에 토큰 업데이트
    expect(mockUsersRepository.updateToken).toHaveBeenCalledTimes(1);
    expect(mockUsersRepository.updateToken).toHaveReturned();

    // user가 정의되어있는지 확인
    expect(user).toBeDefined();
  });

  // 내정보 조회 테스트
  test("getUserById", async () => {
    const sampleUser = {
      id: "1",
      email: "hong@naver.com",
      userName: "홍길동",
      role: "USER",
      token: "created token",
    };
    mockUsersRepository.findById.mockReturnValue(sampleUser);

    const user = await usersServices.getUserById(1);

    expect(mockUsersRepository.findById).toBeCalledTimes(1);
    expect(user).toStrictEqual(sampleUser);
  });
});

 

// users.controller.spec.js

import { beforeEach, describe, expect, jest } from "@jest/globals";
import { UsersController } from "../../../src/controllers/users.controller";
const mockUsersService = {
  createUser: jest.fn(),
  logInUser: jest.fn(),
  getUserById: jest.fn(),
};

const mockRequest = {
  body: jest.fn(),
  user: jest.fn(),
};

const mockResponse = {
  status: jest.fn(),
  json: jest.fn(),
  cookie: jest.fn(), // res.cookie를 스파이로 사용, 스파이는 메서드 호출 추적 및 호출에 대한 정보 제공
};

const mockNext = jest.fn();

const usersController = new UsersController(mockUsersService);

describe("Users Controller unit Test", () => {
  beforeEach(() => {
    jest.resetAllMocks();
    mockResponse.status.mockReturnValue(mockResponse);
  });

  // 회원가입 테스트
  test("signUp Method by success", async () => {
    const createUserRequestBody = {
      email: "email_Success",
      password: "123456",
      passwordCheck: "123456",
      userName: "userName_Success",
    };

    mockRequest.body = createUserRequestBody;

    const createUserReturnValue = {
      id: 1,
      email: "email_Success",
      userName: "userName_Success",
    };

    mockUsersService.createUser.mockReturnValue(createUserReturnValue);

    await usersController.signUp(mockRequest, mockResponse, mockNext);

    expect(mockUsersService.createUser).toHaveBeenCalledWith(
      createUserRequestBody.email,
      createUserRequestBody.password,
      createUserRequestBody.userName
    );

    expect(mockUsersService.createUser).toHaveBeenCalledTimes(1);
    expect(mockResponse.status).toHaveBeenCalledTimes(1);
    expect(mockResponse.status).toHaveBeenCalledWith(201);
    expect(mockResponse.json).toHaveBeenCalledWith({
      data: createUserReturnValue,
    });
  });

  // 로그인 테스트
  test("signIn Method success", async () => {
    const signInRequestBody = {
      email: "hong@naver.com",
      password: "123456",
    };
    mockRequest.body = signInRequestBody;

    const signInReturnValue = {
      accessToken: "accessToken",
      refreshToken: "refreshToken",
      id: "8",
      email: "hong@naver.com",
      userName: "홍길동",
      role: "USER",
    };

    mockUsersService.logInUser.mockReturnValue(signInReturnValue);

    await usersController.signIn(mockRequest, mockResponse, mockNext);

    expect(mockUsersService.logInUser).toHaveBeenCalledTimes(1);
    expect(mockResponse.cookie).toHaveBeenCalled(); // 전달된 인수를 검증하여 올바른 쿠키 값이 설정되었는지를 확인
    expect(mockResponse.cookie).toHaveBeenCalledWith(
      "accessToken",
      signInReturnValue.accessToken
    );
    expect(mockResponse.cookie).toHaveBeenCalledWith(
      "refreshToken",
      signInReturnValue.refreshToken
    );
    expect(mockResponse.status).toHaveBeenCalledTimes(1);
    expect(mockResponse.status).toHaveBeenCalledWith(201);
    expect(mockResponse.json).toHaveBeenCalledWith({
      data: signInReturnValue,
    });
  });

  // 내정보 조회 테스트
  test("myInfo Method success", async () => {
    const myInfoRequestUser = {
      id: "1",
    };

    mockRequest.user = myInfoRequestUser;

    const MyInfoReturnValue = {
      id: "1",
      email: "hong@naver.com",
      userName: "홍길동",
      role: "USER",
      token: "CREATED TOKEN",
    };

    mockUsersService.getUserById.mockReturnValue(MyInfoReturnValue);

    await usersController.myInfo(mockRequest, mockResponse, mockNext);

    expect(mockUsersService.getUserById).toHaveBeenCalledTimes(1);
    expect(mockResponse.status).toHaveBeenCalledTimes(1);
    expect(mockResponse.status).toHaveBeenCalledWith(200);
    expect(mockResponse.json).toHaveBeenCalledWith({
      data: MyInfoReturnValue,
    });
  });
});

 

 

개선할점

테스트 코드는 내가 실제 프로젝트를 더 편하게 사용하려고 쓰는것이다.

즉, 실제 서버를 가동 했을 때 서버가 내려앉지 않도록, 더 확실하게 자신을 가지고 서버를 돌릴수 있도록 하는것이다.

그러기 위해선 성공하는 코드 보다 실패하는 코드를 더 많이 생각해내고 테스트 해야하는걸 알았다.

하지만 현재 테스트 코드들에는 오로지 성공하는 코드밖에 없으므로 실패하는 코드를 더욱 테스트해야한다.

또한 가짜 객체 (Mock)에는 실제 대응되는 값들을 넣어야하는데 현재 repository를 보면 다 그냥 문자열을 넣었다.

이건 그냥 타자연습한것과 다름이 없다고 한다.

그렇기 때문에 실제로 리턴되는 값들을 가짜 객체에 생성하고 테스트하는게 가장 정확하다.

결론: 테스트코드는 실제 작동하는 것과 최대한 동일하게 작성해야하고 실패하는 코드들을 먼저 글로 작성해보고,

         나열된 테스트할 코드들을 작성해가며 해결한뒤 조금이라도 편하게 서비스를 가동하는것이 최대 목표인것 같다.

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함