[express] jest + supertest

DaeChan Jo·2023년 9월 21일
3

library

목록 보기
4/4
post-thumbnail

그동안 api를 개발하고 테스트는 포스트맨 하나에 의존하고있었고 딱히 문제도 없었었다.
불편했던 점이라면 하나의 api에 수정사항이 생겼을 때, 연동되는 api들을 전부 일일이 테스트했어야했다.
좋은 방법이 없나 생각해보다 예전에 첫 프로젝트를 할때 받았던 스켈레톤 코드 스크립트에서 봤던 jest가 생각이 났다.
그 당시에는 jest가 뭐지 먹는건가 하고 그냥 넘어갔었는데 좀 더 일찍 알았더라면 수월한 디버깅을 할 수 있었을텐데..

Jset 와 Supertest

Jest와 Supertest는 모두 JavaScript 및 Node.js 환경에서 테스트를 작성하고 실행하는 데 사용되는 도구들이다. 각각의 역할은 다음과 같다

  • Jest: Jest는 Facebook이 개발한 자바스크립트 테스트 프레임워크로, 유닛 테스트와 통합 테스트를 모두 지원한다. Jest는 목(mock) 함수, 타이머 제어, 비동기 지원 등 다양한 기능을 제공하여 코드의 정확성을 검증하는 데 사용된다.

  • Supertest: Supertest는 HTTP 요청과 응답을 쉽게 테스트할 수 있게 해주는 라이브러리이다. 주로 Express.js 같은 Node.js 웹 서버에 대한 엔드포인트를 테스트하는 데 사용됩니다.

따라서 Jest와 Supertest의 관계를 간략하게 설명하면, Jest가 전체적인 테스팅 환경(프레임워크)을 제공하고, 그 안에서 HTTP 요청/응답에 대한 구체적인 검증 작업을 Supertest가 담당한다고 볼 수 있다. Jset와 Supertest를 혼용하면 다음과 같이 사용할 수 있다.

ex) HTTP GET 요청을 보내고 그 결과를 검증

import request from 'supertest';
import app from './app';

describe('GET /', () => {
  it('responds with 200', async () => {
    const response = await request(app).get('/');
    expect(response.statusCode).toBe(200);
  });
});

