TIL | Jest 특강

bubblegum·2024년 2월 27일
0

Today I learn(TIL)

목록 보기
27/84
post-thumbnail

1. 테스트에 대한 고찰


1) 사전에 알아야할 배경 지식

  • Node 심화 1주차 강의 완강
  • Class
  • **Layered Achitecture Pattern**
  • Unit Test
  • jest
  • 위 것들에 대해서는 기본 사용법이나 정의 등은 다루지 않을 것 입니다!

[ 특강 목표 ]
1. 왜 테스트를 해야하는가?
2. Layer 나누기 전의 테스트 코드 작성하기
3. Layer 나눈 후의 테스트 코드
4. jest 테스트 코드 작성하는 방법
5. 여러분 성공 케이스 만큼 실패에 대한 검증도 중요합니다.

❗ 시간 관계상 모든 부분을 라이브 코딩으로 진행하긴 어렵습니다! 그러니 이미 작성된 코드로 차근이 출발 해 봅시다! 속도가 빠를 수 있으니, 여러분들 이해가 안될때 멈춰 외쳐주시고, 반복하면서 가봅시다!

최대한 simple 한 코드 덩어리 기반으로,
“Node 심화 1주차 강의” 에서 했던 것을 위주로 다시 보는 형태로 진행할 예정입니다.

2) 왜 테스트를 해야하는가?

app.get("/ping", (req, res, next) => {
    return res.status(200).json({ message: "ok" });
});
  • 이런 API 를 만들었다고 가정합시다. 어떻게 테스트 하셨나요?
  • Insomnia(HTTP API TEST TOOL), Thunder Client, 뭐 브라우저 등등, 직접 요청을 하셨을 겁니다.
