티스토리 뷰

728x90
SMALL

배경

저번글에서 기본 jest 사용법을 알아봤다. 기본 사용법은 알았으니 뚝딱 되지 않을까?? 그럴리가...

 

단위 테스트

미들웨어 테스트

먼저 미들웨어 테스트를 해보자. 비교적 간단한 것부터 시작하려고 한다.

 

is-logged-in-middleware.ts

import { NextFunction, Request, Response } from 'express';

const isLoggedInMiddleware = async (req: Request, res: Response, next: NextFunction) => {
  if (req.user) {
    return next();
  }
  res.status(403).json({ errorMessage: '로그인이 필요합니다' });
};

export default isLoggedInMiddleware;

 

is-logged-in-middleware.test.ts

import { Request, Response } from 'express';
import isLoggedInMiddleware from '../is-logged-in-middleware';

let req = {} as Request;
let res = {} as Response;
const next = jest.fn();

describe('isLoggedInMiddleware', () => {
  test('로그인 되어있으면 isLoggedInMiddleware가 next 호출', () => {
    req = { user: {} } as unknown as Request;
    isLoggedInMiddleware(req, res, next);
    expect(next).toBeCalledTimes(1);
  });

  test('로그인 되어있지 않으면 isLoggedInMiddleware가 에러 응답', () => {
    req = { user: null } as unknown as Request;
    res = {
      status: jest.fn(() => res),
      json: jest.fn(() => res),
    } as unknown as Response;
    isLoggedInMiddleware(req, res, next);
    expect(res.status).toBeCalledWith(403);
    expect(res.json).toBeCalledWith({ errorMessage: '로그인이 필요합니다' });
  });
});

 

req.user를 확인해서 존재한다면 next로 넘겨주고 존재하지 않는다면 res 처리후 종료하는(next로 넘기지 않으니 종료) 간단한 미들웨어다.

 

사용법은 어렵지 않다. 조금 신경써야 될 부분은 express의 req,res부분이다. 자스면면 신경쓸것도 없는데 타입스크립트니 as로 목 데이터를 만들어준다 상황에 따라 다른 데이터를 넣어서 호출하면 된다. next는 그냥 jest.fn으로 모킹하면된다. 모킹했기 때문에 toBe가 아니라 toBeCalledWith로 확인해줘야한다.

 

jwt 미들웨어도 시도했는데 함수가 너무 큰 탓에 일단 실패했다. 곧 추가하겠다...

 

jwt-middleware.test.ts

import { Request, Response } from 'express';
import jwtMiddleware from '../jwt-middleware';

const req = { headers: { authorization: '' }, cookies: '' } as Request;
const res = {} as Response;
const next = jest.fn();

describe('jwtMiddleware', () => {
  test('TODO 어렵다', () => {
    jwtMiddleware(req, res, next);
    expect(next).toBeCalledTimes(1);
  });
});

 

통합 테스트

지금까지 해온것은 단위테스트다. 각각의 나눠서 하나하나 테스트해왔는데 이렇게 되면 문제는 전체 코드가 정상적을 동작하는지 알 수 없다. 특히 서버에서는 db에서 생기는 문제가 많기 때문에 꼭 통합 테스트도 해줘야 한다. 

supertest

HTTP assertions made easy via superagent

위는 공식 사이트에서 supertest를 설명하는 말이다. 즉 supertest는 superagent를 기반으로 한 http 검증 라이브러리다. 

슈퍼 테스트의 인터페이스는 노드의 http.Server 객체나 함수를 인자로 받는 형태이다. 인자로 받은 서버 객체가 요청 대기 상태가 아니라면 슈퍼 테스트는 임시로 포트를 열어 서버를 요청 대기 상태로 전환해 준다.

- 김정환님 블로그 인용 https://jeonghwan-kim.github.io/dev/2020/05/25/supertest.html

 

그렇기 때문에 엔트리포인트의 app.listen을 제외시켰다. listen을 실행하지 않아도 supertest를 실행할 수 있다. 

 

