본문 바로가기
공부/express

[Express] jest로 단위 테스트 하는 법

by 웅대 2024. 6. 11.
728x90
반응형

이번 포스팅에서는 jest를 사용해서 단위 테스트를 진행해보려고 한다.

단위 테스트는 전체적인 통합 기능을 테스트 하는 것이 아닌 작은 단위의 모듈을 테스트하는 것이다.

어떠한 모듈을 테스트하고 싶을 때 그 모듈이 다른 모듈과 연관 관계를 가지고 있으면 단위 테스트가 어려울 수 있다.

이 때 사용하는 것이 바로 Mock 객체이다.

Mock 객체는 가짜 객체로 우리가 미리 어떤 입력이 들어오면 어떤 값을 내보낸다고 정해두는 것이다.

내부 실제 비즈니스 로직이 동작하는 것이 아니라 그냥 단순히 입력과 출력이 있는 것이다.

이 Mock 객체를 사용하면 작은 단위의 모듈 하나를 테스트하기 용이하다.

이번 포스팅에서는 아래 회원가입, 로그인 api에 단위 테스트를 적용해보자.

 

<routes/user.js>

const express = require('express')
const User = require('../schemas/user')
const bcrypt = require('bcrypt')
const passport = require('passport')
const router = express.Router()
const {isAuthenticated, isNotAuthenticated} = require('../middlewares/apiAuthMiddleware')

router.post('/join', isNotAuthenticated, async (req, res)=>{
    console.log(req.body)
    const {name, password} = req.body
    const newUser = new User({
        name,
        password: await bcrypt.hash(password, 10)
    })
    try{
        await newUser.save()
    } catch(error){
        if(error.name == "MongoServerError" && error.code ==11000){
            return res.status(409).json({
                code: 409,
                message: '유저 이름 중복'
            })
        }
    }
    res.json({
        code: 200,
        message: '회원가입 성공'
    })
})
router.post('/login', isNotAuthenticated, async(req, res, next)=>{
    passport.authenticate('local', (error, user, info)=>{  
        if (error){
            console.error(error)
            return next(error)
        }
        if (!user){
            return res.status(info.code).json(info)
        }
        return req.login(user, (error)=>{
            if(error){
                return next(error)
            }
            return res.status(info.code).json(info)
        })

    })(req, res, next)
})
router.get('/logout', isAuthenticated, (req, res)=>{
    req.session.destroy()
    res.json({
        code: 200,
        message: '로그아웃에 성공했습니다.'
    })
})

module.exports = router

 

위 api 중 회원 가입과 로그인에 대해서 테스트 코드를 작성해보자.

Jest 세팅


express에서 jest를 사용해서 다음 api에 단위 테스트를 적용 해보자.

jest를 설치해보자.

npm i jest


참고로 jest는 기본적으로 commonJS을 사용하기 때문에 만약 본인의 프로젝트가 ES Module을 사용 중이라면 추가적인 설정을 진행해야 한다.

아래 블로그에 ES Module에서 jest 사용하는 방법이 자세하게 설명되어 있다.
https://velog.io/@motiveko/jest%EC%97%90%EC%84%9C-ESM-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0

 

jest에서 ESM 사용하기

바닐라 자바스크립트로 개발을 하던 중, jest로 테스트 코드를 작성하려는데 import문에서 오류가 발생했다. nodejs 환경에서는 기본적으로 CommonJS 모듈을 사용하기 때문에 ESM 을 사용할 수 없는데,

velog.io

 

나는 이번 포스팅에서 commonJS를 사용하려고 한다.

테스트 실행 

pacakge.json의 scripts에 jest를 실행하는 구문을 작성하자.

 

<package.json>

{
.
.
.
  "scripts": {
    "start": "nodemon index.js",
    "test": "jest"
  }
}

 

그리고 npm test를 터미널에 입력하면 테스트를 실행하고 그 결과를 알려준다.

 

scripts를 등록하지 않아도 된다.

 

jest를 글로벌로 설치했다면 "jest"를, 아니라면 "npx jest"를 입력하자.

 