app.post("/user", (req, res, next) => {
    try {
      const { email, password, passwordConfirm, name, age, gender } = req.body;

      if (!email || !password || !passwordConfirm || !name || !gender)
        throw new Error('필수 값이 입력되지 않았습니다.');

      if (password !== passwordConfirm) throw new Error('비밀번호가 일치하지 않습니다.');

      if (password.length < 6) throw new Error('비밀번호는 6자 이상이어야 합니다.');

			// 유저 생성하기 코드 생략...

      return res.status(201).json({ user });
    } catch (err) {
      next(err);
    }
  };
  • 자 이제 email, password, passwordConfirm, name, age, gender 데이터를 client 에게 받아야 합니다.
  • 어떻게 테스트 할까요? 또 하나 하나 값 넣었다가, 안넣어었다 해 보면서 인섬니아로 테스트하면 되겠죠?
  • 근데 우리가 설계한 table이 10개야.. 이 10개의 table 에 해당하는 모든 model이 create가 있고 사용자에게 값을 받는다고 해봅시다.
app.post("/user", (req, res, next) => {
    try {
      const { email, password, passwordConfirm, name, age, gender } = req.body;
      if (!email || !password || !passwordConfirm || !name || !gender)
					...생략...
		}
};

app.post("/post", (req, res, next) => {
    try {
      const { content, title } = req.body;
      if (!content || !title)
					...생략...
		}
};

app.post("/comment", (req, res, next) => {
    try {
      const { content } = req.body;
      if (!content)
					...생략...
		}
};

app.post("/profile", (req, res, next) => {
    try {
      const { nickname, profileImg } = req.body;
      if (!nickname || !profileImg)
					...생략...
		}
};

...dammn....
  • 이걸 또 모두 인섬니아로 테스트 하나 하나, 값 하나 하나 넣으면서 테스트 할까요?
    • 그리고 여러분들이 직접 하나 하나 테스트 하는게 정확할까요!?
    • 아니면 기계가 자동으로 하나 하나 테스트 하는게 정확할까요!?
    • 정확도 관점에서, 사람을 믿습니까 기계를 믿습니까?!
  • 뿐만 아닙니다. 여러분들이 A 라는 서비스를 제공하는 회사에 들어갔다고 가정해봅시다.
    • 해당 회사는 단순하게 3L 패턴을 따라 아래와 같이 나눠져 있다고 해봅시다.
project-root/
├── node_modules/                   # NPM 패키지 디렉토리
├── src/
│   ├── controllers/                # 컨트롤러 파일 디렉토리
│   │   ├── model1Controller.js
│   │   ├── model2Controller.js
│   │   ├── model3Controller.js
│   │   ├── model4Controller.js
│   │   ├── model5Controller.js
│   │   ├── model6Controller.js
│   │   ├── model7Controller.js
│   │   ├── model8Controller.js
│   │   ├── model9Controller.js
│   │   ... 생략 ...
│   ├── models/                     # 모델 파일 디렉토리
│   │   ├── model1.js
│   │   ├── model2.js
│   │   ├── model3.js
│   │   ├── model4.js
│   │   ├── model5.js
│   │   ├── model6.js
│   │   ├── model7.js
│   │   ├── model8.js
│   │   ├── ***model9.js***
│   │   ... 생략 ...
│   ├── repositories/               # 레포지토리 파일 디렉토리
│   │   ├── model1Repository.js
│   │   ├── model2Repository.js
│   │   ├── model3Repository.js
│   │   ├── model4Repository.js
│   │   ├── model5Repository.js
│   │   ├── model6Repository.js
│   │   ├── model7Repository.js
│   │   ├── model8Repository.js
│   │   ├── model9Repository.js
│   │   ... 생략 ...
│   └── services/                  # 서비스 파일 디렉토리
│       ├── model1Service.js
│       ├── model2Service.js
│       ├── model3Service.js
│       ├── model4Service.js
│       ├── model5Service.js
│       ├── model6Service.js
│       ├── model7Service.js
│       ├── model8Service.js
│       ├── model9Service.js
│       ... 생략 ...
├── app.js                          # 애플리케이션 메인 파일
├── package.json                    # 프로젝트 
  • 여러분들이 model9 와 관련된 “속성” 을 하나 바꿔야 하는 임무를 받았습니다. 수정이 무서운 신입입니다.. 수정이 무서운 신입입니다..
  • 검색 창에 조심스럽게 model9 를 쳐봅니다.

Untitled

Untitled

  • model9Repository.js 에서 model9 를 쓰는 코드를 발견했다고 해보죠!
  • 그래서 2번째 사진 처럼 찾아봤는데 model3Service.js, model4Service.js, model9Service.js 에서 모두 사용하는 것 같아요!
  • 이제 살짝 식은땀이 납니다. 내가 바꾼 모델의 영향 범위가 너무 넓을 것 같은 두려움이 생겨요 힘들어서 그 사이 늙은 신입… 힘들어서 그 사이 늙은 신입…
  • 그래서 테스트 코드가 필요합니다. 이런 영향 범위를 수정하는 사람이 하나 하나 다 찾아가면서 괜찮을까? 다 테스트를 현실적으로 할 수 있을까요?
    • 시간을 들이면 가능하겠죠, 실수가 절대 없을까요?
    • 이게 실수를 못찾고 운영 서버에 배포했다고 해봅시다.
    • 운영 서버에서 실수로 인해 서버가 터지면 바로 손실입니다.
    • 그래서 테스트 코드가 필요합니다. 속성을 바꿔보고 테스트 코드를 러닝을 해보면서 영향 범위를 찾을 수 있으니까요!!

Untitled

  • 테스트 코드는 “작성하는 순간의 당장의 편리함” 을 위한 선택이 아닙니다.
  • 나와 너의 그리고 우리 팀의 미래를 위한 보험에 좀 더 가깝죠.
  • 하지만 많은 통계적인 결과에서 “잘 짜여진 테스트를 포함한 프로젝트” 는 항상 “좋은 퀄리티” 를 유지한다는 결론은 변하지 않습니다.
  • 여러분들 유명한 오픈 소스 github repo 를 가봅시다. - 가령 express ps) 여러분들 현업 코드가 궁금하다면, github 의 open-source 들의 코드를 많이 살펴보세요!

Untitled

  • 하지만 테스트 코드의 함정은 있습니다.
  • 잘못된 테스트 코드는 없으니만 못하다가 될 수 있으니까요
  • 일단 더 깊은 내용을 고찰하기 전에 테스트에 대해서 좀 더 생각해 봅시다.

3) Layered Achitecture Pattern 고찰하기 - 계층 나누기 전

  • 근데 왜 레이어를 나눌까? 귀찮은데 그냥 비즈니스 로직 다 한 곳에서 구현하면 안되는가?
  • 아래는 단순하게 class 형태로 표현한 user 관련 API 입니다.
    • 전체 코드를 5분정도 같이 살펴봅시다.
import express from "express";
import bodyParser from "body-parser";
const app = express();
const PORT = 3000;

app.use(bodyParser.json());
app.use(express.json());

export class UserManagement {
    constructor() {
        this.users = []; // 사용자 데이터를 저장하는 배열
    }

    // 사용자 생성
    createUser = async (req, res) => {
        const { id, name } = req.body;
        if (!id || !name) {
            res.status(422).json({ error: "ID와 이름은 필수입니다." });
            return;
        }
        const user = { id, name };
        this.users.push(user);
        res.status(201).json(user);
    }

    // 모든 사용자 조회
    findAllUsers = async (req, res) => {
        res.status(200).json(this.users);
    }

    // ID로 사용자 조회
    findUserById = async (req, res) => {
        const userId = req.params.id;
        const user = this.users.find(user => user.id === userId);
        if (user) {
            res.status(200).json(user);
        } else {
            res.status(404).json({ error: "User not found" });
        }
    }

    // 사용자 업데이트
    updateUser = async (req, res) => {
        const userId = req.params.id;
        const { id, name } = req.body;
        const userIndex = this.users.findIndex(user => user.id === userId);
        if (userIndex !== -1) {
            this.users[userIndex] = { id, name };
            res.status(200).json(this.users[userIndex]);
        } else {
            res.status(404).json({ error: "User not found" });
        }
    }

    // 사용자 삭제
    deleteUser = async (req, res) => {
        const userId = req.params.id;
        const userIndex = this.users.findIndex(user => user.id === userId);
        if (userIndex !== -1) {
            this.users.splice(userIndex, 1);
            res.status(204).json({});
        } else {
            res.status(404).json({ error: "User not found" });
        }
    }
}

const userManagement = new UserManagement();

app.get("/users", async (req, res) => await userManagement.findAllUsers(req, res));
app.post("/user", async (req, res) => await userManagement.createUser(req, res));
app.get("/user/:id", async (req, res) => await userManagement.findUserById(req, res));
app.put("/user/:id", async (req, res) => await userManagement.updateUser(req, res));
app.delete("/user/:id", async (req, res) => await userManagement.deleteUser(req, res));

app.listen(PORT, () => {
    console.log(`Server is running on port ${PORT}`);
});
  • package.json - 심화 1주차 내용과 완전 동일합니다.
    {
      "name": "before",
      "version": "1.0.0",
      "main": "app.js",
      "license": "MIT",
      "type": "module",
      "scripts": {
        "start": "nodemon app.js",
        "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"
      },
      "dependencies": {
        "express": "^4.18.2",
        "nodemon": "^3.0.3"
      },
      "devDependencies": {
        "@jest/globals": "^29.7.0",
        "cross-env": "^7.0.3",
        "jest": "^29.7.0"
      }
    }
  • 일단 분리를 안한, 통으로 되어 있는 코드는 “단기적” 으로 “주로 혼자” 작업할 때 유리할 수 있습니다.
  • 테스트 코드 작성해볼까요?
    • 테스트 코드에 절대적인 정답은 없습니다.
    • 논리적으로, “우리가 작성한 코드의 대부분의 경우의 수에 대해 테스트를 할 수 있는 코드” 여야 합니다.
    • 그러니, 형태가 다를 수 있는데, 이는 염려하지 않아도 됩니다. 왜냐면? 어짜피 사내 rule 따라 test code 형태는 달라집니다. 우리가 집중할 것은
      1. 데이터를 모킹하는 논리적인 방법
      2. 각 단계 테스트 코드를 작성하는 논리적인 흐름
      3. 검증하는 흐름
    • 위 3가지에 집중하는게 핵심입니다.

1차 테스트 코드 고찰하기

import { jest } from "@jest/globals";
import { UserManagement } from "../../app.js";

describe("UserManagement", () => {

    // 사전에 필요한 mock 데이터 세팅
    let mockReq;
    let mockRes;
    let userManagement;

    beforeEach(() => {
        jest.resetAllMocks();
        // 각 테스트 실행 전에 mock 객체를 초기화합니다.
        mockReq = { params: {}, body: {} };
        mockRes = {
            status: jest.fn().mockReturnThis(), // status 메서드가 this를 반환하도록 수정
            json: jest.fn().mockReturnThis(), // json 메서드도 체이닝을 위해 this를 반환하도록 수정
            cookie: jest.fn().mockReturnThis(),
        };
        userManagement = new UserManagement();
    });

    it("should create a user", async () => {
        mockReq.body = { id: "1", name: "John Doe" };
        await userManagement.createUser(mockReq, mockRes);
        expect(mockRes.status).toHaveBeenCalledWith(201);
        expect(mockRes.json).toHaveBeenCalledWith(mockReq.body);
        expect(userManagement.users).toHaveLength(1);
        expect(userManagement.users[0]).toEqual(mockReq.body);
    });

    it("should return all users", async () => {
        const mockUsers = [{ id: "1", name: "John Doe" }, { id: "2", name: "Jane Doe" }];
        userManagement.users = mockUsers;
        await userManagement.findAllUsers(mockReq, mockRes);
        expect(mockRes.status).toHaveBeenCalledWith(200);
        expect(mockRes.json).toHaveBeenCalledWith(mockUsers);
    });

    it("should find a user by ID", async () => {
        const user = { id: "1", name: "John Doe" };
        userManagement.users.push(user);
        mockReq.params.id = "1";

        await userManagement.findUserById(mockReq, mockRes);
        expect(mockRes.status).toHaveBeenCalledWith(200);
        expect(mockRes.json).toHaveBeenCalledWith(user);
        expect(userManagement.users[0].name).toBe("John Doe");
    });

    it("should return 404 if user not found when finding a user", async () => {
        mockReq.params.id = "2";
        await userManagement.findUserById(mockReq, mockRes);
        expect(mockRes.status).toHaveBeenCalledWith(404);
        expect(mockRes.json).toHaveBeenCalledWith({ error: "User not found" });
    });

    it("should update a user", async () => {
        const user = { id: "1", name: "John Doe" };
        userManagement.users.push(user);
        mockReq.params.id = "1";
        mockReq.body = { id: "1", name: "John Updated" };

        await userManagement.updateUser(mockReq, mockRes);
        expect(mockRes.status).toHaveBeenCalledWith(200);
        expect(mockRes.json).toHaveBeenCalledWith(mockReq.body);
        expect(userManagement.users[0].name).toBe("John Updated");
    });

    it("should delete a user", async () => {
        const user = { id: "1", name: "John Doe" };
        userManagement.users.push(user);
        mockReq.params.id = "1";
        await userManagement.deleteUser(mockReq, mockRes);
        expect(mockRes.status).toHaveBeenCalledWith(204);
        expect(userManagement.users).toHaveLength(0);
    });
});

1차 테스트 코드의 결과와 테스트 커버러지 체크

Untitled

2차 테스트 코드 작성하기, 업데이트 하기

  • 여러분들이 가장 많이 놓치는 부분
  • 테스트는 성공의 경우만 하는게 아닙니다. 절대!
  • 테스트의 완성도는 실패의 경우의 수를 확신할때 올라갑니다!
...생략...

it("should return 404 if user not found when updating a user", async () => {
    mockReq.params.id = "2";
    await userManagement.updateUser(mockReq, mockRes);
    expect(mockRes.status).toHaveBeenCalledWith(404);
    expect(mockRes.json).toHaveBeenCalledWith({ error: "User not found" });
});

it("should return 404 if user not found when deleting a user", async () => {
    mockReq.params.id = "2";
    await userManagement.deleteUser(mockReq, mockRes);
    expect(mockRes.status).toHaveBeenCalledWith(404);
    expect(mockRes.json).toHaveBeenCalledWith({ error: "User not found" });
});

2차 테스트 코드의 결과와 테스트 커버러지 체크

Untitled

FileStatementsBranchesFunctionsLines
테스트 커버리지가 측정된 각 파일의 경로와 이름프로그램에서 실행 가능한 각각의 명령문을 의미전체 실행 가능한 문장 중 테스트를 통해 실행된 문장의 비율코드 내의 조건문(예: if, switch)에서 여러 경로(분기) 중 얼마나 많은 경로가 테스트를 통해 실행되었는지 분기 커버리지는 코드 내의 모든 가능한 경로가 테스트를 통해 실행되었는지를 보여주는 지표테스트 대상인 각 함수 또는 메서드의 커버리지를 나타냄.함수 커버리지는 전체 정의된 함수 중 테스트를 통해 실행된 함수의 비율

ps) 아래는 “이런게 있다~” 정도로만 이해하시면 됩니다.

테스트-후 개발(Test-After Development)

  • 먼저 비즈니스 로직이나 기능 구현을 완료한 후, 그 기능을 검증하기 위한 테스트 코드를 작성
  • 이 방식은 전통적인 개발 프로세스에서 자주 볼 수 있으며, 개발자가 기능을 완전히 이해하고 구현한 후에 테스트를 통해 버그를 찾고, 문제를 수정하는 데 초점

테스트-주도 개발(Test-Driven Development, TDD)

  • 실제 코드를 작성하기 전에 테스트 코드를 먼저 작성하고, 이 테스트 코드가 성공하도록 실제 코드를 구현하는 절차
    1. 실패하는 테스트를 먼저 작성
    2. 테스트를 통과할 수 있도록 최소한의 코드를 작성
    3. 코드를 정리하고 개선, 이때 테스트는 계속 통과해야 한다.

4) Layered Achitecture Pattern 고찰하기 - 계층 나누기 전의 문제점은 무엇인가?

  1. 가장 먼저 느껴지는게 코드의 재사용성 및 확장성 문제

    • 이제 게시글(Post) 이라는 모델을 추가한다고 해보자
      • 해당 게시글 모델은 “author” 와 “content” 가 있다고 생각해보자
    • Post 를 create 할 때 author 를 찾아야 한다.
      • 그러면, userManagement class instance 를 만들어야 한다.
      • 게다가 해당 class method 는 req, res 를 받고 있어서 단순하게 user 만 가져오지도 못한다.
      • 그러면 어떻게 하게 되는가? → 그냥 그런 일을 하는 method를 새로 만드는 일을 계속 한다.
    • 이제 댓글(Comment) 이라는 모델을 추가한다고 해보자
      • Post 와 User 를 찾아야한다.
      • 위의 악순환의 반복
    • 더 나아가 위의 “model9.js” 사태를 생각해보자..!! 🥹
  2. 그러면 테스트 코드는 괜찮은가? → “테스트의 명확성 부족”

        it("should create a user", async () => {
            mockReq.body = { id: "1", name: "John Doe" };
            await userManagement.createUser(mockReq, mockRes);
            expect(mockRes.status).toHaveBeenCalledWith(201);
            expect(mockRes.json).toHaveBeenCalledWith(mockReq.body);
            expect(userManagement.users).toHaveLength(1);
            expect(userManagement.users[0]).toEqual(mockReq.body);
        });
    • 이게 모델 생성 성공/실패 (즉 create 자체의 성공/실패) 를 테스트 하는지 -
    • 이게 HTTP 요청 자체의 성공/실패 를 테스트 하는지 -
    • 비즈니스 로직 처리 자체의 성공/실패 를 테스트 하는지 -
    • 위 내용이 모두 혼재되어 있음!
    • 더욱이 (1) 을 해결하기 위해 새로운 메서드 생성했다고 치자.
      • 어라? 테스트 커버러지 떨어졌네?
      • 무의미한 테스트 코드 추가
      • 무한 반복
      • 🥹🥹🥹
  3. 그 외, “비즈니스 로직”과 “프레임워크의 결합”

    1. 비즈니스 로직이 프레임워크와 땔래야 땔수가 없는 테스트 코드가 짜잔 하고 만들어졌다.
    2. 이 얘기는 나중에, 계층 분리를 하고 다시 생각해 봅시다!

2. 잠시 복습


[ 아래는 여러분들이 이미 학습한 “1주차 “ 부분을 그대로 발췌한 내용입니다. ]

1) Presentation Layer - 컨트롤러 (Controller)

  • 프레젠테이션 계층(Presentation Layer)은 3계층 아키텍처 패턴에서 가장 먼저 클라이언트의 요청(Request)을 만나게 되는 계층이며, 대표적으로 컨트롤러(Controller)가 이 역할을 담당합니다.
  • 클라이언트의 요청(Request)을 수신합니다.
  • 요청(Request)에 들어온 데이터 및 내용을 검증합니다.
  • 서버에서 수행된 결과를 클라이언트에게 반환(Response)합니다.