app.ts

import express, { Application } from 'express';
import errorMiddleware from '@/middlewares/error-middleware';
import jwtMiddleware from '@/middlewares/jwt-middleware';
import loaders from './loaders';
import router from './routes';

class App {
  app: Application;

  constructor() {
    this.app = express();
    this.setLoaders();
    this.setRouter();
    this.setErrorMiddleware();
  }

  async setLoaders() {
    await loaders(this.app);
  }

  setRouter() {
    this.app.use('/api', jwtMiddleware, router);
  }

  setErrorMiddleware() {
    this.app.use(errorMiddleware);
  }
}

export default App;

 

server.ts

import App from './app';

const { app } = new App();
const port = process.env.PORT;

app.listen(port, () => {
  console.log(`Server running on ${port}`);
});

 

테스트 db 연결

일단 통합테스트를 할 때 이용중인 db를 절대 이용해서는 안되다. 테슽트할 때 db에 데이터를 저장하고 삭제하는데 이용중인 db가 바뀐다면 큰 문제가 생긴다. 그래서 꼭 test용 db설정을 해줘야한다. 기본적으로 jest를 실행하면 NODE_ENV는 test로 실행된다. db에 연결하는 코드를 만들어보자

 

test-connection.ts

import { Connection, getConnection } from 'typeorm';
import dbLoader from '@/loaders/db-loader';

const testConnection = {
  async create() {
    await dbLoader();
  },

  async close() {
    await getConnection().close();
  },

  async clear() {
    const connection = getConnection();
    const entities = connection.entityMetadatas;

    await Promise.all(entities.map(entity => this.deleteAll(entity.name, connection)));
  },

  async deleteAll(entityName: string, connection: Connection) {
    const repository = connection.getRepository(entityName);
    await repository.query(`DELETE FROM ${entityName}`);
  },
};

export default testConnection;

 

db-loader.ts

import dbConfig from '@/config/db-config';
import 'reflect-metadata';
import { createConnection } from 'typeorm';

const dbLoader = async (): Promise<void> => {
  try {
    await createConnection(dbConfig());
  } catch (err) {
    console.log('db connection error \n', err);
  }
};

export default dbLoader;

 

create로 연결하고 close로 연결을 끊고 clear로 데이터를 비우는 것이다. 더 세부적으로는 clear에서 deleteAll을 호출해서 query문으로 테이블의 데이터를 삭제한다.

 

라우터 테스트

이제 통합 테스트를 해보자. 거의 모든 프로젝트에 공통적으로 들어가는 인증부분을 보이겠다. 로그인은 너무 길어져서 생략하겠다.

 

auth/index.ts

import { Router } from 'express';
import AuthController from '@/controllers/auth-controller';
import signupValidation from '@/validation/auth/signup-validation';
import loginValidation from '@/validation/auth/login-validation';

const authRouter = Router();

const authController = new AuthController();

authRouter.get('/', authController.checkUser);
authRouter.post('/signup', signupValidation, authController.signup);
authRouter.post('/login', loginValidation, authController.login);
authRouter.delete('/', authController.logout);

export default authRouter;

 

auth.test.ts

import App from '@/app';
import supertest from 'supertest';
import testConnection from './test-connection';

const { app } = new App();
const request = supertest(app);
const agent = supertest.agent(app);