컨트롤러 분리

우리가 테스트 할 api 코드는 http 요청을 받아서 바로 로직을 실행하고 있다.

 

이럴 경우 단위 테스트가 까다롭기 때문에 이를 분리해보자.

 

(컨트롤러 분리에 대한 설명은 아래 링크 참고)

https://growth-coder.tistory.com/277

 

[Express] API 작성법 (라우터, 컨트롤러 분리)

https://growth-coder.tistory.com/274 [express] express 웹 서버 기초pacakage.json 생성npm init 명령어로 package.json을 만들어준다. package.json에는 프로젝트가 사용 중인 패키지의 정보가 담겨있다.package.json 문서를

growth-coder.tistory.com

 

<controllers/user.js>

const User = require('../schemas/user')
const bcrypt = require('bcrypt')
const passport = require('passport')
const joinUser = async (req, res)=>{
    console.log(req.body)
    const {name, password} = req.body
    const newUser = new User({
        name,
        password: await bcrypt.hash(password, 10)
    })
    try{
        await newUser.save()
    } catch(error){
        if(error.name == "MongoServerError" && error.code ==11000){
            return res.status(409).json({
                code: 409,
                message: '유저 이름 중복'
            })
        }
    }
    res.json({
        code: 200,
        message: '회원가입 성공'
    })
}

const loginUser = async(req, res, next)=>{
    passport.authenticate('local', (error, user, info)=>{  
        if (error){
            console.error(error)
            return next(error)
        }
        if (!user){
            return res.status(info.code).json(info)
        }
        return req.login(user, (error)=>{
            if(error){
                return next(error)
            }
            return res.status(info.code).json(info)
        })

    })(req, res, next)
}

const logoutUser = async (req, res)=>{
    req.session.destroy()
    res.json({
        code: 200,
        message: '로그아웃에 성공했습니다.'
    })
}

module.exports = {
    joinUser,
    loginUser,
    logoutUser
}

 

<routes/user.js>

const express = require("express");
const User = require("../schemas/user");
const bcrypt = require("bcrypt");
const passport = require("passport");
const router = express.Router();
const {
  isAuthenticated,
  isNotAuthenticated,
} = require("../middlewares/apiAuthMiddleware");
const { joinUser, loginUser, logoutUser } = require("../controllers/user");

router.post("/join", isNotAuthenticated, (req, res) => {
  joinUser(req, res);
});
router.post("/login", isNotAuthenticated, (req, res) => {
  loginUser(req, res);
});
router.get("/logout", isAuthenticated, (req, res) => {
  logoutUser(req, res);
});

module.exports = router;

 

컨트롤러와 라우터를 분리했기 때문에 컨트롤러 로직에 대해서 단위 테스트를 수행하기 쉬워졌다.

 

단위 테스트를 위해서 controllers/user.test.js 파일을 만들고 이 안에서 테스트 코드를 작성하자.

 

의존성 주입

controllers/users.js에서 사용하는 의존성을 주입하기 전에 어떤 모듈을 의존하고 있는지 파악하자.

 

<controllers/user.js>

const User = require('../schemas/user')
const bcrypt = require('bcrypt')
const passport = require('passport')
.
.
.

 

1. User 스키마

데이터베이스는 mongoDB를, ORM으로 mongoose를 선택했고 이를 바탕으로 만들어진 User 스키마를 의존하고 있다.

 

2. bcrypt

비밀번호 해시를 위한 bcrypt 모듈을 의존하고 있다.

 

3. passport

인증 과정을 쉽게 하기 위해 passport 모듈을 의존하고 있다. 

 

이 중 User 스키마와 passport 모듈을 Mock 객체로 만들어보자.

 

의존 받기 전에 jest.mock을 사용해서 Mock 객체를 생성하면 된다.

jest.mock("../schemas/user.js");
const User = require("../schemas/user.js");
jest.mock("passport");
const passport = require("passport");

 

 

그리고 컨트롤러의 user.js를 테스트 해야 하므로 해당 함수들을 불러오자.

const {joinUser, loginUser} = require('./user.js');

 