2) Service Layer - 서비스 (Service)

  • 서비스 계층(Service Layer), 다른 이름으로는 비즈니스 로직 계층(Business logic layer)은 아키텍처의 가장 핵심적인 비즈니스 로직을 수행하고 클라이언트가 원하는 요구사항을 구현하는 계층입니다.
  • 프레젠테이션 계층(Presentation Layer)과 데이터 엑세스 계층(Data Access Layer) 사이에서 중간 다리 역할을 하며, 서로 다른 두 계층이 직접 통신하지 않게 만들어 줍니다.
  • 서비스(Service)는 데이터가 필요할 때 저장소(Repository)에게 데이터를 요청합니다.
  • 어플리케이션의 규모가 커질수록, 서비스 계층의 역할과 코드의 복잡성도 점점 더 커지게 됩니다.
  • 어플리케이션의 핵심적인 비즈니스 로직을 수행하여 클라이언트들의 요구사항을 반영하여 원하는 결과를 반환해주는 계층입니다.

3) Repository Layer - 저장소 (Repository)

  • 저장소 계층(Repository Layer)은 데이터 엑세스 계층(Data Access Layer)이라고도 불리는데요, 주로 데이터베이스와 관련된 작업을 처리하는 계층입니다.
  • 저장소 계층을 도입하면, 데이터 저장 방법을 더욱 쉽게 변경할 수 있고, 테스트 코드 작성시 가짜 저장소(Mock Repository)를 제공하기가 더 쉬워집니다.
  • 어플리케이션의 다른 계층들은 저장소의 세부 구현 방식에 대해 알지 못하더라도 해당 기능을 사용할 수 있습니다.즉, 저장소 계층의 변경 사항이 다른 계층에 영향을 주지 않는 것입니다.

