통합 테스트(Integration Test) 관련 정보를 구글링 하다가 향로님께서 작성하신 '테스트하기 좋은 코드 - 테스트하기 어려운 코드'라는 글을 읽었다. 향로님은 단위 테스트(Unit Test)에 대해서 글을 작성해주시긴 했지만, 전반적인 테스트라는 과정을 생각해봤을 때, 엔지니어로써 가져야 하는 마인드셋인 것 같아서 적어본다.
Q. 테스트는 구현의 보조적인 수단인데, 테스트를 위해서 원본 코드의 구현과 설계를 고치는 게 맞는 건가?
A. 테스트 코드는 구현의 보조적인 수단이 아니며, 같은 레벨로 봐야 한다. 오히려 구현 설계 smell를 맡게 해주는 좋은 수단이다. 좋은 디자인으로 구현된 코드는 대부분 테스트하기 쉽다.
즉 테스트 코드를 작성하는 과정은 서비스를 프로그래밍하는 관점에서 반드시 필요한 영역이고, 구현하는 코드만큼 중요하다는 마인드를 가져야 한다고 생각한다. 실제로 코드를 업그레이드하거나 리팩토링 이후 버그가 발생할 수 있고, 혹은 사용자가 서비스를 운영하는 도중에 에러가 발생할 수도 있다. 제대로 된 테스트 코드를 구상 및 설계하고 작성하였다면 예방할 수 있는 문제들이 많을 것이다.
하지만 테스트 코드를 작성하는 과정이 절.대. 쉽지 않다. 물론 나 같은 경우 개발을 시작한 지 얼마 안 된 얼라 개발자이기 때문에 더욱 어려움을 느끼는 것도 있겠지만, 실제 내가 테스트 코드를 작성했던 경험에 의하면, 빨리 로직을 구현하고 완성시키고 싶은 조급함과 발생할 수 있는 에러들에 대한 예외 처리 상황들을 미리 가정하고 구상하는 순간의 막막함 때문에 매우 고통스러웠다. 이런 상황이다 보니 TDD를 통해 얻을 수 있는 이점보다는 그저 껍데기에 불과한 형식적인 테스트 코드를 작성하게 되기도 했었다.
아모턴, 서비스의 고도화가 진행되고 스케일이 확장된다면 이와 비례하게 코드의 양도 방대해질 것이고, 그에 따른 유지보수 난이도도 매우 높아지게 될 것이다. 즉 장기적인 관점에서 봤을 때 테스트 코드를 작성하는 것은 레거시 코드를 개선하고 디버깅하는데 있어서 효율적인 지원을 해주는 중요한 프로세스일 것이라고 본다. 또한 개발자 개인적인 관점에서 봤을 때에도 테스트 코드를 작성하면서 테스트를 쉽게 하기 위해 좋은 디자인으로 구현된 코드를 작성하려고 노력할 것이고, 그로 인한 성장을 할 수 있을 것이라고 생각한다.
그래서 지금 이렇게 테스트 코드에 대해 횡설수설하는 이유는 현재 내가 해야 하는 업무가 테스트 코드를 작성하는 것이기 때문이다..! 최근에 서비스를 업그레이드하는 과정에서 버그가 발생했고, 이런 상황이 재발하기 않기 위해 기존에 작성한 코드에 대한 테스트 코드를 작성하기로 한 것이다(물론 선임 개발자분들께서 작성하셨던 테스트 코드들이 있어서 이를 레퍼런스 삼아 내가 맡은 도메인에 테스트 코드를 작성하려고 하고 있음).
테스트 코드 작성 전에 끈 짧은 내 지식을 보강하기 위해 node 테스트 코드 작성에 대한 학습도 계속 하고 있고, 그 과정에서 테스트 코드를 작성하는 데 있어서 다양한 라이브러리들이 사용되는 것을 봤다. 나는 인프런 강의를 통해 supertest를 사용하여 통합 테스트를 했었는데, 이외에도 chai-http, axiosist 같은 테스트용 모듈들이 있고 axios나 superagent 같은 http 통신 라이브러리로도 테스트를 한다는 것을 알게 되었다(우리 회사도 axios와 chai를 통해 통합 테스트 진행. 테스팅 프레임워크는 mocha 사용).
현재 테스트 코드를 작성한 방식에 맞게 axios + chai로 테스트 코드를 작성할수도 있지만, 각 라이브러리들이 어떻게 다른지 궁금해서 간단하게나마 분석, 비교해보려고 한다.
라이브러리 트렌드 비교
우선, 각 라이브러리의 트렌드를 비교하면 다음과 같다.
우선 axios가 압도적으로 높은 것은 당연하다. axios는 브라우저, node.js에서 많이 사용되는 promise 기반의 HTTP 비동기 통신 라이브러리이기 때문이다. axios를 제외하고 테스트용 라이브러리 중에서 가장 인기 있는 것은 supertest다. 물론 가장 많이 사용되는 것이 제일 좋다는 것은 아니지만, 많은 사용자가 있는 만큼 커뮤니티의 규모가 크고, 이를 통해 패키지가 얼마나 잘 유지되는지를 유추할 수 있다는 점에서 쉽게 무시할 수는 없다고 생각한다.
일단 axios는 비교에서 제외시키고 supertest, chai-http, axiosist가 어떻게 다른지 한 번 비교해보자.
supertest
supertest는 superagent를 기반으로 한 HTTP 검증 라이브러리다.
superagent는 axios와 같은 비동기 기반 http 통신 라이브러리다. 경량으로 만들어졌고, 다양한 플러그인 생태계가 있다는 장점이 있다고 하지만, 다른 통신 라이브러리에 비해 유저풀이 크지는 않다. 일단 이 라이브러리에 대한 정보는 논외로!
supertest의 인터페이스는 노드의 http.Server 객체나 함수를 인자로 받는 형태다. supertest의 github README에 적힌 예시를 보자.
const request = require('supertest');
const assert = require('assert');
const express = require('express');
const app = express();
app.get('/user', function(req, res) {
res.status(200).json({ name: 'john' });
});
request(app)
.get('/user')
.expect('Content-Type', /json/)
.expect('Content-Length', '15')
.expect(200)
.end(function(err, res) {
if (err) throw err;
});
request 변수에 supertest 모듈을 넣고, 익스프레스 모듈을 넣은 app 변수를 request() 안에 넣었다. 그 이후 GET /user 라는 http 요청을 만들고 expect()메소드를 통해 응답을 검증한다.
(만약 헤더를 보내려면 set() 함수를 사용하면 되고, 리스폰스 본문을 보내려면 send()함수를 사용하면 된다!)
chai-http
chai-http는 http 관련 기능을 chai를 이용해서 검증할 수 있도록 하는 라이브러리다. 즉 노드, 브라우저에서 검증하는 데 사용되는 라이브러리인 chai에서 http API 테스트를 하기 위해 확장한 모듈이다. 어떠한 api를 테스트하는 예시는 다음과 같다.
const chai = require("chai");
const chaiHttp = require("chai-http");
const server = require("../src/rest-api");
const expect = chai.expect;
// Http 관련 기능을 chai 와 연동해서 사용하려면 반드시 필요한 코드이다.
chai.use(chaiHttp);
...
describe("Tasks API", function () {
...
// * GET by id
describe("GET /api/tasks/:id", function () {
it("should GET a task by id", function (done) {
const taskId = 2;
chai
.request(server)
.get(`/api/tasks/${taskId}`)
.end(function (error, response) {
expect(response).to.have.status(200);
expect(response.body).to.be.a("object");
expect(response.body).to.have.property("id");
expect(response.body).to.have.property("name");
expect(response.body).to.have.property("completed");
expect(response.body).to.have.property("id").to.be.eq(2);
done();
});
});
it("faild to GET a task by id", function (done) {
const taskId = 0;
chai
.request(server)
.get(`/api/tasks/${taskId}`)
.end(function (error, response) {
expect(response).to.have.status(404);
expect(response.text).to.be.eq(
"The task with the provided ID does not exist."
);
done();
});
});
});
});
즉 chai에 chai-http 미들웨어를 추가하고, chai의 request()함수를 이용하여 서버를 구동시키는 변수를 실행시키고, 그 이후 테스트할 함수들을 작성한다.
axiosist
axiost는 axiost 기반의 supertest 라이브러리다. 기존의 supertest는 superagent 기반인 것과 비교되는 부분이다. github에 명시된 사용 예시는 다음과 같다.
// App
const express = require('express')
const app = express()
app.get('/', (req, res) => res.status(201).send('foo'))
// Unit test in async function
const assert = require('assert')
const axiosist = require('axiosist')
void (async () => {
const response = await axiosist(app).get('/')
assert.strictEqual(201, response.statusCode)
assert.strictEqual('foo', response.data)
}) ()
문법적으로는 supertest보다 axios와 비슷해보인다. 여하튼 이 라이브러리 등장 배경으로는 기존의 supertest(based on superagent)는 콜백 함수를 기반으로 되어 있고 stream mode, promise mode가 다 호환되지만 두 모드를 동시에 사용하는 것은 불가능하다고 한다. 반면에 axios는 promise 기반이기 때문에 여러 모드를 동시에 사용하는 것이 더 쉽다고 한다.
(자세한 것은 github 페이지 참조: https://github.com/Gerhut/axiosist)
일단 세 개의 라이브러리를 살짝 엿 본 소감으로는, 그게 그거다...! 이다. 내 수준이 아직 미천하다 보니 보고 느끼는 것이 크지 않은 거겠지.. 뭐가 됐든 도구는 도구이고, 결국 좋은 코드를 작성하는 것은 이런 기능들을 얼마나 잘 활용해서 만드냐에 따라 달라질 것 같다.
다만 만약 저 세 개의 라이브러리 중 하나를 선택해서 사용해야 한다면 나는 supertest를 사용하겠다. 그 이유로 셋 중에서 가장 많은 커뮤니티를 보유하고 있어서 장애물을 마주칠 때 찾을 수 있는 정보도 많을 것이고, 나 또한 슈퍼테스트를 사용한 경험이 있기 때문이다. axiosist에도 흥미가 가긴 하지만 현재 내가 작성하는 코드의 수준에서 이 라이브러리가 가지는 장점을 잘 활용하지 못할 것 같기도 하고, 가장 최근 릴리즈 된 날이 작년 11월인 것을 보면 실제 코드를 짜는 것보다 모듈 사용간 발생하는 문제를 해결하는 데 더 많은 시간이 소요될 것 같아서 사용하지 않기로 했다.
그럼, supertest vs axios( + chai)를 비교하면 어떨까?
supertest vs axios( + chai)
앞서 보여준 것 처럼, 슈퍼테스트는 axios나 superagent같은 http 통신 라이브러리에 테스트할 수 있는 기능들을 확장한 라이브러리이기 때문에 테스트를 실행할 때 코드 작성에서 더 편리한 점들이 있을 것이다. 그렇다면 어떤 부분에서 편의성이 있는지 알아보려고 한다.
assertion method: expect()
만약 axios로 테스트를 진행하게 된다면, 검증을 위한 별도의 함수를 사용해야 한다. node 이용자 중에서 가장 많이 사용되는 것은 chai다. TDD 강의를 들을 때 강사님께서도 테스트를 하는 데 있어서 node에 기본적으로 내장되어 있는 assert 라이브러리를 사용하지 말고, 서드파티 라이브러리를 사용하라고 하셨다. node의 공식문서에도 이런 글이 있다.
The module is intended for internal use by Node.js, but can be used in application code via require('assert'). However, assert is not a testing framework, and is not intended to be used as a general purpose assertion library.
즉 assert 라이브러리는 어떤 함수의 조건이나 유효성을 검증하는데 사용되지만 테스트 자체를 위한 목적으로 개발되지 않았다는 것이다.
다시 돌아와서, axios로 테스트를 진행하게 된다면 이런 검증하기 위한 라이브러리를 사용해야 하고, 일반적으로 chai를 사용한다. chai 라이브러리 내에 should, expect, assert 등의 다양한 인터페이스를 제공하기 때문에 개발자 입장에서 자신이 선호하는 것을 선택하여 편리하게 사용할 수 있다. 우리 회사는 expect를 사용하여 테스트 코드를 작성했었고, expect가 체이닝을 이용하여 하나의 영어 문장으로 코드를 작성할 수 있다는 것이 직관적이어서 이 인터페이스를 사용할 것이다.
▶︎ should vs expect vs assert?
궁극적으로, chai에서 제공하는 이 세 개의 인터페이스는 모두 같은 역할을 수행한다. 개발자별로 자신의 기술적 고려 사항을 통해 선호하는 것을 선택하면 된다고 한다. 공식 문서에서 expect와 should의 차이에 대해 명시되어 있다. 나중에 제대로 이해해보자...!
axios와 chai의 expect를 사용할 시 다음과 같은 코드를 작성할 수 있다.
axios(whereAppIs + "/endpoint")
.then((res) => {
expect(res.statusCode).toBe(200);
});
http를 요청하는 url을 적고, 그 이후 expect()를 통해 상태 코드가 200인지 테스트하는 건데, 이를 supertest로 작성한다면 다음과 같다.
request(app)
.get("/endpoint")
.expect(200);
여기에 있는 expect는 chai의 expect가 아니라, supertest 내에 내장되어 있는 메소드다. 즉 슈퍼테스트 내에 응답을 검증하는 expect()가 있어서 보다 더 편리하게 코드를 작성할 수 있다.
그런데 여기서 헷갈리는 점은, 그럼 supertest의 expect() 메소드가 chai를 대체할 수 있다는거야? 인데... 결국 로직을 검증하는 코드를 작성할 때에는 역시 chai를 사용해야 하는 것 같다. 이와 관련된 레퍼런스를 찾아봤다: https://stackoverflow.com/questions/31277456/supertests-expect-vs-chai-expect
내가 이해한 바로는 supertest의 expect는 실행되는 서버에게 받는 응답과 관련된 http 검증을 하는 것이고, 그 내부의 데이터들에 대한 검증은 결국 chai같은 검증 라이브러리를 사용해야 하는 것 같다. 위의 레퍼런스에서는 이 둘을 혼합하여 사용하는 사례를 보여준다.
request(app).
get('/').
expect(200). // request.expect, is status code 200?
expect('Content-Type', /json/). // request.expect, does content-type match regex /json/?
expect(function(res){ // request.expect, does this user-provided function throw?
// user-provided function can include Chai assertions
expect(res.body).to.exist;
expect(res.body).to.have.property('status');
}).
end(done);
서버 연결 상태: 임시 포트 오픈
axios로 테스트하는 경우, 당연하게 서버가 연결되어 있어야 한다. 서버 객체에 포트가 연결되어 클라이언트로부터 데이터를 받을 수 있는 상태가 되어 있어야 테스트가 가능하다. 반면에 supertest 경우 테스트용 라이브러리답게 서버가 아직 연결을 수신하지 않는 경우에 임시 포트를 열어서 서버 요청 대기 상태로 전환한다. 즉 axios로 테스트하는 경우 서버가 돌아가는 상황에서만 테스트가 가능하지만, supertest는 서버를 켜지 않은 상황에서 테스트가 가능하다.
요약하자면, 확실히 테스트를 목적으로 만들어진 라이브러리인 supertest가 사용하는데 있어서 더 편리해 보인다. axios와 비교했을 때 장점으로
- supertest 모듈 내에서 제공하는 검증 메소드들이 있기 때문에 보다 더 간편하게 코드를 작성할 수 있다.
- 서버를 run하지 않은 상황에서도 바로 테스트를 돌릴 수 있다.
정도일 것 같다(물론 세세하게 더 찾아본다면 더 많은 이점들이 있을 것이다). 하지만 현재 내 상황에서 슈퍼테스트를 적용하는 데 있어서 고려해야 할 부분들이 있다.
- 기존 테스트 코드들은 axios + chai로 작성되어 있고, 팀원들은 supertest에 대해 잘 모른다. 어렵지 않은 라이브러리이다 보니 다들 쉽게 배울 수는 있겠지만, 슈퍼테스트를 익히고 적응하는 소요 시간에 비해 과연 그만큼 많은 이점을 얻을 수 있을까?
- 아직 제대로 코딩해보지 않아서 잘 모르겠지만, 기존 방식에서 supertest로 전환하면서 혼란스러운 부분들은(예를 들면 expect 같은 동명의 메소드)? 이를 줄이기 위해 chai의 expect 대신 should를 사용할 수도 있겠지만, 이 부분은 더 학습이 필요할 것 같음
- 서버를 키지 않은 상태에서 테스트를 돌릴 수 있다는 것이 우리 서비스를 통합 테스트하는 데 있어서 큰 메리트일까? 나 같은 경우 로컬로 dev 서버를 띄우고 직접 브라우저를 보면서 테스트를 하기 때문에 이 부분이 큰 장점으로 다가오지 않는다.
사실 어떤 것을 선택하든, 난이도면에 있어서는 큰 차이는 나지 않는 것 같다. 이 부분은 팀원들과 의견을 공유해봐야겠다.
'개발자 도전기 > [STUDY] TEST CODE' 카테고리의 다른 글
TDD | node.js | 사용자 조회, 삭제, 추가, 수정 API 테스트 (0) | 2022.05.24 |
---|---|
TDD | node.js | 사용자 목록 조회 API 테스트 코드 만들기 (0) | 2022.05.23 |
TDD | 테스트 주도 개발? (0) | 2022.05.09 |
댓글