이제 준비가 되었다. 본격적으로 jest 사용법에 대해 알아보자.

 

Jest 기초 사용법

1. test

test의 첫 번째 인자로 테스트 설명을, 두 번째 인자로 테스트 코드가 담긴 함수를 넣어준다.

test('기본 테스트', ()=>{
	// 테스트 코드
})

 

2. describe

 

비슷한 유형의 test를 묶을 수 있다.

 

test의 첫 번째 인자로 테스트 설명을, 두 번째 인자로 여러 test가 담긴 함수를 넣어준다.

 

describe('loginUser', ()=>{
	test('첫 번째 테스트', ()=>{})
  	test('두 번째 테스트', ()=>{})
	test('세 번째 테스트', ()=>{})
}

 

3. expect

expect 안에 테스트 할 값을 넣어주고 matchers를 사용해서 값을 검증할 수 있다.

 

test('기본 테스트', ()=>{
    expect(1+1).toBe(2)
    expect(1+1).toEqual(2)
    expect(3).toBeLessThan(4)
    expect(1).not.toBeNull()
})

 

mathcers의 종류들은 공식 문서에 매우 자세하게 나와있기 때문에 공식 문서를 참고하는 것을 추천한다.

https://jestjs.io/docs/using-matchers

 

Using Matchers · Jest

Jest uses "matchers" to let you test values in different ways. This document will introduce some commonly used matchers. For the full list, see the expect API doc.

jestjs.io

회원 가입 단위 테스트 코드 작성

회원 가입 단위 테스트 코드를 작성하기 전에 회원 가입 코드부터 살펴보자.

 

<controllers/user.js > joinUser>

const joinUser = async (req, res)=>{
    console.log(req.body)
    const {name, password} = req.body
    const newUser = new User({
        name,
        password: await bcrypt.hash(password, 10)
    })
    try{
        await newUser.save()
    } catch(error){
        if(error.name == "MongoServerError" && error.code ==11000){
            return res.status(409).json({
                code: 409,
                message: '유저 이름 중복'
            })
        }
    }
    res.json({
        code: 200,
        message: '회원가입 성공'
    })
}

 

1. req.body에는 name과 password가 담겨 있고 이를 바탕으로 User 객체를 생성하고 save를 호출한다.

 

2. 만약 유저 이름 중복 에러가 발생했다면 res.status를 호출하고 res.json을 호출한다.

 

3. 에러가 발생하지 않았다면 res.json을 호출한다.

 

분기점이 존재하고 가능한 경우의 수는 2개이다. (중복, 성공)

 

즉 로그인에 관련된 테스트 코드는 describe로 묶고 그 

 

req, res 가짜 객체를 만들고 User.save, res.status, res.json에 대해서 가짜 함수를 만들어보자.

 

<req 가짜 객체>

실제 요청을 보내지 않기 때문에 라우터를 거치지 않는다.

 

그래서 다음과 같은 요청이 들어왔다는 것을 의미하는 가짜 req 객체를 만든다.

    const req = {
      body:{
        nickname: "chulsoo",
        password: "password",
      }
    };

 

 

여기서 req는 단순히 데이터를 전달하는 역할이기 때문에 일반 json 만드는 것처럼 만들면 된다.

 

그런데 res는 단순히 데이터를 전달하는 것이 아닌 내부의 함수를 실행하기 때문에 내부에 가짜 함수를 만들어야 한다.

 

가짜 함수는 jest.fn을 사용해서 만들 수 있다.

    const res = {
      json: jest.fn(()=>{})
    };

 

이제 res.json을 호출할 수 있고 여기서 호출할 때의 정보를 확인할 수 있다.

 

우리가 비교할 정보는 해당 함수가 호출된 횟수호출될 때 담긴 값이다.

 

호출된 횟수는 toHaveBeenCalledTimes로, 담긴 값은 toHaveBeenCalledWith를 사용할 수 있다.

 

ex)

    expect(res.json).toHaveBeenCalledTimes(1);
    expect(res.json).toHaveBeenCalledWith({
      code: 200,
      message: "회원가입 성공",
    });

 