4) 그... 왜 더 복잡하게 분리 하나요..? 잘하는 척 하는건가..?

  • 다시 계층 나누기 전의 문제점 고민해보기
  • 관심사 분리 → 유지 보수 용이 & 재사용 용이 → 확장성 및 개발 효율성 증대
  • 계층을 나눳다고 가정하고, [ 계층 나누기 전의 문제점 ] 을 다시 고찰해보자!

3. 실습


1) Repository, Service, Controller 나누기

  • 잠깐 하나 하나 살펴보면서 가봅시다!

Repository

export class UserRepository {
    constructor() {
        this.users = []; // 사용자 데이터를 저장하는 배열
    }

    // 사용자 생성
    createUser = async (user) => {
        this.users.push(user);
        return user;
    }

    // 모든 사용자 조회
    findAllUsers = async () => {
        return this.users;
    }

    // ID로 사용자 조회
    findUserById = async (userId) => {
        return this.users.find(user => user.id === userId);
    }

    // 사용자 업데이트
    updateUser = async (userId, id, name) => {
        const userIndex = this.users.findIndex(user => user.id === userId);
        if (userIndex !== -1) {
            this.users[userIndex] = { id, name };
            return this.users[userIndex];
        }
        return null;
    }

    // 사용자 삭제
    deleteUser = async (userId) => {
        const userIndex = this.users.findIndex(user => user.id === userId);
        if (userIndex !== -1) {
            this.users.splice(userIndex, 1);
            return true;
        }
        return false;
    }
}

