...
Supertest
이때까지는 JEST를 이용해 메서드 레벨의 단위 테스트를 다루었다.
단위 테스트란, 가장 작은 단위를 테스트한다는 것으로 즉각적인 피드백이 나온다는 장점이 있다.
하지만, 하나의 메서드가 잘 동작하는 것은 보장할 수 있지만, 그들이 결합되었을 때도 잘 작동한다는 보장 할 수 없다.
반면, 통합 테스트란, API의 기능을 테스트 하는 것으로, 여러 개(외부 라이브러리도 포함)를 통합해서 테스트하는 것을 말한다.
슈퍼테스트는 ExpressJS 통합 테스트용 라이브러리로 내부적으로 익스프레스 서버를 구동시켜 가상의 요청을 보낸 뒤 결과를 검증한다.
API 서버를 만들고 HTTP 검증 도구로 슈퍼테스트(supertest)를 자주 사용한다고 보면 된다.
설치
테스트 모듈이기 때문에, development로 설치한다.
> npm i -D supertest
supertest 주요 메서드
request() : 가상의 서버를 실행하고 api 요청
슈퍼 테스트의 인터페이스는 노드의 http.Server 객체나 함수를 인자로 받는 형태이다.
인자로 받은 서버 객체가 요청 대기 상태가 아니라면 슈퍼 테스트는 임시로 포트를 열어 서버를 요청 대기 상태로 전환해 준다.
const request = require("supertest") // 1번
const express = require("express")
// 라우터
const app = express()
app.get("/user", (req, res) => res.json({ name: "alice" }))
request(app) // 2번
.get("/user") // 3번
.expect(200, { name: "alice" }) // 4번
- 슈퍼 테스트 모듈을 가져와서 request 변수에 담는다.
그리고 구동시킬 서버 프레임워크 객체를 가져온다. 모두들 사용하는 express객체를 가져오고 라우터를 생성한다. - express객체를 request() 함수에 넣어 자체적으로 가상의 서버 어플리케이션을 구동한다.
- get() 함수로 HTTP 요청을 만든다.
물론 메소드에 따라 post(), put(), delete() 함수를 사용할 수 있다.
헤더를 보내려면 set() 함수를 사용한다.
요청 본문을 보내려면 send() 함수를 사용한다. - 마지막 expect() 함수로 응답을 검증하는데, 여기서는 상태 코드와 JSON 형식의 본문을 테스트 한다.
- request(app).get("/user") 을 통해 가상으로 서버에 GET /user 요청을 보낸다.
- 그러면 위의 라우터 app.get("/user", (req, res) => ... )) 에서 요청을 받게 된다.
- 요청을 받은 라우터는 res.json({ name: "alice" }) 을 답신으로 응답하게 된다. (익스프레스는 응답할때 자동으로 statusCode를 포함해서 보낸다)
- 그렇게 응답받은 데이터와 expect(비교) 하여 200코드와 { name: "alice" } 가 올바른지 검증한다
expect() 함수는 다양한 인터페이스가 있어서 상황에 맞게 활용할 수 있는데 주로 expect(statusCode, body) 형태로 사용한다.
JEST의 expect()와 유사하다고 보면 되지만, JEST와는 달리 .toXXxx() 같은 Matcher가 없고, 체이닝 형태로 결과를 검증한다.
보통 우리는 익스프레스 listen을 통해 포트를 열어줘서 서버를 구동시켜 주었다.
하지만, supertest는 자체적으로 따로 서버 구동해서 가상의 요청을 보내기 때문에, app에서 직접 서버를 구동 시킬 필요가 없고 구동 시키면 안된다. (app.js를 모듈로 분리하여 관리)
왜냐하면 우리는 테스트를 하는 것이기 때문에 실제 서버를 구동시키면 안되기 때문이다.
agent() : 가상의 사용자를 두어 실제로 서비스를 사용하는 것 과 같이 요청 상태를 지속
보통 로그인 방식은, 세션과 쿠키를 이용한 인증 방식을 사용하곤 한다.
로그인을 통해 인증되면 서버에서는 세션 아이디를 만들고 세션 쿠키를 클라이언트에 보낸다.
브라우져는 요청한 응답 헤더에 set-cookie가 있으면 쿠키에 값을 저장하고 있다가 다음 요청부터는 이 값을 전달한다.
그리고 이후 브라우저에서 서버로 보내는 모든 요청에서는 쿠키 값을 같이 전송하는 구조다.
따라서 로그인 api 테스트를 하기 위해선, 당연히 로그인 인증을 통해 쿠키 생성 및 유지를 선행해야만 한다.
그러나 테스트 코드에서 인증을 완료한 뒤 다음 요청을 보내면 인증이 안되어있는 현상이 발생하는데,
슈퍼테스트의 request() 로 요청할때는 매번 모든 요청이 새롭게 생성되기 때문이다.
그래서 슈퍼테스트에는 브라우저가 쿠키 값을 기본으로 보내는 것처럼 비슷한 행동을 할 수 있는 메소드를 지원 하는데 agent() 라는 함수가 그것이다.
request() 와 비슷하지만 요청을 지속시킬수 있다는 것이 차이점이다.
쿠키 기반의 인증을 사용하는 것처럼, 어떠한 요청을 지속시킬 필요성이 있을 경우 agent() 함수로 테스트 하자.
const request = require('supertest');
const agent = request.agent(app);
aganet
.post('/login') // post 요청
.send(credentials) // 인증값 전송
.get('/user') // 로그인 되었는지 get 요청
.expect( '...' ); // 검증
// request()로하면 한번 요청보내고 끝(로그인이 풀림) 이지만,
// agent는 로그인 상태를 유지시켜, 다음 로그인 테스트를 할때 이용된다.
supertest 환경 설정 하기
테스트 appjs 모듈 분리하기
supertest를 사용하기 위해 app 객체를 모듈로 만들어 분리한다.
app.js : 서버 라우팅 처리 역할
...
app.use((err, req, res, next) => {
res.locals.message = err.message;
res.locals.error = process.env.NODE_ENV !== 'production' ? err : {};
res.status(err.status || 500);
res.render('error');
});
module.exports = app; // app.js를 모듈로 만들어 외부로 꺼낸다.
server.js : 실제 서버 구동 역할
const app = require('./app');
// listen to port
app.listen(app.get('port'), () => {
console.log(app.get('port'), '번 포트에서 대기 중');
})
app.js 파일에서 app 객체를 모듈로 만든 후, server.js에서 불러와 listen한다.
따라서, server.js는 app의 포트 리스닝만 담당하게 된다.
이렇게 기존의 app.js에서 서버 listen을 안하고 굳이 분리한 이유는, api 테스트를 하는데 실제 서버가 구동되어 버리면 안되기 때문이다.
후술할 supertest 코드에서는 app.js를 모듈로 받아 request(app).post().XXX 이런식으로 가상의 서버 요청을 보내 테스트 할 것인데, 만일 app.js 안에 listen메소드가 있으면 진짜 서버가 구동되어버리기 때문이다.
실제 서버 실행의 주체(app.js -> server.js)가 바뀌었으니 당연히 package.json의 start 커맨드도 "start": "nodemon server" 로 같이 바꿔준다.
테스트용 데이터베이스 설정
테스트용 데이터베이스도 추가한다.
통합 테스트에서는 데이터베이스 코드를 모킹하지 않으므로, 데이터베이스에 실제로 테스트용 데이터가 저장된다.
그런데 실제 서비스 중인 데이터베이스에 테스트용 데이터가 들어가면 안되므로, 테스트용 데이터베이스를 따로 만드는 것이 좋다.
config.json을 처음 만들때,
자동으로 development / test / production 으로 구성되어져 있는데, 다 이유가 있는 것이었다
[config.json에 설정한 데이터베이스를 생성하는 명령어]
> npx sequelize db:create --env test
통합테스트
이번 포스팅에선 하나의 라우터를 통째로 테스트해볼 예정이다.
하나의 라우터에는 여러 개의 미들웨어가 붙어 있고, 다양한 라이브러리가 사용된다.
이런 것들이 모두 유기적으로 잘 작동하는지 테스트하는 것을 통합 테스트(integration test) 라고 한다.
로그인 인증 관련 미들웨어를 테스트 해보자.
routes/auth.js : 테스트할 함수
const router = express.Router();
//* 회원 가입
router.post('/join', isNotLoggedIn, async (req, res, next) => {
const { email, nick, password } = req.body; // 프론트에서 보낸 폼데이터를 받는다.
try {
const exUser = await User.findOne({ where: { email } });
if (exUser) {
return res.redirect('/join?error=exist'); // 에러페이지로 바로 리다이렉트
}
const hash = await bcrypt.hash(password, 12);
await User.create({
email,
nick,
password: hash, // 비밀번호에 해시문자를 넣어준다.
});
return res.redirect('/');
} catch (error) {
console.error(error);
return next(error);
}
});
//* 로그인 요청
router.post('/login', isNotLoggedIn, (req, res, next) => {
passport.authenticate('local', (authError, user, info) => {
if (authError) {
console.error(authError);
return next(authError); // 에러처리 미들웨어로 보낸다.
}
if (!user) {
return res.redirect(`/?loginError=${info.message}`);
}
return req.login(user, loginError => {
if (loginError) {
console.error(loginError);
return next(loginError);
}
return res.redirect('/');
});
})(req, res, next);
});
//* 로그아웃 (isLoggedIn 상태일 경우)
router.get('/logout', isLoggedIn, (req, res) => {
req.logout();
req.session.destroy();
res.redirect('/');
});
routes/auth.test.js
const request = require('supertest');
const { sequelize } = require('../models');
const app = require('../app');
//* 테스트 하기전 앞서 실행. 준비작업
beforeAll(async () => {
await sequelize.sync(); // 가짜 ORM 생성
});
//* 가입 테스트
describe('POST /join', () => {
test('로그인 안 했으면 가입', done => {
request(app) // supertest에 app.js를 넣어줘서 미들웨어 실행되는 효과를 모킹한다.
.post('/auth/join') // 클라이언트에서 post 라우팅 한 효과
.send({
// post 데이터
email: 'test@example.com',
nick: 'test',
password: '123123',
})
//? auth.js에서 가입 성공하면 return res.redirect('/'); 와 비슷하게 설정
.expect('Location', '/') //? Location은 다음에 올 문자열이 경로라고 알려주는 거다.
.expect(302, done); //~ 비동기 메소드를 처리할때 콜백함수로 처리할경우 반드시 done을 써주어야 한다.
});
});
//* 로그인 중복 테스트
describe('POST /login', () => {
//? agent를 쓰면 상태를 유지시킬수 있어 여러 테스트를 할 수 있다. (로그인 상태를 유지시킨다 거나)
const agent = request.agent(app);
//? beforeEach 는 각 다음의 test()모듈들이 실행될때 이걸 먼저 실행하고 테스트하도록 하는 것이다.
//? beforeAll : test들을 하기전에 이걸 먼저 한번 실행하고 그 이후 쭉 테스트 시작
//? beforeEach : test들 할때마다(Each) 이걸 먼저 실행하고 테스트 하도록
beforeEach(done => {
//^ 테스트 하기전에 우선 agent로 로그인 하고 유지 시킨다.
agent
.post('/auth/login')
.send({
email: 'test@example.com',
password: '123123',
})
.end(done);
});
test('이미 로그인했으면 redirect /에러', done => {
// 위 beforeEach에서 이미 로그인 했는데, 또 로그인 시도를 하면 실패하게 되는 원리를 이용
const message = encodeURIComponent('로그인한 상태입니다.');
agent
.post('/auth/login')
.send({
email: 'test@example.com',
password: '123123',
nick: 'test',
})
.expect('Location', `/?error=${message}`)
.expect(302, done);
});
});
//* 로그인 수행 테스트
describe('POST /login', () => {
test('가입되지 않은 회원', done => {
const message = encodeURIComponent('가입되지 않은 회원입니다.');
request(app)
.post('/auth/login')
.send({
email: 'test@example.com',
password: '123123',
})
.expect('Location', `/?loginError=${message}`)
.expect(302, done);
});
test('로그인 수행', done => {
request(app)
.post('/auth/login')
.send({
email: 'test@example.com',
password: '123123',
})
.expect('Location', '/')
.expect(302, done);
});
test('비밀번호 틀림', done => {
const message = encodeURIComponent('비밀번호가 일치하지 않습니다.');
request(app)
.post('/auth/login')
.send({
email: 'test@example.com',
password: 'wrong',
})
.expect('Location', `/?loginError=${message}`)
.expect(302, done);
});
});
//* 로그아웃 테스트
describe('GET /logout', () => {
//* 로그인 되지 않을 경우 테스트
test('로그인 되어있지 않으면 403', done => {
request(app).get('/auth/logout').expect(403, done); // res.status(403).send('로그인 필요');
});
//* 로그인 시키로 로그아웃 잘 되는지 테스트
const agent = request.agent(app);
beforeEach(done => {
agent
.post('/auth/login')
.send({
email: 'test@example.com',
password: '123123',
})
.end(done);
});
test('로그아웃 수행', done => {
agent.get('/auth/logout').expect('Location', `/`).expect(302, done);
});
});
//* 테스트 후 마무리 작업
//! afterAll을 안했을때는 처음에는 성공했는데 두번째는 실패하는 황당한 현상이 일어날 수 있다.
afterAll(async () => {
// 테이블을 다시 만듬 -> 기존 유저를 초기화
// 왜냐하면 이미 가입된 테스트 계정이 있을경우, 가입테스트 할 경우 충돌나니까 항상 테이블 초기화
await sequelize.sync({ force: true });
});
코드에 beforeAll 이라는 함수가 추가되었다. 이는 현재 테스트를 실행하기 전 수행되는 코드이다.
여기에 sequelize.sync() 를 넣어 데이터베이스에 테이블을 생성한다.
비슷한 함수로
- afterAll (모든 테스트가 끝난 후)
- beforeEach (각각의 테스트 수행 전)
- afterEach (각각의 테스트 수행 후)
가 있다.
테스트를 위한 값이나 외부 환경을 설정할 때 테스트 전후로 수행할 수 있도록 사용하는 함수이다.
supertest 패키지로부터 불러온 request 함수에 app 객체를 인수로 넣어준다.
여기에 get, post, put, patch, delete 등의 메서드로 원하는 라우터에 테스트 HTTP 요청을 보낼 수 있다.
supertest를 사용하면 app.listen의 수행 없이도 서버 라우터를 실행할 수 있다.
따라서, 위에서 app.js의 서버 실행코드 app.listen()을 따로 파일로 나누어 보관한게 그 이유다.
데이터는 send 메서드에 담아서 보낸다. (ajax 요청할때 데이터를 실어서 보내는 그것이다)
그 후, 예상되는 응답의 결과를 체이닝으로 expect 메서드의 인수로 넣어 그 값과 일치하는지 테스트한다.
그리고, done을 두 번째 인수로 넣어 테스트가 마무리되었음을 알려야 한다.
왜냐하면, request(app) 메소드는 비동기 메소드라 done 처리를 해야 하기 때문이다
- 먼저, 첫 번째 describe에서 회원가입을 테스트한다.
- 그 다음 describe에서는 로그인한 상태에서 회원가입을 시도하는 경우를 테스트한다.
이 때, 코드의 순서가 매우 중요하다.
로그인한 상태여야 회원가입을 테스트할 수 있으므로 로그인 요청과 회원가입 요청이 순서대로 이루어져야 한다.
이 때 agent 를 만들어서 하나 이상의 요청에서 재사용할 수 있다.
agent를 쓰면 상태를 유지시킬수 있어, 여러 테스트에 사용 할 수 있다. (로그인 상태를 유지시킨다 거나)
beforeEach는 각각의 테스트 실행에 앞서 먼저 실행되는 부분으로, 회원가입 테스트를 위해 방금 생성한 agent 객체로 로그인을 먼저 수행한다.
이후엔 로그인된 agent로 회원가입 테스트를 진행한다.
로그인한 상태이기 때문에 '로그인한 상태입니다.'라는 메시지와 함께 리다이렉트 되는 동작을 테스트한다.
테스트 코드를 보면 마지막에 afterAll() 로 후처리 작업으로 데이터베이스 데이터 초기화 처리를 해주었는데,
테스트 후처리 작업을 안해주면, 첫번째는 테스트가 성공했는데 두번째는 테스트가 실패하는 현상을 겪을 수 있다.
왜냐하면 이전에 테스트를 통해 이미 데이터베이스에 test@example.com 계정을 생성해놓았기 때문에,
이와 같이 테스트 후에 데이터베이스에 데이터가 남아 있다면 다음 테스트에 영향을 미칠 수가 있기 때문이다.
이와 같은 방식으로 다른 라우터도 통합 테스트를 진행하면 된다.
다른 라우터 중에서도 로그인을 해야 접근할 수 있는 라우터가 있을 것이다.
그럴 때는 마찬가지로 beforeEach로 미리 로그인한 agent를 만들어주면 된다.
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.