위 코드는

res.json({
      code: 200,
      message: "회원가입 성공",
    }}

이 한 번만 호출되었다면 성공할 것이다.

 

이제 User.save 또한 가짜 함수를 넣어주자.

 

mockReturnValueOnce를 사용하여 User.save가 호출될 때 반환할 값을 정하자.

    User.prototype.save = jest.fn().mockReturnValueOnce({
      nickname: "chulsoo",
      password: "password"
    })

 

 

 

마지막으로 joinUser에 가짜 req, res를 넣어서 실행하고 res.json이 제대로 호출되었는지 확인하자.

 

    await joinUser(req, res);
    expect(res.json).toHaveBeenCalledTimes(1);
    expect(res.json).toHaveBeenCalledWith({
      code: 200,
      message: "회원가입 성공",
    });

 

<joinUser 단위 테스트>

describe("joinUser", () => {
  test("회원가입 성공", async () => {
    const req = {
        body:{
          nickname: "chulsoo",
          password: "password",
        }
      };
      const res = {
        json: jest.fn(()=>{})
      };
    User.prototype.save = jest.fn().mockReturnValueOnce({
      nickname: "chulsoo",
      password: "password"
    })

    await joinUser(req, res);
    expect(res.json).toHaveBeenCalledTimes(1);
    expect(res.json).toHaveBeenCalledWith({
      code: 200,
      message: "회원가입 성공",
    });
  });
  test("회원가입 실패 유저 닉네임 중복", async () => {
    User.prototype.save = jest.fn().mockRejectedValue({
      name: 'MongoServerError',
      code: 11000

    })
    const req = {
      body:{
        nickname: "chulsoo",
        password: "password",
      }
    };
    const res = {
      status: jest.fn(()=>res),
      json: jest.fn(()=>{})
    };
    await joinUser(req, res);
    expect(res.json).toHaveBeenCalledWith({
      code: 409,
      message: "유저 이름 중복",
    });
  });
});

 

다음은 loginUser의 단위 테스트를 작성해보자.

 

로그인 인증 과정은 passport 모듈을 사용하고 있기 때문에 이 모듈의 authenticate 함수를 가짜 함수로 만들어야 한다.

 

passport 인증 과정이 복잡한데 passport 모듈의 인증 과정을 모른다면 아래 포스팅을 보고 오는 것을 추천한다.

https://growth-coder.tistory.com/278

 

[Express] 쿠키 세션 방식으로 로그인 구현

이번 포스팅에서는 쿠키, 세션 방식으로 로그인을 구현해보려고 한다. 세팅 패키지를 설치하자. npm install express express-session bcrypt dotenv passport passport-local mongoose express : express 서버express-session :

growth-coder.tistory.com

loginUser에서 passport.authenticate를 통해 localStrategy를 호출한다.

 

<passport/localStrategy.js>

const passport = require('passport')
const LocalStrategy = require('passport-local').Strategy
const bcrypt = require('bcrypt')

const User = require('../schemas/user')
module.exports = ()=>{
    passport.use('local', new LocalStrategy({
        usernameField: 'name',
        passwordField: 'password'
    }, async(name, password, done)=>{
        try{
            const findUser = await User.findOne({name})
            if (findUser){
                const res = await bcrypt.compare(password, findUser.password)
                if (res){
                    done(null, findUser, {code: 200, message: "로그인 성공"})
                } else{
                    done(null, false, {code: 400, message: "비밀번호가 일치하지 않습니다."})
                }
            } else{
                done(null, false, {code:404, message: "해당 유저가 존재하지 않습니다."})
            }
        } catch(error){
            console.log(error)
        }
    }))
}

 

단위 테스트를 localStrategy까지 실행하지 않고 localStrategy에서 콜백 함수가 실행된 상태라고 가정한 상태로 단위 테스트를 작성해보자.

 

가짜 req 객체를 만들 건데 joinUser와 다른 점은 내부에 login 함수가 존재한다는 점이다.

 