Service

export class UserService {
    constructor(userRepository) {
        this.userRepository = userRepository;
    }

    // 새 사용자 생성
    createUser = async (id, name) => {
        const user = { id, name };
        return await this.userRepository.createUser(user);
    }

    // 모든 사용자 조회
    getAllUsers = async () => {
        return await this.userRepository.findAllUsers();
    }

    // ID로 사용자 조회
    getUserById = async (userId) => {
        return await this.userRepository.findUserById(userId);
    }

    // 사용자 정보 업데이트
    updateUser = async (userId, id, name) => {
        const targetUser = await this.userRepository.findUserById(userId);
        if (targetUser) {
            return await this.userRepository.updateUser(userId, id, name);
        }
        return null;
    }

    // 사용자 삭제
    removeUser = async (userId) => {
        const targetUser = await this.userRepository.findUserById(userId);
        if (targetUser) {
            return await this.userRepository.deleteUser(userId);
        }
        return false;
    }
}

Controller

export class UserController {
    constructor(userService) {
        this.userService = userService;
    }

    // 사용자 생성
    createUser = async (req, res) => {
        try {
            const { id, name } = req.body;
            if (!id || !name) {
                throw new Error("ID와 이름은 필수입니다.");
            }
            const user = await this.userService.createUser(id, name);
            res.status(201).json(user);
        } catch (error) {
            res.status(400).json({ error: error.message });
        }
    }

    // 모든 사용자 조회
    getAllUsers = async (req, res) => {
        const users = await this.userService.getAllUsers();
        res.json(users);
    }

    // ID로 사용자 조회
    getUserById = async (req, res) => {
        try {
            const userId = req.params.id;
            const user = await this.userService.getUserById(userId);
            if (user) {
                res.json(user);
            }
            else {
                throw new Error("User not found");
            }
        } catch (error) {
            res.status(400).json({ error: error.message });
        }
    }

    // 사용자 정보 업데이트
    updateUser = async (req, res) => {
        try {
            const userId = req.params.id;
            const { id, name } = req.body;
            if (!id || !name) {
                throw new Error("업데이트 할 내용이 없습니다.");
            }

            const updatedUser = await this.userService.updateUser(userId, id, name);
            if (updatedUser) {
                res.json(updatedUser);
            } else {
                throw new Error("User not found");
            }
        } catch (error) {
            res.status(400).json({ error: error.message });
        }
    }

    // 사용자 삭제
    deleteUser = async (req, res) => {
        try {
            const userId = req.params.id;
            const isDeleted = await this.userService.removeUser(userId);
            if (isDeleted) {
                res.status(204).json({});
            } else {
                throw new Error("User not found");
            }
        } catch (error) {
            res.status(400).json({ error: error.message });
        }
    }
}

app.js

import express from "express";
import bodyParser from "body-parser";
import { UserRepository } from "./user.repository.js";
import { UserService } from "./user.service.js";
import { UserController } from "./user.controller.js";

const app = express();
const PORT = 3000;

app.use(bodyParser.json());
app.use(express.json());

const userRepository = new UserRepository();
const userService = new UserService(userRepository);
const userController = new UserController(userService);

