상세 컨텐츠

본문 제목

[Node.js] 심화 실습 (14-18) (Jest configs / CLI Options / scripts / Mock / 의존성주입DI / Unit Test)

notes

by 서울의볼 2024. 2. 20. 04:00

본문

이때까지 실습한 Layered Architecture에 Jest를 적용해보기 위해 아래의 순서로 설정함.

  1. .env 파일에서 DB관련 환경변수 설정함
  2. yarn 패키지 설치 (yarn) & Prisma로 DB 및 테이블 정보 동기화 (npx prisma db push)
  3. 테스팅 라이브러리 jest 모듈 설치 ( yarn add -D jest cross-env @jest/globals )
  4. jest.config.js파일을 만들어 CLI환경에서 추가 옵션을 설정하여 커버리지를 출력하거나, 실시간 모니터링 등 다양한 기능 사용 가능케 함
    • export default {
        // 해당 패턴에 일치하는 경로가 존재할 경우 테스트를 하지 않고 넘어갑니다.
        testPathIgnorePatterns: ['/node_modules/'],
        // 테스트 실행 시 각 TestCase에 대한 출력을 해줍니다.
        verbose: true,
      };
  5. ***package.json파일에서 Jest CLI 옵션을 추가한 jest script를 선언함***
    • {
        ...
      
        "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"
        },
      
        ...
      }
      뒷 단의 silent, coverage 등은 특정 경로를 뜻함 (후술).
    • 의미를 하나씩 이해해보자면, test는 Jest로 테스트코드를 실행하기 위함 / test:silent는 해당 코드를 실행했을 때 console.log와 같은 것들을 출력하지 않도록 함 / test:coverage는 테스트코드의 coverage가 어떤 코드들까지 커버가 되었는지에 대한 %가 나오게 함 / test:unit은 test폴더 하위의 유닛 폴더에 해당하는 테스트 코드 파일들만 전부 다 테스트를 진행할 수 있도록 만듦
    • cross-env는 OS마다 환경변수 설정이 달라서 OS 종속적인 부분을 일관된 문법으로 사용할 수 있게 해줌
      • macOS는 NODE_ENV=test 형식으로 환경 변수를 설정하지만, Windows에서는 set NODE_ENV=test 로 설정해야함
    • ***Jest CLI Options***:
      • --forceExit: 테스트 코드 검사가 완료되었을 때, 강제로 Jest를 종료. express의 app 객체와 Prisma 연결이 Connect상태로 남아있어 테스트 코드가 종료되지 않을 때 사용
      • --silent: 테스트 코드를 실행했을 때, console.log와 같은 메시지를 출력하지 않음
      • --coverage: 테스트 코드 검사가 완료된 후 현재 프로젝트의 테스트 코드 커버리지를 출력해줌 (작성된 코드가 얼마나 안전하게 구현됐는지 확인 가능)
      • --coverage 옵션 이미지: 
        • --coverage 설정을 하지 않은 경우
        • 테스트 파일을 기준으로 결과가 출력됨:
        • --coverage 설정을 한 경우
        • 전체 프로젝트의 커버리지가 출력됨:
      • --verbose: 테스트 코드의 개별 테스트 결과를 표시함
      • --verbose 옵션 이미지:
        • --verbose 옵션 설정을 하지 않은 경우
        • 테스트 파일을 기준으로 결과가 출력됨:
        • --verbose 옵션 설정을 한 경우
        • 테스트 파일의 test()를 기준으로 결과값이 출력됨 (테스트 결과를 명확하게 볼 수 있어 디버깅할 때 유용하게 사용할 수 있음):
  6. 외부 의존성 주입(DI: Dependency Injection: 객체 사이의 의존 관계를 외부에서 제공하는 방법) 리팩토링을 함.
    • prisma 클라이언트를 직접 사용하고 있는 클라이언트의 테스트를 진행할 때 실제 데이터베이스에 접근하지 않기 위해 외부에서 Prisma 클라이언트를 주입받아 사용하도록 변경함 --> 생성자 주입(Constructor Injection)방식:
    • 클래의 생성자에서 전달받은 prisma를 this.prisma 멤버 변수에 할당해서 직접 Prisma 클라이언트를 사용하지 않고, 생성자를 통해 prisma를 주입받아 사용함.
    • repository부터 시작해서, service, controller의 의존성도 차례대로 외부에서 주입 받도록 수정함. 그리고, 최종적으로 이 모든 의존성은 router 파일에서 관리하게 됨.
      • 예를 들자면,
        기존의 repository 코드:
        import { prisma } from '../utils/prisma/index.js';
        
        export class PostsRepository {
          findAllPosts = async () => {
            // ORM인 Prisma에서 Posts 모델의 findMany 메서드를 사용해 데이터를 요청합니다.
            const posts = await prisma.posts.findMany();
        
            return posts;
          };
          
        의존성 주입 코드:
        export class PostsRepository {
          constructor(prisma) {
            // 생성자에서 전달받은 Prisma 클라이언트의 의존성을 주입합니다.
            this.prisma = prisma;
          }
        
          findAllPosts = async () => {
            // ORM인 Prisma에서 Posts 모델의 findMany 메서드를 사용해 데이터를 요청합니다.
            const posts = await this.prisma.posts.findMany();
        
            return posts;
          };
         
      • 이렇게 하고 router는 아래와 같이 수정함:
      • import express from 'express';
        import { prisma } from '../utils/prisma/index.js';
        import { PostsRepository } from '../repositories/posts.repository.js';
        import { PostsService } from '../services/posts.service.js';
        import { PostsController } from '../controllers/posts.controller.js';
        
        const router = express.Router();
        
        // 3계층의 의존성을 모두 주입합니다.
        const postsRepository = new PostsRepository(prisma);
        const postsService = new PostsService(postsRepository);
        const postsController = new PostsController(postsService);
    • 개념이 좀 어려운데, 풀어쓰자면 기존의 PostsRepository 클래스는 Prisma 클라이언트의 findMany 메서드에 직접적으로 의존하고 있었음. 이런 구조를 바탕으로 테스트 코드를 작성한다면, PostsRepository 클래스가 사용하는 Prisma 클라이언트를 모킹(Mocking) 할 수 없기 때문에, 실제 데이터베이스에 계속 접근하게됨.
    • 해당 클래스 내에서 직접 Prisma 클라이언트를 참조하는 대신, 외부에서 Prisma 클라이언트를 주입받아 사용하도록 변경하는데, 이를 DI라고 함. 그리고 그 방법 중 하나가 생성자 주입, CI임.
      • 생성자 주입(Constructor Injection)은 객체의 생성자(Constructor)를 호출할 때, 의존성을 전달하여 해당하는 객체는 전달받은 의존성을 이용해 코드를 실행하는 것
  7. 유닛 테스트 - Repository : Mock객 생성 (뭔가 폭풍처럼 지나감... 복습하면서 다시 작성해야 할 듯...)
    • Repository Layer는 데이터베이스 이외의 다른 하위 계층은 존재X. 유닛테스트를 위해 __tests__ 폴더 하위에 unit폴더를 생성하고, 그 안에 posts.repository.unit.spec.js 파일을 만듦.
    • // __tests__/unit/posts.repository.unit.spec.js
      
      import { jest } from '@jest/globals';
      import { PostsRepository } from '../../../src/repositories/posts.repository';
      
      // Prisma 클라이언트에서는 아래 5개의 메서드만 사용합니다.
      let mockPrisma = {
        posts: {
          findMany: jest.fn(),
          findUnique: jest.fn(),
          create: jest.fn(),
          update: jest.fn(),
          delete: jest.fn(),
        },
      };
      
      let postsRepository = new PostsRepository(mockPrisma);
      
      describe('Posts Repository Unit Test', () => {
        // 각 test가 실행되기 전에 실행됩니다.
        beforeEach(() => {
          jest.resetAllMocks(); // 모든 Mock을 초기화합니다.
        });
      
        test('findAllPosts Method', async () => {
          // findMany Mock의 Return 값을 "findMany String"으로 설정합니다.
          const mockReturn = 'findMany String';
          mockPrisma.posts.findMany.mockReturnValue(mockReturn);
      
          // postsRepository의 findAllPosts Method를 호출합니다.
          const posts = await postsRepository.findAllPosts();
      
          // prisma.posts의 findMany은 1번만 호출 되었습니다.
          expect(postsRepository.prisma.posts.findMany).toHaveBeenCalledTimes(1);
      
          // mockPrisma의 Return과 출력된 findMany Method의 값이 일치하는지 비교합니다.
          expect(posts).toBe(mockReturn);
        });
      
        test('createPost Method', async () => {
          // create Mock의 Return 값을 "create Return String"으로 설정합니다.
          const mockReturn = 'create Return String';
          mockPrisma.posts.create.mockReturnValue(mockReturn);
      
          // createPost Method를 실행하기 위해 필요한 Params 입니다.
          const createPostParams = {
            nickname: 'createPostNickname',
            password: 'createPostPassword',
            title: 'createPostTitle',
            content: 'createPostContent',
          };
      
          // postsRepository의 createPost Method를 실행합니다.
          const createPostData = await postsRepository.createPost(
            createPostParams.nickname,
            createPostParams.password,
            createPostParams.title,
            createPostParams.content,
          );
      
          // createPostData는 prisma.posts의 create를 실행한 결과값을 바로 반환한 값인지 테스트합니다.
          expect(createPostData).toBe(mockReturn);
      
          // postsRepository의 createPost Method를 실행했을 때, prisma.posts의 create를 1번 실행합니다.
          expect(mockPrisma.posts.create).toHaveBeenCalledTimes(1);
      
          // postsRepository의 createPost Method를 실행했을 때, prisma.posts의 create를 아래와 같은 값으로 호출합니다.
          expect(mockPrisma.posts.create).toHaveBeenCalledWith({
            data: createPostParams,
          });
        });
      });
  8. 유닛 테스트 - Service (에러메세지도 같아야 함...)
  9. 유닛 테스트 - Controller

 

 

 

 

 

 

Mock Functions: Mock특정 메서드나 함수를 Mocking하기 위해 사용됨. 즉, 테스트에서 시간 또는 비용이 많이들거나, 의존성이 높은 코드직접 실행하지 않고 호출 여부, 입력한 값의 일치 여부와 같은 정보를 실제로 실행한 것처럼 확인하기 위해 사용하는 가짜 객체인 것.

  • eg) Repository가 사용하는 DB를 Mocking하게 된다면, 실제 DB에 접근하지 않고도 특정한 값이 입력되었을 때 어떤 결과가 나오는지 임의로 설정하여 DB에 접근했을 때와 동일한 상황을 예상하여 코드를 작성할 수 있음.
  • 또한 Mock 객체의 특정 메서드가 몇번 호출되었는지, 어떤 값을 전달받았는지, 전달받은 데이터의 형식은 내가 생각한것이 맞는지 등 다양한 조건을 검사할 수 있음.
    • .toHaveBeenCalledTimes(number): Mock이 몇번 호출되었는지 검증
    • .toHaveBeenCalledWith(arg1, arg2, ...): 어떤 인자를 이용해 Mock이 호출되었는지 검사

 

 

 

 

자료 출처: 스파르타코딩클럽

관련글 더보기