해당 login 함수는 첫 번째 인자로 user, 두 번째 인자로 함수를 받는다.

  const req = {
    body: {
      nickname: 'chulsoo',
      password: 'password',
    },
    login: jest.fn((user, callback)=>{
      callback();
    }),
  };

 

callback 함수를 실행해야 로그인에 성공했을 때 res.status(info.code).json(info) 코드가 제대로 실행될 것이다.

 

res의 경우 status와 json을 호출하는데 status는 자기 자신을 반환해야 json을 정상적으로 호출할 수 있다.

 

  const res = {
    status: jest.fn().mockReturnThis(),
    json: jest.fn(),
  };

 

에러가 발생하면 next에 error를 담기 때문에 next 가짜 함수도 생성하자.

  const next = jest.fn();

 

준비는 끝났고 이 가짜 객체들을 loginUser에 넣어준 다음 검증해보자.

  test('로그인 성공', async ()=>{
    const req = {
        body: {
          nickname: 'chulsoo',
          password: 'password',
        },
        login: jest.fn((user, callback)=>{
          callback();
        }),
      };
      const res = {
        status: jest.fn().mockReturnThis(),
        json: jest.fn(),
      };
      const next = jest.fn();
    passport.authenticate.mockImplementationOnce((strategy, callback) => (req, res, next) => {
      callback(null, { nickname: 'chulsoo', password: 'password' }, { code: 200, message: '로그인 성공' });
    });
    
    await loginUser(req, res, next);
    
    expect(res.status).toHaveBeenCalledWith(200);
    expect(res.json).toHaveBeenCalledWith({ code: 200, message: '로그인 성공' });
  })

 

이제 로그인 실패한 경우에 대해서 테스트 케이스를 작성하자.

describe('loginUser', ()=>{

  const req = {
    body: {
      nickname: 'chulsoo',
      password: 'password',
    },
    login: jest.fn((user, callback)=>{
      callback();
    }),
  };
  const res = {
    status: jest.fn().mockReturnThis(),
    json: jest.fn(),
  };
  const next = jest.fn();

  afterEach(()=>{
    jest.clearAllMocks();
  })
  test('로그인 성공', async ()=>{
    const req = {
        body: {
          nickname: 'chulsoo',
          password: 'password',
        },
        login: jest.fn((user, callback)=>{
          callback();
        }),
      };
      const res = {
        status: jest.fn().mockReturnThis(),
        json: jest.fn(),
      };
      const next = jest.fn();
    passport.authenticate.mockImplementationOnce((strategy, callback) => (req, res, next) => {
      callback(null, { nickname: 'chulsoo', password: 'password' }, { code: 200, message: '로그인 성공' });
    });
    
    await loginUser(req, res, next);
    
    expect(res.status).toHaveBeenCalledWith(200);
    expect(res.json).toHaveBeenCalledWith({ code: 200, message: '로그인 성공' });
  })
  test('로그인 실패 비밀번호 불일치', async ()=>{
    passport.authenticate.mockImplementationOnce((strategy, callback) => (req, res, next) => {
      callback(null, false, { code: 400, message: "비밀번호가 일치하지 않습니다." });
    });

    await loginUser(req, res, next);

    expect(res.status).toHaveBeenCalledWith(404);
    expect(res.json).toHaveBeenCalledWith({ code: 404, message: "해당 유저가 존재하지 않습니다." });
  })
  test('로그인 실패 유저 찾지 못 함', async ()=>{
    passport.authenticate.mockImplementationOnce((strategy, callback) => (req, res, next) => {
      callback(null, false, { code: 404, message: "해당 유저가 존재하지 않습니다." });
    });

    await loginUser(req, res, next);

    expect(res.status).toHaveBeenCalledWith(404);
    expect(res.json).toHaveBeenCalledWith({ code: 404, message: "해당 유저가 존재하지 않습니다." });
  })
})

 

지금은 strategy까지 들어가지 않고 passport.authenticate 자체를 가짜로 만들었는데 strategy를 가짜로 만들어서 테스트 해보는 것도 좋을 것 같다.

728x90
반응형

댓글