// 사용자 관련 라우트 설정
app.get("/users", async (req, res) => await userController.getAllUsers(req, res));
app.post("/user", async (req, res) => await userController.createUser(req, res));
app.get("/user/:id", async (req, res) => await userController.getUserById(req, res));
app.put("/user/:id", async (req, res) => await userController.updateUser(req, res));
app.delete("/user/:id", async (req, res) => await userController.deleteUser(req, res));

app.listen(PORT, () => {
    console.log(`Server is running on port ${PORT}`);
});
  • 자 여기서 재미있는 점이 있습니다.

  • 지금까지 DBMS 를 사용하고 있지 않죠.

    • 만약 DBMS 를 사용하기 위해 ORM 을 도입한다면, 어디만 바꾸면 되나요?
    • 실제 코드를 바꿨다고 합시다. 그러면 어디 테스트 코드만 손대면 되나요?
    • “도입”하고, “검증”하는 과정에서 이런 관심사 분리가 어떤 이점이 있는지 확 와닿습니다.
  • 더욱이 프레임 워크를 바꿀 수 있습니다.

    • 앞으로 배울 nest.js 말고, koa 라는 유명한 framework 도 있습니다.

      https://github.com/koajs/koa

    • 어디만 바꾸면 될까요?

    • app.js 만 바꾸면 프레임워크도 바꿀 수 있습니다.

  • [ 계층 나누기 전의 문제점 ] 을 다시 고찰해봅시다.

    • post - author…
    • comment - post & user …
    • model9.js
  • 그래도 여전히 API 가 수백개라면 “검증” 하는 데 있어서, 힘든 것은 비슷합니다!

  • 그래서 테스트 코드가 필요합니다.

  • 이와 같이 테스트 코드의 필요성과 3Layer 패턴의 필요성의 관점은 약간 다릅니다.

    • 하지만 3Layer 패턴 으로 인해 test code 의 완성도가 훨씬 올라갑니다.
  • 이 형태, 이 흐름은 javascript 라는 언어에 국한되는게 아닙니다.

  • C, C++, Java, Python … etc 언어는 “도구” 입니다.

2) 자 이제 계층별 test 코드 실습!

  • [ 계층 나누기 전의 문제점 ] 에서 “테스트 코드” 이슈를 다시 고찰해봅시다.
  • 만약 위 코드에서 우리가 DBMS 연결을 위해 ORM 을 사용한다고 해봅시다.
    • 그래서 어디만 바뀌면 되죠?
    • 그러면 검증할때 어떤 부분만 검증하면 되죠?
    • 그래서 3Layer 의 방점은 관심사 분리를 바탕으로 한 테스트 코드로 완성이 됩니다.

Repository

import { UserRepository } from "../../user.repository.js";

describe("UserRepository", () => {
    let userRepository;

    beforeEach(() => {
        userRepository = new UserRepository();
    });

    it("should create and find a user", async () => {
        const user = { id: "1", name: "Test User" };
        await userRepository.createUser(user);

        const foundUser = await userRepository.findUserById(user.id);
        expect(foundUser).toEqual(user);
    });

    it("should return all users", async () => {
        const user1 = { id: "1", name: "Test User 1" };
        const user2 = { id: "2", name: "Test User 2" };
        await userRepository.createUser(user1);
        await userRepository.createUser(user2);

        const users = await userRepository.findAllUsers();
        expect(users).toEqual(expect.arrayContaining([user1, user2]));
        expect(users.length).toBe(2);
    });

    it("should update a user", async () => {
        const user = { id: "1", name: "Original Name" };
        await userRepository.createUser(user);

        const updatedName = "Updated Name";
        const updatedUser = await userRepository.updateUser(user.id, user.id, updatedName);
        expect(updatedUser).toEqual({ id: user.id, name: updatedName });

        const foundUser = await userRepository.findUserById(user.id);
        expect(foundUser.name).toBe(updatedName);
    });

    it("should delete a user", async () => {
        const user = { id: "1", name: "Test User" };
        await userRepository.createUser(user);

        const isDeleted = await userRepository.deleteUser(user.id);
        expect(isDeleted).toBe(true);
        expect(await userRepository.findUserById(user.id)).toBeUndefined();
    });

    it("should return null when updating a non-existent user", async () => {
        const updateResult = await userRepository.updateUser("non-existent", "non-existent", "Non Existent");
        expect(updateResult).toBeNull();
    });

    it("should return false when deleting a non-existent user", async () => {
        const deleteResult = await userRepository.deleteUser("non-existent");
        expect(deleteResult).toBe(false);
    });
});

Service

import { jest } from "@jest/globals";
import { UserService } from "../../user.service.js";

