supertest

What is supertest

Node.js 에서 express 를 이용하여 서버를 개발함에 있어, rest api 를 테스트 하기 위해서는 실제로 서버에 요청을 보내고 클라이언트에 원하는 데이터가 전송되는 것을 확인해야 한다.

이러한 e2e 테스트를 위해서는 실제 서버를 띄운 뒤에 해당 서버로 요청을 날려 보면서 테스트를 진행해야 하며, 이렇게 가상의 서버를 띄워주고 express web server 에 대해 e2e 테스트를 가능하게 해주는 것이 바로 supertest 이다.

본 포스트에서는 그 중에서도 일반 javascript가 아닌 typescript 환경에서의 e2e 테스트 코드 작성법에 대해 알아보고자 하며, 테스트를 위해서는 페이스북에서 만든 잘나가는 프레임웍인 jesttypescript 배포판인 ts-jest 를 사용한다.

Prerequisition

먼저 타입스크립트로 작성된 프로그램은 javascript 기반의 테스트 환경인 jest 로는 테스트를 작동시킬 수가 없기 때문에, ts-jest 패키지를 dev 환경에 설치해 주어야 jest 가 typescript 코드를 읽고 실행할 수 있다.

다음과 같이 ts-ject, supertest, @types/supertest를 설치해 준다.

1
2
3
npm install --save-dev ts-jest
npm install --save-dev supertest
npm install --save-dev @types/supertest

여기서 한가지 문제가 있는데, 아직 typescript supertest 의 경우 여러 패키지들과의 문제를 보이고 있다.

단편적인 예로 XMLHttpRequest 를 찾지 못하는 현상이 있는데, 이는 다음 설정을 tsconfig.json 에 해줌으로써 해결된다.
다음과 같이 dom 을 앞에다 추가해 주자. 반드시 첫번째로 해야한다.

1
2
3
4
5
{
compilerOptions:{
"lib":["dom","es2015]
}
}

위처럼 ts-jest를 설치했다면 이제 typescript 코드를 jest 에서 돌릴수 있게 된다.

본 프로젝트에서는 ts-jest 는 내부적으로 js 의 es6 문법을 컴파일 하기 위해 babel loader 를 사용하기 때문에 다음과 같이 babel env 를 설치하고 babel configuration 을 다음과 같이 해주어야 한다.

1
npm install --save-dev babel-preset-env

.babelrc

1
2
3
{
presets:["env"]
}

Setup jest configuration

Jest 세팅은 tsconfig.json 에서도 할 수 있고 jest.config.js 파일에서도 할 수 있다.

이 경우 반드시 한쪽에서만 해야 문제가 발생하지 않기때문에 유념한다.

Jest 는 typescript 를 컴파일 하지 않고 파일별로 컴파일과 실행을 동시에 처리하기 때문에, module alias 를 사용하였다면, 해당 파일이 어디에 있는지를 가르쳐 주어야 한다.

아래 코드에서는 moduleNameMapper 를 통해 module alias 를 발견하면 어디에서 해당 파일을 찾을 수 있는지 jest 에게 가르쳐 준다.

또한 ts-jest preset 를 사용한다는 것을 명시하고 있다.

jest.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: {
'^@domain/(.*)$': '<rootDir>/src/server/domain/$1',
'^@common/(.*)$': '<rootDir>/src/server/common/$1',
'^@utils/(.*)$': '<rootDir>/src/server/utils/$1',
'^@infra/(.*)$': '<rootDir>/src/server/infra/$1',
'^@api/(.*)$': '<rootDir>/src/server/api/$1',
'^@interfaces/(.*)$': '<rootDir/src/server/interfaces/$1',
'^@root/(.*)$': '<rootDir>/src/server/$1'
},
transform: {
'^.+\\.jsx?$': 'babel-jest',
'^.+\\.(ts|tsx)$': 'ts-jest'
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'node', 'json'],
testMatch: ['**/*.test.+(ts|tsx|js)']
};

여기서 moduleFileExtensions의 경우 typescript 테스트만을 진행하는 경우에는 tx, tsx, js 만 명시해주어도 되지만, 기본적인 node 가 제공하는 확장자인 json, node 등도 추가하여야 여러 패키지를 사용함에 있어 문제가 없다.

Getting started

how to setup express application

Supertest 는 express application 객체를 받아서 내부적으로 서버를 listen 하기 때문에 supertest를 할 때마다 이런 express application을 주입시켜 주어야 한다. 어찌보면 단순한 일이지만 사실 실제 서버의 경우 express application 을 초기화하는 일련의 과정이 존재하기 때문에 이 작업을 해주는 것이 다소 까다로울 수 있다.

