TDD(test driven development)

100793 단어 TDDnode jsTDD

4. TDD

TDD란 Test Driven Development의 두문자어로 테스트코드를 작성한 후 개발코드를 작성하는 개발방식을 일컷습니다.

왜 사용할까?

저는 코드로부터 예상 혹은 기대되는 내용을 테스트코드에 규약처럼 작성하여 개발코드를 작성하는 과정이 규약에 어긋나지 않도록 함과 동시에 각 라우터 마다의 api 호출을 통한 테스트 과정을 생략하기 위해 사용하는 것으로 받아들였습니다.

주의해야할 점

테스트코드가 갑이 되어야 한다는 것입니다. 반대로 개발 코드는 테스트코드의 니즈를 충족시키는 을의 역할이 되어야 하는데, 개발자의 변심, 상황의 변화에 따라 개발의 방향성과 내용이 변경된다면 테스트코드 또한 수시로 변경되어야 하며 이는 TDD의 취지와 맞지 않습니다. 개발 내용이 주기적으로 바뀐다면, 개발 - 테스트 코드를 분리하여 작업하는 것은 이상속의 얘기가 될 것은 분명합니다.

코드를 설명하기 앞서

몇가지 개념을 되새기고 넘어가고 싶습니다.

4.1. mocha test - should

mocha테스트는 describe , it 매서드로 구성되어 있는데, 각

describe : 테스트 환경을 제공하는 테스트수트 이다. it : 실제 테스트를 진행하는 테스트케이스 이다.

npm i mocha

를 통해 패키지 제이슨 파일에 해당 모듈을 추가해 준 후,

const request = '검사하고 싶은 파일의 경로'
describe('~~일 때', () => {
    it('~~이다', done => {
        // 검사내용
	});		
});

검사를 진행합니다. 이 때 검사를 위해 사용되는 모듈이 크게 두 가지, assert 와 should가 있습니다.

~는 ~여야 해, ~는 ~로 예상돼 등의 내용을 서술 하려면 assert(주장)이나 should(확신)이 있어야 합니다.

const should = require('should');
const assert = require('asert');
const { a, b } = require('./test'); // 변수 a, b를 받아온다.

describe('~~일 때', () => {
		it('~~이다', done => {
			// assert		
			assert.equal(a,b)	
			// should
			a.should.be.equal(b);
	});		
});

테스트 코드 작성에서는 웬만하면 should를 사용하는 것이 좋다고 하는데 이유는 모르네요 ㅋㅋ

4.2. mocha test - supertest

위의 코드를 보면 a와 b가 일치하는지의 여부, 즉 단일한 조건에대한 테스트를 진행하였는데 결국 제가 하고자 하는 테스트는 클라이언트 요청에 따른 서버 api의 응답값 예측, 예상 이기 때문에 이러한 단일 테스트 이상의 것이 필요로 합니다. 이를 해결해 주는 것이 바로 supertest 모듈입니다.

const request = require('supertest');
const app = require('express객체 app을 exports하는 파일의 경로') // api테스트가 필요한 파일 경로

describe('~~이면', () => {
	it('~~이다', done => {
			request(app)
					.method1()
					.method2()
					.method3(done)
	});
});

사용법은 위와 같으며 테스트하고자 하는 api가 들어있는 (보통 app.js)를 슈퍼테스트의 인자로 넣어줍니다.

4.2.1. mocha test 동기처리(테스트 케이스 콜백함수)

앞서 mocha test의 테스트 케이스 작성 시 it 매서드의 콜백함수로 done 함수를 호출한 적이 있습니다. supertest 객체 생성은 내부적으로 express 서버를 구동하는 무거운 일로, 비동기로 진행되는 mocha test에서 필해 동기처리가 필요합니다. 이의 처리 방법으로 크게 세가지를 들어보면

  • done

.end() 매서드 호출 시 별도 then - catch매서드의 사용이 필요 없다.

  • async / await

promise의 상태가 변경될 때 까지(resolve 혹은 reject 될 때 까지) 기다리는 await 선언과 그의 사용을 위한 async 선언

  • return

mocha테스트는 리턴되는 모든 promise를 기다려준다.

supertest는 내부적으로 Promise를 생성한 후 resolve 또는 reject하기 때문에 해당 결과를 처리해줄 수 있어야 합니다. 이에 대한 방법으로

  1. then ~ catch : [resolved → then][rejected → catch ]
  2. .end() method