describe("UserService", () => {
    let mockUserRepository;
    let userService;

    beforeEach(() => {
        jest.resetAllMocks();
        mockUserRepository = {
            createUser: jest.fn(),
            findAllUsers: jest.fn(),
            findUserById: jest.fn(),
            updateUser: jest.fn(),
            deleteUser: jest.fn(),
        };
        userService = new UserService(mockUserRepository);
    });

    it("should create a user", async () => {
        const user = { id: "1", name: "Test User" };
        mockUserRepository.createUser.mockResolvedValue(user);
        const result = await userService.createUser(user.id, user.name);

        expect(mockUserRepository.createUser).toHaveBeenCalledWith(user);
        expect(result).toEqual(user);
    });

    it("should get all users", async () => {
        const users = [
            { id: "1", name: "Test User 1" },
            { id: "2", name: "Test User 2" }
        ];
        mockUserRepository.findAllUsers.mockResolvedValue(users);
        const result = await userService.getAllUsers();

        expect(mockUserRepository.findAllUsers).toHaveBeenCalled();
        expect(result).toEqual(users);
    });

    it("should get a user by ID", async () => {
        const user = { id: "1", name: "Test User" };
        mockUserRepository.findUserById.mockResolvedValue(user);
        const result = await userService.getUserById(user.id);

        expect(mockUserRepository.findUserById).toHaveBeenCalledWith(user.id);
        expect(result).toEqual(user);
    });

    it("should return null if user not found on get by ID", async () => {
        mockUserRepository.findUserById.mockResolvedValue(null);
        const result = await userService.getUserById("non-existent");

        expect(result).toBeNull();
    });

    it("should update a user", async () => {
        const user = { id: "1", name: "Updated User" };
        mockUserRepository.findUserById.mockResolvedValue(user);
        mockUserRepository.updateUser.mockResolvedValue(user);
        const result = await userService.updateUser(user.id, user.id, user.name);

        expect(mockUserRepository.updateUser).toHaveBeenCalledWith(user.id, user.id, user.name);
        expect(result).toEqual(user);
    });

    it("should return null when trying to update a non-existent user", async () => {
        mockUserRepository.findUserById.mockResolvedValue(null);
        const result = await userService.updateUser("non-existent", "non-existent", "Non Existent");

        expect(result).toBeNull();
    });

    it("should delete a user", async () => {
        mockUserRepository.findUserById.mockResolvedValue(true); // Assuming the user exists
        mockUserRepository.deleteUser.mockResolvedValue(true);
        const result = await userService.removeUser("1");

        expect(mockUserRepository.deleteUser).toHaveBeenCalledWith("1");
        expect(result).toBe(true);
    });

    it("should return false when trying to delete a non-existent user", async () => {
        mockUserRepository.findUserById.mockResolvedValue(null);
        const result = await userService.removeUser("non-existent");

        expect(result).toBe(false);
    });

});

Controller

import { jest } from "@jest/globals";
import { UserController } from "../../user.controller";

describe("UserController", () => {
    let mockUserService;
    let userController;
    let mockReq, mockRes;

    beforeEach(() => {
        jest.resetAllMocks();
        mockUserService = {
            createUser: jest.fn(),
            getAllUsers: jest.fn(),
            getUserById: jest.fn(),
            updateUser: jest.fn(),
            removeUser: jest.fn(),
        };
        mockReq = { params: {}, body: {} };
        mockRes = {
            status: jest.fn().mockReturnThis(),
            json: jest.fn().mockReturnThis(),
            cookie: jest.fn().mockReturnThis(),
        };
        userController = new UserController(mockUserService);
    });

    it("should create a user and return 201 status", async () => {
        const user = { id: "1", name: "Test User" };
        mockReq.body = user;
        mockUserService.createUser.mockResolvedValue(user);

        await userController.createUser(mockReq, mockRes);

        expect(mockRes.status).toHaveBeenCalledWith(201);
        expect(mockRes.json).toHaveBeenCalledWith(user);
        expect(mockUserService.createUser).toHaveBeenCalledWith(user.id, user.name);
    });

    it("should return all users", async () => {
        const users = [{ id: "1", name: "Test User 1" }, { id: "2", name: "Test User 2" }];
        mockUserService.getAllUsers.mockResolvedValue(users);

        await userController.getAllUsers(mockReq, mockRes);

        expect(mockRes.json).toHaveBeenCalledWith(users);
        expect(mockUserService.getAllUsers).toHaveBeenCalled();
    });

    it("should return a user by ID", async () => {
        const user = { id: "1", name: "Test User" };
        mockReq.params.id = user.id;
        mockUserService.getUserById.mockResolvedValue(user);

        await userController.getUserById(mockReq, mockRes);

        expect(mockRes.json).toHaveBeenCalledWith(user);
        expect(mockUserService.getUserById).toHaveBeenCalledWith(user.id);
    });

    it("should return 400 if user not found by ID", async () => {
        mockReq.params.id = "non-existent";
        mockUserService.getUserById.mockResolvedValue(null);

        await userController.getUserById(mockReq, mockRes);

        expect(mockRes.status).toHaveBeenCalledWith(400);
        expect(mockRes.json).toHaveBeenCalledWith({ error: "User not found" });
    });

    it("should update a user", async () => {
        const updatedUser = { id: "1", name: "Updated Name" };
        mockReq.params.id = updatedUser.id;
        mockReq.body = { id: updatedUser.id, name: updatedUser.name };
        mockUserService.updateUser.mockResolvedValue(updatedUser);

        await userController.updateUser(mockReq, mockRes);

        expect(mockRes.json).toHaveBeenCalledWith(updatedUser);
        expect(mockUserService.updateUser).toHaveBeenCalledWith(updatedUser.id, updatedUser.id, updatedUser.name);
    });

    it("should return 400 if update user not found", async () => {
        mockReq.params.id = "non-existent";
        mockReq.body = { id: "non-existent", name: "Some Name" };
        mockUserService.updateUser.mockResolvedValue(null);

        await userController.updateUser(mockReq, mockRes);

        expect(mockRes.status).toHaveBeenCalledWith(400);
        expect(mockRes.json).toHaveBeenCalledWith({ error: "User not found" });
    });

    it("should delete a user", async () => {
        mockReq.params.id = "1";
        mockUserService.removeUser.mockResolvedValue(true);

        await userController.deleteUser(mockReq, mockRes);

        expect(mockRes.status).toHaveBeenCalledWith(204);
        expect(mockUserService.removeUser).toHaveBeenCalledWith(mockReq.params.id);
    });

    // ============================================================ //
    // 2차 업데이트 이후
    // ============================================================ //

    it("should return 400 if delete user not found", async () => {
        mockReq.params.id = "non-existent";
        mockUserService.removeUser.mockResolvedValue(false);

        await userController.deleteUser(mockReq, mockRes);

        expect(mockRes.status).toHaveBeenCalledWith(400);
        expect(mockRes.json).toHaveBeenCalledWith({ error: "User not found" });
    });

    it("should return 400 if createUser is called with invalid data", async () => {
        // ID 또는 이름이 누락된 경우
        mockReq.body = {};
        await userController.createUser(mockReq, mockRes);

        expect(mockRes.status).toHaveBeenCalledWith(400);
        expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
    });

    it("should return 400 if updateUser is called with invalid data", async () => {
        // 업데이트할 사용자 정보가 유효하지 않은 경우
        mockReq.params.id = "1";
        mockReq.body = {};
        await userController.updateUser(mockReq, mockRes);

        expect(mockRes.status).toHaveBeenCalledWith(400);
        expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ error: "업데이트 할 내용이 없습니다." }));
    });

    it("should handle exceptions thrown by userService methods", async () => {
        // UserService에서 예외가 발생한 경우
        const errorMessage = "An error occurred";
        mockUserService.createUser.mockRejectedValue(new Error(errorMessage));
        mockReq.body = { id: "1", name: "Test User" };

        await userController.createUser(mockReq, mockRes);

        expect(mockRes.status).toHaveBeenCalledWith(400);
        expect(mockRes.json).toHaveBeenCalledWith({ error: errorMessage });
    });

});