위 코드에서 describe 와 it 함수는 jest가 제공하고,
request(app).get('/) 부분은 Supertest가 제공하는 기능이다.

통합테스트

TypeScript | Service-Oriented MVC 패턴을 기준으로 작성되었습니다

api를 개별적으로 테스트코드를 작성하거나 컨트롤러와 서비스로직을 분리하는 등 단위 테스트가 가능하지만 개인적으로 비효율적으로 느껴졌다. 귀챠ㄴㅎ
하나의 라우터를 통째로 테스트해보자.


먼저 필요한 모듈을 설치한다.

npm i -D supertest
npm install -D ts-jest @types/jest
npm install -D @types/jest

테스트코드를 typescript로 작성할거기 때문에 jest.config도 설정해줘야 한다.
루트경로에 jest.config.js 를 생성 후 다음과 같이 작성해 주자

module.exports = {
    preset: "ts-jest",
    testEnvironment: "node",
};

스크립트도 추가해주자

"scripts": {
		...
		"test": "jest"
	},

이제 라우터 경로에 코드를 작성해주면 된다. 회원가입, 로그인 등 인증관련 API 테스트 코드를 routers 경로에 작성해줬다.

jest에서 제공하는 fn 이나 mock 메서드를 사용하면 모킹할수 있지만 현재 코드에선 실제로 가동중인 서버가 아니고 생성된 레코드는 테스트가 끝남과 동시에 삭제될거라 사용중인 데이터베이스를 그대로 사용하고 mock메서드를 사용하지 않았다.

import request from "supertest";
import express from "express";
import authRouter from "./authRouter";
import passport from "passport";
import { local } from "../passport";
import { jwt } from "../passport";

//app.ts 에 작성된 서버설정 및 필요한 미들웨어도 전부 추가해줘야 한다
const app = express();
app.use(passport.initialize());
passport.use("local", local);
passport.use("jwt", jwt);
app.use(express.json());
app.use("/auth", authRouter);

//test suite 정의
//여러 개의 테스트 케이스를 그룹화하는 역할
//첫 번째 인자로 설명 문자열, 두 번째 인자로 테스트 케이스 정의 함수들을 넣어주면 된다
describe("Authentication API", () => {
    let userToken: any; // 로그인 이후 발급되는 토큰을 저장할 변수

    // 회원가입 테스트
    // it : 개별적인 테스트 케이스 정의
    // describe 과 마찬가지로 첫번째 인자로 설명, 두 번째 인자로 해당 테스트가 어떻게 동작할지 정의해준다
    it("POST /auth/signup - 새 유저를 생성하고 201을 반환", async () => {
        const res = await request(app).post("/auth/signup").send({
            email: "test@example.com",
            password: "password",
            name: "Test User",
            nickname: "testuser",
        });
		
      	// 예상값 설정
        expect(res.statusCode).toEqual(201);
        expect(res.body.message).toContain("회원가입에 성공했습니다");
    });

    // 로그인 테스트
    it("POST /auth - 로그인 성공하고 200을 반환", async () => {
        const res = await request(app).post("/auth").send({
            email: "test@example.com",
            password: "password",
        });
        expect(res.statusCode).toEqual(200);
        expect(res.body.user).toEqual("Test User");
        expect(res.body.nickname).toEqual("testuser");
        userToken = res.body.token;
    });

    // 유저 상세보기 테스트
    it("GET /auth - 유저 정보 가져오고 200 반환", async () => {
        const res = await request(app)
            .get("/auth")
            .set("Authorization", `Bearer ${userToken}`);

        expect(res.statusCode).toEqual(200);
        expect(res.body.name).toEqual("Test User");
        expect(res.body.nickname).toEqual("testuser");
    });

    // 유저 정보 수정 테스트
    it("PUT /auth - 유저 정보 수정하고 201 반환", async () => {
        const res = await request(app)
            .put("/auth")
            .set("Authorization", `Bearer ${userToken}`)
            .send({
                // 업데이트할 필드를 자유롭게 추가
                name: "Updated User",
            });
        console.log(res.statusCode);
        expect(res.statusCode).toEqual(201);

        // 업데이트된 필드만 추가
        expect(res.body.name).toEqual("Updated User");
    });

    // 회원 탈퇴 테스트
    it("DELETE /auth - 유저 삭제하고 204 반환", async () => {
        const res = await request(app)
            .delete("/auth")
            .set("Authorization", `Bearer ${userToken}`);

        expect(res.statusCode).toEqual(204);
    });
});

app.ts 에서 내보내는 app객체와 해당 api들을 사용하기 위해 꼭 필요한 모듈과 미들웨어들을 반드시 import 해줘야한다.
만약 테스트 결과에 서버에러 (500) 를 발생시키는 api가 있다면 높은 확률로 이 문제 때문이다.


이제 npm test 혹은 yarn test 를 실행시키면 다음과 같은 결과를 확인할 수 있다.

yarn test 
yarn run v1.22.19
$ jest
  console.log
    201

      at src/routers/auth.test.ts:63:17

 PASS  src/routers/auth.test.ts
  Authentication API
    ✓ POST /auth/signup - 새 유저를 생성하고 201을 반환 (99 ms)
    ✓ POST /auth - 로그인 성공하고 200을 반환 (69 ms)
    ✓ GET /auth - 유저 정보 가져오고 200 반환 (7 ms)
    ✓ PUT /auth - 유저 정보 수정하고 201 반환 (21 ms)
    ✓ DELETE /auth - 유저 삭제하고 204 반환 (4 ms)

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        1.251 s, estimated 2 s
Ran all test suites.
✨  Done in 1.96s.

결과에는 테스트정보와 api의 성공 여부 등이 출력된다.
이제 수정사항이 있을때 모든 api를 postman으로 하나하나 테스트해보는 번거로움은 겪지 않아도 된다

추가

테스트에 목 객체를 사용하지 않고 실제 사용중인 데이터베이스로 테스트를 진행할 경우 보다 완전한 테스트를 진행해볼 수 있지만, 현재 각 라우터 단위로 통합테스트를 진행할 때 회원가입과 로그인, 그리고 테스트로 생성된 데이터들이 삭제되어야 되기 때문에 반복적인 코드가 발생된다.

그러므로 회원가입, 로그인, 회원탈퇴 테스트 코드를 모듈화시키고 jest 에서 제공하는 라이프사이클인 beforeAll, afterAll 을 이용해보자.

특징은 아래와 같다

  • beforeAll() : 모든 테스트 케이스가 실행되기 전에 한 번만 실행시킨다. 일반적으로 테스트 데이터를 설정하거나 데이터베이스 연결을 설정하는 등의 작업을 수행한다

  • afterAll() : 모든 테스트 케이스가 완료된 후에 한 번만 호출된다. 일반적으로 사용한 자원을 정리하는데 쓰인다.

두 함수 모두 선택적으로 비동기 동작을 지원하고 promise를 반환할 수 있다. 따라서 비동기 작업도 처리할 수 있다


먼저 회원가입, 로그인, 회원탈퇴 함수를 모듈화시킨다
...

export async function signUpUser() {
    const res = await request(app).post("/auth/signup").send({
        email: "tests@example.com",
        password: "password",
        name: "Test User",
        nickname: "testuser",
    });

    expect(res.statusCode).toEqual(201);
    expect(res.body.message).toContain("회원가입에 성공했습니다");
}

export async function loginUser() {
    const res = await request(app).post("/auth").send({
        email: "tests@example.com",
        password: "password",
    });

    expect(res.statusCode).toEqual(200);
    expect(res.body.user).toEqual("Test User");
    expect(res.body.nickname).toEqual("testuser");

    return res.body.token;
}

export async function deleteUser(userToken: string) {
    const res = await request(app)
        .delete("/auth")
        .set("Authorization", `Bearer ${userToken}`);

    expect(res.statusCode).toEqual(204);
}

모듈화된 함수를 auth.test.ts 에 라이프사이클 함수를 사용하여 적용시킨다
...


describe("Authentication API", () => {
    let userToken: any;

    beforeAll(async () => {
        await signUpUser();
        userToken = await loginUser();
    });

    // 유저 상세보기 테스트
    it("GET /auth - 유저 정보 가져오고 200 반환", async () => {
        const res = await request(app)
            .get("/auth")
            .set("Authorization", `Bearer ${userToken}`);

        expect(res.statusCode).toEqual(200);
        expect(res.body.name).toEqual("Test User");
        expect(res.body.nickname).toEqual("testuser");
    });

    // 유저 정보 수정 테스트
    it("PUT /auth - 유저 정보 수정하고 201 반환", async () => {
        const res = await request(app)
            .put("/auth")
            .set("Authorization", `Bearer ${userToken}`)
            .send({
                // 업데이트 테스트 필드 추가 가능
                name: "Updated User",
            });
        console.log(res.statusCode);
        expect(res.statusCode).toEqual(201);

				// 업데이트 필드 추가하면 여기도 추가
        expect(res.body.name).toEqual("Updated User");
    });

    afterAll(async () => {
        await deleteUser(userToken);
    });
});

이제 다른 라우터 통합테스트에서도 beforeAll, afterAll을 사용해 무의미한 중복코드를 줄이고 해당 라우터에서 테스트하고자 하는 api만 테스트하게 된다.

단, afterAll 의 deleteUser 로직은 해당 레코드가 삭제될 때 관련된 모든 데이터베이스의 데이터와 업로드파일도 삭제되도록 구현한 상태이기에 현재 자신의 코드에 맞게 구성하도록 한다.

profile
BackEnd Developer

0개의 댓글