describe('auth', () => {
  beforeAll(async () => {
    await testConnection.create();
  });

  afterAll(async () => {
    await testConnection.close();
  });

  beforeEach(async () => {
    await testConnection.clear();
  });

  test('signup success', async () => {
    const signupData = {
      email: 'email@naver.com',
      nickname: 'nickname',
      password: '12341234',
    };
    const res = await request.post('/api/auth/signup').send(signupData);
    expect(res.status).toBe(201);
  });

  test('login success', async () => {
    const signupData = {
      email: 'email@naver.com',
      nickname: 'nickname',
      password: '12341234',
    };
    await request.post('/api/auth/signup').send(signupData);

    const loginData = { email: 'email@naver.com', password: '12341234' };
    const res = await request.post('/api/auth/login').send(loginData);
    expect(res.status).toBe(200);
  });

  test('logout success', async () => {
    const res = await request.delete('/api/auth');
    expect(res.status).toBe(200);
  });

  test('checkUser null', async () => {
    const res = await request.get('/api/auth');
    expect(res.body).toEqual({ user: null });
    expect(res.status).toBe(200);
  });

  test('checkUser success', async () => {
    const signupData = {
      email: 'email@naver.com',
      nickname: 'nickname',
      password: '12341234',
    };
    const signupRes = await request.post('/api/auth/signup').send(signupData);
    const refreskToken = signupRes.headers['set-cookie'][0].split(';')[0].split('=')[1];
    const { accessToken } = signupRes.body;
    const checkUserRes = await request
      .get('/api/auth')
      .set('Authorization', `Bearer ${accessToken}`)
      .set('Cookie', [`refreshtoken=${refreskToken}`]);
    expect(checkUserRes.body.user.nickname).toBe(signupData.nickname);
    expect(checkUserRes.status).toBe(200);
  });

  test('checkUser success agent version', async () => {
    const signupData = {
      email: 'email@naver.com',
      nickname: 'nickname',
      password: '12341234',
    };
    const signupRes = await agent.post('/api/auth/signup').send(signupData);
    const { accessToken } = signupRes.body;
    const checkUserRes = await agent.get('/api/auth').set('Authorization', `Bearer ${accessToken}`);
    expect(checkUserRes.body.user.nickname).toBe(signupData.nickname);
    expect(checkUserRes.status).toBe(200);
  });

  describe('signup fail', () => {
    test('signup email fail', async () => {
      const signupData = {
        email: 'email',
        nickname: 'nickname',
        password: '12341234',
      };
      const res = await request.post('/api/auth/signup').send(signupData);
      expect(res.status).toBe(400);
    });

    test('signup email max length fail', async () => {
      const signupData = {
        email: 'emailemailemailemailemailemailemailemailemailemailemailemailemailemailemail@naver.com',
        nickname: 'nickname',
        password: '12341234',
      };
      const res = await request.post('/api/auth/signup').send(signupData);
      expect(res.status).toBe(400);
    });

    test('signup nickname min length fail', async () => {
      const signupData = {
        email: 'email@naver.com',
        nickname: 'a',
        password: '12341234',
      };
      const res = await request.post('/api/auth/signup').send(signupData);
      expect(res.status).toBe(400);
    });

    test('signup nickname max length fail', async () => {
      const signupData = {
        email: 'email@naver.com',
        nickname: 'nicknamenicknamenicknamenicknamenicknamenicknamenicknamenicknamenickname',
        password: '12341234',
      };
      const res = await request.post('/api/auth/signup').send(signupData);
      expect(res.status).toBe(400);
    });

    test('signup password min length fail', async () => {
      const signupData = {
        email: 'email@naver.com',
        nickname: 'nickname',
        password: '1',
      };
      const res = await request.post('/api/auth/signup').send(signupData);
      expect(res.status).toBe(400);
    });

    test('signup password max length fail', async () => {
      const signupData = {
        email: 'email@naver.com',
        nickname: 'nickname',
        password: '1234123412341234123412341234123412341234123412341234123412341234',
      };
      const res = await request.post('/api/auth/signup').send(signupData);
      expect(res.status).toBe(400);
    });
  });
});

 

 

일단 먼저 beforeAll, afterAll, beforeEach를 살펴보자. 전체 테스트를 실행하기 전에 db에 연결하고 테스트가 끝난다면 db에 연결을 끊어준다. 그릐고 beforeEach로 테이블을 빈 테이블로 만들어준다. 빈테이블로 만들지 않는다면 중간에 예상하지 못한 정보를 얻을 수 있다. 이건 앞의 글에서도 설명을 했었다.

  beforeAll(async () => {
    await testConnection.create();
  });

  afterAll(async () => {
    await testConnection.close();
  });

  beforeEach(async () => {
    await testConnection.clear();
  });

 