대충 다 해결해서 행복하다는 표정으로 바뀐 신입입니다 ㅎ

대충 다 해결해서 행복하다는 표정으로 바뀐 신입입니다 ㅎ

4. QnA


Q-1) 테스트 코드를 얼마나 디테일하게 작성해야 하나요 ㅠㅠ? 모든 경우의 수 인가요?🥹

A. 사실 “가능한 모든 경우의 수” 가 올바른 접근이지만, 정답은 없습니다. 회사라면 사내 rule를 따르면 됩니다!

  • 대게 test code 가 실제 code 를 얼마나 검증해내느냐 를 판단 합니다.
  • 그래서 code coverage 를 사용하구요!
  • 배포하는 순간 부터 해당 code coverage평균 80% 를 넘지 못하면 안된다! 와 같은 기준도 생각해 볼 수 있습니다.

Q-2) 과제 피드백 영상 56분 대부터 jest.fn( () => response) 가 나오는데, 이게 체이닝..?!

A. 이건 사실 문법적인 부분입니다. “Method Chaining” 을 아셔야 이해가 가능한데, 이미 여러분들이 무의식적으로 아주 많이 사용했습니다 ㅎ

res.status(201).json(user);
  • 여기서 status 도 함수죠? 정확하게는 res 라는 object의 함수, method 입니다.
    • 함수는 뭐죠? [ input → 함수 … 처리 →output ] 즉 return 이 있습니다.
    • res.status(201) 도 뭔가를 리턴한다는 얘기죠?
    • 얘의 리턴 값은 this 입니다. 즉, res.status(201) 의 호출 결과 값은 res object 라는 거에요
    • 그러니까 res object 가 제공하는 함수를 다시 호출할 수 있죠! 아래와 같이 분리할 수 있어요!
const resAgain = res.status(201);
resAgain.json(user);
// resAgain === res
  • 다시 볼까요?!
const response = {
	status: jest.fn(() => response),
	json: jest.fn(),
};
  • statusreturn 하는게 뭐다? 자기 자신이다~ → responsestatusresponsereturn한다!
  • 여담으로 디자인 패턴 중 생성 패턴과 관련된, “빌더 패턴” 이 Method Chaining 을 기반으로 합니다.

Q-3). 파일 이름에 왜 “spec” 접미사가 붙나요?

A. specification 의 약자고, 특정 “테스트 스팩” 을 담고 있다는 것을 의미합니다.

  • 특히 단위 테스트(Unit Test)를 위한 파일에서 해당 파일이 소프트웨어의 "명세"를 테스트 형태로 기술하고 있음을 나타냅니다!
  • 명세 주도 개발(Specification-Driven Development)이나 행동 주도 개발(Behavior-Driven Development, BDD)과 같은 개발 접근 방식과 도 관련이 있다고 합니다.
  • 간단하게 위 개발 접근 방식은 “실제 코드를 작성하기 전에 소프트웨어가 충족해야 하는 사양이나 행동을 먼저 정의” 하는 것을 의미하는데 *.spec.js 파일은 이러한 사양이나 행동을 테스트 코드의 형태로 명세화하는 역할을 한다고 하네요!
  • *.spec.py
  • *.spec.java

기타 테스트 도와주는 라이브러리

  1. Faker - data mocking 에 도움

    Getting Started | Faker

  2. supertest - supertest는 HTTP assertions을 수행하기 위해 사용 / 실제 express 에서 테스트를 위해 사용한 라이브러리

    GitHub - ladjs/supertest: 🕷 Super-agent driven library for testing node.js HTTP servers using a fluent API. Maintained for @forwardemail, @ladjs, @spamscanner, @breejs, @cabinjs, and @lassjs.

  3. factory girl

    1. 객체 mocking 도와주는 라이브러리
    2. 좀 에전 라이브러리 인 점 주의!

    GitHub - simonexmachina/factory-girl: A factory library for node.js and the browser inspired by factory_girl


profile
황세민

0개의 댓글

관련 채용 정보