.end((res, err) => {
//에러처리 부분
     if(err) throw err;
// 상위 매서드(호출자)로 단계적으로 에러를 던짐. 최종적인 처리는 request(app) 에서 위뤄질 것으로 예상.
***// 그 외부분***
		res.body. ....
}

앞서 설명한 동기처리 방식 중 return과 async방식은 .end()매서드가 이미 내부적으로 호출되고 있으므로 .end() 추가호출 시 twice calling error 가 발생합니다.

// return case
it('~~', () => {
return
     request(app)
        .get('/')
        .expect(~~)
// error 발생 지점
      ~~.end(done)~~
});

// async - await case
it('~~', async function{
await
    request(app)
	 .get()
         .expect()
// error 발생 지점
       ~~.end()~~
});

4.2.2. TDD 소스 코드(동기, 예외 처리)

/*
- mocha 테스트는 기본적으로 nodejs위에서 이뤄지기 때문에 비동기, 논블라킹 방식을 취한다.
- express서버를 구동하는 무거운 작업을 내부에서 진행하는 supertest를 처리하는 별도과정이 필요하다.
*/

/* 동기 처리에 대한 3가지 방법(상기방법과 동일)
1. done
2. async - await
3. return
*/

// done : 슈퍼테스트 마무리에 done매서드를 호출해준다.
const request = require('supertest');
const should = require('should');
const app = require('app');

// end ~ done
describe('/players는', () => {
    describe('성공 시', () => {
        it('~가 된다.', done => {
            request(app)
                .get('/players')
                .expect(200)
                .end( (res, err) => {
                    if (err) throw err;
                    res.body.should.have.property('key', 'value');
                    done(); // done함수 호출
                });
        });
    });
});

// expect ~ done : 에러처리는 가능하지만 response에 대한 통제권을 지정해주지 않은상태.
describe('/players는', () => {
    describe('성공 시', () => {
        it('~가 된다.', done => {
            request(app)
                .get('/players')
                .expect(200, done);
        });
    });
});

// then ~ catch ~ done : end()매서드 대신 then catch 사용
describe('/players는', () => {
    describe('성공 시', () => {
        it('~가 된다.', done => {
            request(app)
                .get('/players')
                .expect(200) // 프라미스 반환
                    .then( res => {
                        res.body.should.have.lengthOf(2); // res.body에 대한 테스트
                        console.log(res);
                    })
                    .catch( err => {
                        done(err); // done 호출
                    });
        });
    });
});

// 일반함수 + return & async - await : then ~ catch를 이용해 반환값, 에러를 처리한다.
describe('/players는', () => {
    describe('성공 시', () => {
        it('~가 된다.', () => {
            return request(app)
                .get('/players')
                .expect(200) // 여기서 프라미스의 상태가 변경된다.(만족 / 불만족)
                    .then( res => {
                        res.body.should.be.instanceOf(Array); // res에 대한 테스트
                    })
                    .catch( err => {
                        console.log(err);
                    });
        });
    });
});

describe('/players는', () => {
    describe('성공 시', () => {
        it('~가 된다.', async() => {
            await request(app)
                .get('/players')
                .expect(200) // 마찬가지 프라미스의 상태가 변하는 곳
                    .then( res => {
                        res.body.should.be.instanceOf(Array); // res에 대한 처리권한
                    })
                    .catch( err => {
                        console.log(err);
                    });
        });
    });
});

4.2.3. 서버 구동 시 자동 감시자 설정

시작 : pm2 start file_dir --watch

중지 : pm2 stop file_dir --watch

효과 : 소스코드가 변경되어도 파일 저장만 누르면 서버를 재 구동할 필요 없이 변경된 사항이 자동으로 반영된다. next js프레임워크를 이용한 react 웹페이지 만들기에서 최초 빌드 이후엔 소스변경 시 별도 재구동이 필요하지 않은 경우와 동일한 원리인 것 같습니다.


4.2.4. package.json script

package.json 파일 script 항목에 "단축단어": "동작 명령" 을 통해 동작명령어를 줄일 수 있습니다.

"test": "mocha file_dir" (주소 기준은 json파일이 존재하는 현위치이다.)

terminal : npm run test → mocha test 진행.


4.2.5. (GET, POST, PUT, DELETE 매서드) api 환경 슈퍼테스트

하나의 파일이 너무 방대해 질 때 코드 리팩터(refactor)를 통해 파일을 분리하는 작업을 수행하는 것이 좋습니다.

spec.js : 실제 테스트가 진행되는 파일(테스트 수트 / 케이스가 담겨져 있다.)

// 모듈, 미들웨어 가져오기
const request = require('supertest');
const app = require('../../server');
const should = require('should')
const models = require('../../models');
const mocha = require('mocha')

// GET
 describe('GET : /players는', () => {
     describe('성공 시', () => {
         // before( () => models.sequelize.sync({force:true}));
         it('player list를 반환한다.', () => {
             return request(app)
                .get('/players')
                .then( res => {
                    res.body.should.be.instanceOf(Array);
                })
                .catch( err => {
                    console.log(err);
                })
                /* .end((err, res) => {
                    if(err) throw err;
                    res.body.should.be.instanceOf(Array);
                    done();
                }); */
         });
         it('최대 limit갯수만큼 응답한다.', (done) => {
             request(app)
                .get('/players?limit=2')
                .end((err, res) => {
                    if (err) throw err;
                    res.body.should.have.lengthOf(2)
                    done();
                });
         });
     });
     describe('실패 시', () => {
         it('limit이 숫자형이아니면 400을 응답한다.', (done) => {
             request(app)
                .get('/players?limit=three')
                .expect(400, done);
                /* .end((err, res) => {
                    if (err) throw err; // expect에서 발생하는 error가 end매서드로 던져진 것. 따라서 throw처리를 하지 않으면 에러가 처리되지 않음.
                    done();
                });
                */
         });
     });
 });

 describe('GET : /players:mvp 는', () => {
     describe('성공 시', () => {
         it('mvp가 4인 객체를 반환한다.', (done) => {
             request(app)
                .get('/players/4')
                .end((err, res) => {
                    if(err) throw err;
                    res.body[0].should.have.property('final_mvp', 4);
                    done();
                });
         });
     });
 });

//DELETE
describe('DELETE : /players/:mvp는', () => {
    describe('성공 시', () => {
        it('상태코드 204를 반환한다.', (done) => {
            request(app)
               .delete('/players/4')
               .expect(204)
               .end((err, res) => {
                   done();
               });
        });
    });
    describe('실패 시', () => {
        it('mvp가 숫자가 아니면 상태코드 400을 반환한다.', async () => {
            await request(app)
               .delete('/players/four')
               .expect(400)
        });
    });
});

//POST
describe('POST : /players 는', () => {
    describe('성공 시', () => {
        let body;
        before(done => {
            request(app)
               .post('/players')
               .send({name: 'ball'})
               .expect(201)
               .end((err, res) => {
                   if (err) throw err;
                   body = res.body;
                   done();
               });
        });
        it('입력한 이름을 반환한다.', () => {
            body.should.have.property('name', 'ball');
        });
        it('생성된 유저객체를 반환한다.', () => {
            body.should.have.property('club', 'bulls');
        });
    });
    describe('실패 시', () => {
        it('파라매터 누락 시 400을 반환한다.', done => {
            request(app)
               .post('/players')
               .send({})
               .expect(400)
               .end(done);
        });
        it('name이 중복일 경우 409를 반환한다.', done => {
            request(app)
               .post('/players')
               .send({name: 'tatum'})
               .expect(409)
               .end(done);
        });
    });
});

//PUT
describe('PUT : /players 은', () => {
    describe('성공 시', () => {
        const name = 'zrue';
        it('변경된 name을 응답한다.', done => {
            request(app)
               .put('/players?club=bucks')
               .send({name})
               .end((err, res) => {
                   if (err) throw err;
                   res.body.should.have.property('name', 'zrue');
                   done();
               });
        });
    });
    describe('실패 시', () => {
        const name = 'howard';
        it('문자열이 아닌 club일 경우 400을 응답', done => {
            request(app)
               .put('/players?club=4')
               .send(name)
               .expect(400)
               .end(done);
        });
        it('name이 없을 경우 400 응답', done => {
            request(app)
               .put('/players?club=nets')
               .send()
               .expect(400)
               .end(done);
        });
        it('없는 클럽일 경우 404 응답', done => {
            request(app)
               .put('/players?club=w')
               .send({ name: 'howard' })
               .expect(404)
               .end(done);
        });
        it('이름이 중복일 경우 409 응답', done => {
            request(app)
               .put('/players?club=knicks')
               .send({ name: 'tatum'})
               .expect(409)
               .end(done);
        });
    });
});

router : 라우터 객체가 담긴 파일

const express = require('express');
const router = express.Router();
const playerService = require('./service');

router.get('/', playerService.getPlayers);
router.get('/:mvp', playerService.getMvpPlayer);
router.delete('/:mvp', playerService.deleteMvpPlayer);
router.post('/', playerService.createPlayer);
router.put('/', playerService.editPlayer);

module.exports = router;

service : 라우터의 미들웨어 함수가 담겨진 파일. 서비스 함수들의 묶음이라고도 한다.

// const sqliteDB = require('../../models');

// dummy data
players = [
    {club: 'lakers', name: 'lebron', age: 36, salary: 38, final_mvp: 4},
    {club: 'celtics', name: 'tatum', age: 23, salary: 28, final_mvp: 0},
    {club: 'nets', name: 'durant', age: 32, salary: 41, final_mvp: 2},
    {club: 'hawks', name: 'trae', age: 22, salary: 8, final_mvp: 0},
    {club: 'mavs', name: 'doncic', age: 22, salary: 10, final_mvp: 0},
    {club: 'clippers', name: 'kawai', age: 30, salary: 36, final_mvp: 2},
    {club: 'raptors', name: 'siakam', age: 27, salary: 31, final_mvp: 0},
    {club: 'heat', name: 'butler', age: 31, salary: 36, final_mvp: 0},
    {club: 'jazz', name: 'mitchel', age: 24, salary: 28, final_mvp: 0},
    {club: 'bucks', name: 'giannis', age: 26, salary: 38, final_mvp: 1},
    {club: 'knicks', name: 'walker', age: 33, salary: 32, final_mvp: 0},
];

// player list를 내려주는 매서드
exports.getPlayers =  (req, res) => {
    if (req.query.limit === undefined) limit = players.length;
    else limit = parseInt(req.query.limit, 10);
    if(Number.isNaN(limit)) return res.status(400).end();
    res.status(200);
    res.json(players.slice(0,limit)).end();
};

// mvp횟수를 쿼리하여 객체를 내려주는 매서드
exports.getMvpPlayer =  (req, res) => {
    const mvp = parseInt(req.params.mvp, 10);
    if (Number.isNaN(mvp)) return res.status(400).end();
    const player = players.filter( player => player.final_mvp === mvp);
    // if (player.length == 0) return res.status(404).end();
    res.json(player).end();
    };

// mvp횟수를 쿼리하여 해당객체를 데이터에서 삭제하는 매서드
exports.deleteMvpPlayer =   (req, res) => {
    const mvp = parseInt(req.params.mvp, 10);
    players =   players.filter( player => player.final_mvp !== mvp);
    if (Number.isNaN(mvp)) res.status(400).end();
    res.status(204).end();
};

// 선수 생성 매서드
exports.createPlayer =   (req, res) => {
    const name = req.body.name;
    const new_player = {club: 'bulls', age: 24, salary: 21, final_mvp: 0};
    const AlreadyExistPlayer =   players.filter( player => player.name == name).length;
    if (!name) return res.status(400).end();
    if (AlreadyExistPlayer) return res.status(409).end();
    new_player.name = name;
    players.push(new_player);
    res.status(201);
    res.json(new_player).end();
};

// 선수정보 수정 매서드
exports. editPlayer =   (req, res) => {
    const name = req.body.name;
    if (typeof name != 'string' || !name) return res.status(400).end();
    const AEplayer =   players.filter( player => player.name == name);
    if ( AEplayer.length != 0) return res.status(409).end();
    const club = req.query.club
    const playerList =  players.filter( player => player.club == club );
    if (playerList.length === 0) return res.status(404).end();
    const player = playerList[0];
    player.name = name;
    res.json(player).end();
};

// 데이터베이스 테이블을 ORM으로 추상화한 것을 모델이라 하며 이 모델을 통해 각종 매서드들에 접근하는 것이다.
// 모델을 정의하고(sequelize.define()) -> 정의된 모델과 데이터베이스를 연동한다. (sequelize.sync())

server : express 객체인 'app' 이 존재하는 파일

const express = require('express');
const bodyParser = require('body-parser');
const morgan = require('morgan');
const app = express();
const player = require('./api/player/router.js');

if (process.env.NODE_ENV != 'test') {
    app.use(morgan('dev')); // 로그를 찍어준당
}

app.use(bodyParser.urlencoded());
app.use(bodyParser.json());

app.use('/players', player);
module.exports = app; // for supertest

server.excuting : express객체의 listen 매서드 호출을 통해 실제 서버를 구동하는 파일

const app = require('./server.js');
app.listen(2500, () => {
    console.log('server is running on port 2500');
});

4.2.6. 결과

data : json형식의 더미데이터 리스트


terminal : mocha test 성공, 실패여부를 로그와함께 출력해줍니다.


반환값 확인

호출) GET : /players


호출) GET : /players/4


호출) POST : /players

req.body type : form data (express 최신버전은 body-parser모듈이 내장되어 있어 form data의 해석이 가능하다)


호출) PUT : /players?club=lakers


호출) DELETE : /players/0 → GET : /players

Mvp수상 경험이 없는 플레이어 리스트에서 제외, 변경된 리스트 겟

좋은 웹페이지 즐겨찾기