성공하는 경우

먼저 성공하는 경우를 테스트해보자

  test('signup success', async () => {
    const signupData = {
      email: 'email@naver.com',
      nickname: 'nickname',
      password: '12341234',
    };
    const res = await request.post('/api/auth/signup').send(signupData);
    expect(res.status).toBe(201);
  });

  test('login success', async () => {
    const signupData = {
      email: 'email@naver.com',
      nickname: 'nickname',
      password: '12341234',
    };
    await request.post('/api/auth/signup').send(signupData);

    const loginData = { email: 'email@naver.com', password: '12341234' };
    const res = await request.post('/api/auth/login').send(loginData);
    expect(res.status).toBe(200);
  });

  test('logout success', async () => {
    const res = await request.delete('/api/auth');
    expect(res.status).toBe(200);
  });

  test('checkUser null', async () => {
    const res = await request.get('/api/auth');
    expect(res.body).toEqual({ user: null });
    expect(res.status).toBe(200);
  });

사용법은 위와같다. send는 body에 넣는 데이터를 생각하면 된다. 그리고 여기서는 단위 테스트할때와 다르게 목 함수를 사용하지 않았기 때문에 toBe, toEqual을 사용한다.

 

  test('checkUser success', async () => {
    const signupData = {
      email: 'email@naver.com',
      nickname: 'nickname',
      password: '12341234',
    };
    const signupRes = await request.post('/api/auth/signup').send(signupData);
    const refreskToken = signupRes.headers['set-cookie'][0].split(';')[0].split('=')[1];
    const { accessToken } = signupRes.body;
    const checkUserRes = await request
      .get('/api/auth')
      .set('Authorization', `Bearer ${accessToken}`)
      .set('Cookie', [`refreshtoken=${refreskToken}`]);
    expect(checkUserRes.body.user.nickname).toBe(signupData.nickname);
    expect(checkUserRes.status).toBe(200);
  });

  test('checkUser success agent version', async () => {
    const signupData = {
      email: 'email@naver.com',
      nickname: 'nickname',
      password: '12341234',
    };
    const signupRes = await agent.post('/api/auth/signup').send(signupData);
    const { accessToken } = signupRes.body;
    const checkUserRes = await agent.get('/api/auth').set('Authorization', `Bearer ${accessToken}`);
    expect(checkUserRes.body.user.nickname).toBe(signupData.nickname);
    expect(checkUserRes.status).toBe(200);
  });

 

신경써야 될 부분이 있어서 따로 빼놨다. 나는 로그인할 때 accesstoken과 refreshtoken을 이용한다. 그리고 하나는 로컬 스토리지에서 관리하며 커스텀 axios 모듈에서 헤더에 넣어서 전송하고 쿠키는 저절로 전송한다. 그렇기 때문에 아무 생각없이 테스트하게 된다면 당연히 실패한다. accessToken은 return 받기 때문에 데이터를 받아서 헤더에 넣어주고 쿠키같은  경우는 set-cookie로 전달을 하기 때문에 위와같은 귀찮은 과정을 해줘야 한다. set-cookie로직이 이해가 안간다면 직접 해보면 된다. 그냥 필요한 정보를 빼온것뿐이다.

그런데 이걸 조금 쉽게 해주는 방법이 있다. agent를 이용하면 요청을 지속시킬 수 있다. 그러면 훨신 간단해진 것을 볼 수 있다. 물론 accessToken 부분은 처리를 해줘야 한다. 저건 방법이 없음...

 