또한, 대부분의 REST API 테스트의 경우 인증된 사용자에 대해서만 요청을 처리하는 authentication 기능이 있기 때문에, 테스트를 진행하기 전에 jwt 토큰 발급 및 회원가입 등 authentication 관련 문제들을 해결해야 한다.

때문에, 본 문서에서는 이런 기본적인 express application initializing 과 authentication 을 도와주는 helper 클래스인 TestHelper 클래스를 먼저 구현하여 supertest 를 하도록 한다.

app.ts

1
2
3
4
5
6
7
8
class App {
public async setup(): Promise<express.Express> {
let app = express();
app.use();

return app;
}
}

testHelper.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class TestHelper {
/**
* 테스트에 필요한 유저를 만들어 주는 함수이다.
*
* @param id 유저의 아이디
* @param password 유저의 비밀번호
*/
public async generateTestUser(
id: string,
password: string
): Promise<User> {
/**
* 필자는 일련의 회원가입을 담당하는 userService 에서
* 회원가입을 처리하고 있다.
* 이 부분은 독자가 임의적으로 회원가입 로직을 구현하면 된다.
*/
let user = await userService.createUser(
id,
password
);

return user;
}

/**
*
* @param id 유저의 아이디
*/
public async generateJwt(id) {
let jwtPayload = new JwtPayload(id);
return encodeJwt(jwtPayload);
}

public async getApp(): Promise<express.Express> {
let APP = new App();
let app = await APP.setup();

return app;
}
}

export const testHelper = new TestHelper();

위처럼 express appilcation 을 비동기적으로 세팅해주는 helper 함수들을 구현하였다면 아래와 같이 모든 테스트 코드를 작성하기 전에 위의 app을 초기화 해준다.

모든 테스트 이전에 다양한 환경을 세팅해주기 위해 beforeAll() 를 이용하는데, 이는 모든 테스트 전에 해야할 일들을 명시하고 세팅해 준다. 만약, 비동기적인 작업을 beforeAll 에서 해야 할 필요가 있다면 다음과 같은 방법이 있다.

  1. Promise 를 return 해주는 방법
  2. Async/Await 을 사용하는 방법
  3. Done 인자를 받아 사용하는 방법

아래 코드에서before all 부분에서 바로 promise 를 리턴하는 방식과 Async/Await 방식을 보여주고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
describe('return promise',()=>{
let app;

beforeAll(()=>{
return testHelper.getApp().then(_app=>{
app = _app;
})
})

describe('POST: /users',()=>{
let res = await request(app).post('api/v1/users');

})
})
describe('example test',()=>{
let app;

beforeAll(async()=>{
app = await testHelper.getApp()
})

describe('POST: /users',async()=>{
let res = await request(app).post('api/v1/users');
})
})

이렇게 테스트를 돌리게 되면, 테스트는 끝이 났는데 프로세스는 계속해서 동작하고 있다는 에러를 볼 수 있다.

이는 데이터베이스 등처럼 connection 객체가 살아있는 경우 해당 프로세스가 종료되지 않기 때문인데, 이 때문에 다음과 같이 테스트가 끝난 이후에 application 내의 데이터베이스 커넥션을 끊어주면 해당 경고가 없어진다.

1
2
3
4
5
6
7
8
9
10
11
12
describe('',()=>{
let app;

beforeAll(async()=>{
app = await testHelper.getApp()
})

afterAll(async()=>{
app.closeApp()
})

})

여러 파일에서의 beforeAll 작업

위처럼 모든 테스트파일에서 setup 을 진행하면, 각 setup 은 jest 의 특성상 비동기적으로 동작하게 되기 때문에, 만약 db 등을 조작한다면 confict 이 날 수 있다.

즉, 만약 모든 테스트를 통틀어 한번 데이터를 세팅하고자 한다면 다음과 같이 별도의 setup.js 파일을 만들어 전체 테스트 실행 전에 돌려주는 것이 좋다.

setup.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var mysql = require('mysql');
console.log(mysql);
var connection = mysql.createConnection({
host: 'localhost',
user: 'root',
password: 'test1234',
database: 'test'
});

connection.connect();

connection.query('DELETE FROM user;', function(error, results, fields) {
if (error) throw error;
});
connection.end();

위 스크립트 파일을 시작하도록 package.json 에 등록해준다.

1
2
3
4
5
{
"scripts":{
"test":"node ./setup.js && jest"
}
}
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×