실패하는 경우

  describe('signup fail', () => {
    test('signup email fail', async () => {
      const signupData = {
        email: 'email',
        nickname: 'nickname',
        password: '12341234',
      };
      const res = await request.post('/api/auth/signup').send(signupData);
      expect(res.status).toBe(400);
    });

    test('signup email max length fail', async () => {
      const signupData = {
        email: 'emailemailemailemailemailemailemailemailemailemailemailemailemailemailemail@naver.com',
        nickname: 'nickname',
        password: '12341234',
      };
      const res = await request.post('/api/auth/signup').send(signupData);
      expect(res.status).toBe(400);
    });

    test('signup nickname min length fail', async () => {
      const signupData = {
        email: 'email@naver.com',
        nickname: 'a',
        password: '12341234',
      };
      const res = await request.post('/api/auth/signup').send(signupData);
      expect(res.status).toBe(400);
    });

    test('signup nickname max length fail', async () => {
      const signupData = {
        email: 'email@naver.com',
        nickname: 'nicknamenicknamenicknamenicknamenicknamenicknamenicknamenicknamenickname',
        password: '12341234',
      };
      const res = await request.post('/api/auth/signup').send(signupData);
      expect(res.status).toBe(400);
    });

    test('signup password min length fail', async () => {
      const signupData = {
        email: 'email@naver.com',
        nickname: 'nickname',
        password: '1',
      };
      const res = await request.post('/api/auth/signup').send(signupData);
      expect(res.status).toBe(400);
    });

    test('signup password max length fail', async () => {
      const signupData = {
        email: 'email@naver.com',
        nickname: 'nickname',
        password: '1234123412341234123412341234123412341234123412341234123412341234',
      };
      const res = await request.post('/api/auth/signup').send(signupData);
      expect(res.status).toBe(400);
    });
  });

크게 설명할 부분이 없다. 지금 프로젝트에서는 joi 라이브러리로 검증을 하고 있는데 여기에 걸리는지 확인을 하는 것이다. 물론 글쓰기 라던가 수정부분이라면 서비스 계층에서 오류가 발생할 수도 있다. 이런식으로 테스트함수를 만들어 놓게 되면 직접 일일히 postman으로 확인하지 않아도 확실하게 검증할 수 있다.

후기

테스트에 대해서 사실 조금 부정적이였다. 그나마 서버쪽은 더 필요하다 라는 생각이였는데 조금씩 긍정적으로 바뀌는 것 같다. tdd방식도 그렇고..

그리고 테스트를 하다보면 내가 얼마나 코드를 잘못짰는지 분리를 못했는지 알 수 있게된다고 한다. 많이 신경썼는데 그럼에도 불구하고 jwt 미들웨어부분에서 부족한 것이 보인다. 어쩌면 테스트 모킹하는 방법이 미숙해서 그럴수도 있지만 어쨋든 문제점을 느꼈다. 그리고 분리하려고 노력했는데 그 부분이 테스트할 때 확실히 나타난 것 같다. 서비스 계층과 jwt, util함수 부분을 테스트 해봐야겠다. 서비스 계층은 비지니스 로직이 많지 않은 코드라면 조금 의문점을 가질 수도 있다. 내가 지금 하고 있는 간단한 프로젝트라면 통합테스트 때 사실 걸리기도 한다. 계층별로 tdd 방식으로 만든다면 또 다르지만....

그리고 컨트롤러부분은 테스트하지 않아도 괜찮을까? 지금은 잘 모르겠다. 아직 테스트가 미숙해서 보완한점을 다음 글에서 올려야겠다. 그리고 내년상반기안에는 리액트에서도 tdd방식으로 만들어봐야겠다. jest로 렌더링테스트나 스토리북을 이용한 ui 테스트, 통합테스트까지 말이다. 아 부하테스트도 해봐야한다. 테스트는 생각보다 작성해야 될 코드가 많다. 위에서는 고작 회원가입 validation을 했을 뿐이다. 회원가입 시 중복되는 이메일이 있는 경우도 있고 닉네임이 중복되는 경우도 있다. 이런 경우까지 전부 생각을 해야한다.

728x90
LIST
댓글
공지사항