TDD
1. 테스트란?
1-1. 테스트란 (소프트웨어 테스트)
- 제품 or 서비스의 품질을 확인
- 소프트웨어의 버그를 찾음
즉, 제품이 예상하는 대로(원하는 대로) 동작 하는지 확인
여기서 제품이란, 함수, 특정한 기능, UI, 성능, API 스펙
1-2. 언제 테스트를 해야 할까?
기존에는 Dev -> QA -> Publish
이제는 (Dev, Automated QA) ->QA -> Publish
1-3. 테스트를 하는 이유? 장점?
- 기능이 정상 동작
- 요구 사항 만족
- 이슈에 대해 예측
- 버그를 빠르게 발견
- 자신감 있게 리팩토링
- 손쉬운 유지 보수
- 코드의 품질 향상
- 코드간 의존성을 낮춤
- 좋은 문서화
- 시간을 절약
1-4. 꼭 알아야 하는 테스트 피라미드
E2E Test(end-to-end) : UI테스트(사용자 테스트)
Integration Test(통합 테스트) : 모듈들, 클래스들
Unit Test(단위 테스트) : 함수, 모듈, 클래스
1-5. TDD란 무엇인가?
개발하는 방식의 일종
Test-driven development 테스트 주도 개발
개발(코드 작성)전 테스트 코드를 먼저 작성
(Re)Write the test -> Run Tests -> Write only enough Code 의 사이클을 반복
1-6. TDD 모든 개발자들이 다 해야할까?
main repository에 merge 하기 전에 테스트 코드를 포함하자.
테스트 코드는 '좋은 문서화'의 기능을 한다.
언제 TDD를 사용할까?
- 요구사항이 명확할 때
- 비지니스 로직
- 협업시 명세서(문서) 역할
- 설계에 대한 고민이 필요
(UI코드는 TDD를 하지 않는다.)
1-7. CI/CD에서의 테스트
CODE -> BUILD -> TEST -> RELEASE -> DEPLOY
지속적인 통합
- 코드 변경사항을 주기적으로 빈번하게 머지해야 한다
- 통합을 위한 단계(빌드, 테스트, 머지)의 자동화
- 코드의 퀄리티 향상(개발 생산성 향상), 버그 수정 용이(문제점을 빠르게 발견)
2. 유닛테스트
2-1. 기본 테스트
// add.js
function add(a,b){
return a+b;
}
module.exports = add;
// add.test.js
const add = require('../add.js');
test('add', () => {
expect(add(1,2)).toBe(3);
})
2-2. 계산기 테스트
// calculator.js
class Calculator{
constructor(){
this.value = 0;
}
set(num){
this.value = num;
}
clear(){
this.value = 0;
}
add(num){
this.value = this.value +num;
}
subtract(num){
this.value = this.value - num;
}
multiply(num){
this.value = this.value * num;
}
divide(num){
this.value = this.value / num;
}
}
module.exports = Calculator;
//calculator.test.js
const Calculator = require('../calculator.js');
describe('Calculator', () => {
let cal;
beforeEach(() => {
cal = new Calculator();
})
it('inits with 0', () => {
expect(cal.value).toBe(0);
});
it('sets', () => {
cal.set(10);
expect(cal.value).toBe(10);
});
it('clear',() => {
cal.set(10);
cal.clear();
expect(cal.value).toBe(0);
});
it('add',() => {
cal.set(10);
cal.add(10);
expect(cal.value).toBe(20);
});
it('subtract',() => {
cal.set(10);
cal.subtract(10);
expect(cal.value).toBe(0);
});
it('multiply',() => {
cal.set(10);
cal.multiply(1);
expect(cal.value).toBe(10);
});
describe('divides',() => {
it('0 / 0 === NaN',() => {
cal.divide(0);
expect(cal.value).toBe(NaN);
});
it('1 / 0 === Infinity',() => {
cal.set(1);
cal.divide(0);
expect(cal.value).toBe(Infinity);
});
it('4/4 === 1', () => {
cal.set(4);
cal.divide(4);
expect(cal.value).toBe(1);
})
})
});
2-3. 에러 테스트
// calculator.js
class Calculator{
constructor(){
this.value = 0;
}
set(num){
this.value = num;
}
clear(){
this.value = 0;
}
add(num){
const sum = this.value = this.value +num;
if(sum > 100){
throw new Error('Value can not be greater than 100');
}
this.value = sum;
}
subtract(num){
this.value = this.value - num;
}
multiply(num){
this.value = this.value * num;
}
divide(num){
this.value = this.value / num;
}
}
module.exports = Calculator;
//calculator.test.js
const Calculator = require('../calculator.js');
describe('Calculator', () => {
let cal;
beforeEach(() => {
cal = new Calculator();
})
it('inits with 0', () => {
expect(cal.value).toBe(0);
});
it('sets', () => {
cal.set(10);
expect(cal.value).toBe(10);
});
it('clear',() => {
cal.set(10);
cal.clear();
expect(cal.value).toBe(0);
});
it('add',() => {
cal.set(10);
cal.add(10);
expect(cal.value).toBe(20);
});
it('add should throw an error if value is greater than 100',() => {
expect(() => {
cal.add(101);
}).toThrow('Value can not be greater than 100');
})
it('subtract',() => {
cal.set(10);
cal.subtract(10);
expect(cal.value).toBe(0);
});
it('multiply',() => {
cal.set(10);
cal.multiply(1);
expect(cal.value).toBe(10);
});
describe('divides',() => {
it('0 / 0 === NaN',() => {
cal.divide(0);
expect(cal.value).toBe(NaN);
});
it('1 / 0 === Infinity',() => {
cal.set(1);
cal.divide(0);
expect(cal.value).toBe(Infinity);
});
it('4/4 === 1', () => {
cal.set(4);
cal.divide(4);
expect(cal.value).toBe(1);
})
})
});
npm run test
jest --coverage
2-4. 비동기 테스트
// async.js
function fetchProduct(error){
if(error === 'error'){
return Promise.reject('network error');
}
return Promise.resolve({item:'Milk', price:200});
}
module.exports = fetchProduct;
// async.test.js
const fetchProduct = require('../async');
describe('Async', () => {
it('async - done', (done) => {
fetchProduct().then((item) => {
expect(item).toEqual({item:'Milk', price:200});
done();
})
});
it('async - return', () => {
return fetchProduct().then((item) => {
expect(item).toEqual({item:'Milk', price:200});
})
});
it('async - await', async () => {
const product = await fetchProduct();
expect(product).toEqual({item:'Milk', price:200});
});
it('async - resolve', () => {
return expect(fetchProduct()).resolves.toEqual({item:'Milk', price:200});
});
it('async - reject', () => {
return expect(fetchProduct('error')).rejects.toBe('network error');
});
})
2-5. Mock 테스트
2-5-1. Mock basic
// check.js
function check(predicate, onSuccess, onFail){
if(predicate()){
onSuccess('yes');
}else{
onFail('no');
}
}
module.exports = check;
// check.test.js
const check = require('../check');
describe('check', () => {
let onSuccess;
let onFail;
beforeEach(() => {
onSuccess = jest.fn();
onFail = jest.fn();
});
it('should call onSuccess when predicate is true', () => {
check(() => true, onSuccess, onFail);
//expect(onSuccess.mock.calls.length).toBe(1);
expect(onSuccess).toHaveBeenCalledTimes(1);
//expect(onSuccess.mock.calls[0][0]).toBe('yes');
expect(onSuccess).toHaveBeenCalledWith('yes');
//expect(onFail.mock.calls.length).toBe(0);
expect(onFail).toHaveBeenCalledTimes(0);
});
it('should call onFail when predicate is false', () => {
check(() => false, onSuccess, onFail);
expect(onFail).toHaveBeenCalledTimes(1);
expect(onFail).toHaveBeenCalledWith('no');
expect(onSuccess).toHaveBeenCalledTimes(0);
});
})
2-5-2. Mock 제품정보 가지고 오기
// product_client.js
class ProductClient{
fetchItems(){
return fetch('http://example.com/login/id+password').then((response) => response.json());
}
}
module.exports = ProductClient;
// product_service_no_di.js
const ProductClient = require('./product_client');
class ProductService{
constructor(){
this.productClient = new ProductClient();
}
fetchAvailableItems(){
return this.productClient
.fetchItems()
.then(items => items.filter(item => item.available));
}
}
module.exports = ProductService;
// product_service_no_di.test.js
const ProductService = require('../product_service_no_di');
const ProductClient = require('../product_client');
jest.mock('../product_client');
describe('ProductService',() =>{
const fetchItems = jest.fn(async () => {
return [
{item:'☕', available:true},
{item:'🍌', available:false}
]
});
ProductClient.mockImplementation(() => {
return {
fetchItems : fetchItems
}
});
let productService;
beforeEach(() => {
productService = new ProductService();
})
it('should filter out only available items', async () => {
const items = await productService.fetchAvailableItems();
expect(items.length).toBe(1);
expect(items).toEqual([{item:'☕', available:true}]);
})
})
2-5-3. Stub
// product_service.js
class ProductService{
constructor(productClient){
this.productClient = productClient;
}
fetchAvailableItems(){
return this.productClient
.fetchItems()
.then(items => items.filter(item => item.available));
}
}
module.exports = ProductService;
// stub_product_client.js
class StubProductClient{
async fetchItems(){
return [
{item:'☕', available:true},
{item:'🍌', available:false}
];
}
}
module.exports = StubProductClient;
// product_service.test.js
const ProductService = require('../product_service');
const StubProductClient = require('./stub_product_client');
describe('ProductService - Stub',() =>{
let productService;
beforeEach(() => {
productService = new ProductService(new StubProductClient());
})
it('should filter out only available items', async () => {
const items = await productService.fetchAvailableItems();
expect(items.length).toBe(1);
expect(items).toEqual([{item:'☕', available:true}]);
})
});
2-5-4. Mock 사용자 로그인
행동에 관해 테스트를 할 때는 Stub 이 아닌 Mock을 이용해야 한다.
// user_client.js
class UserClient{
login(id, password){
return fetch('http://example.com/login/id+password')
.then(response => response.json());
}
}
module.exports = UserClient;
// user_service.js
class UserService{
constructor(userClient){
this.userClient = userClient;
this.isLogedIn = false;
}
login(id, password){
if(!this.isLogedIn){
return this.userClient
.login(id, password)
.then(data => (this.isLogedIn = true));
}
}
}
module.exports = UserService;
// user_service.test.js
const UserService = require('../user_service');
const UserClient = require('../user_client');
jest.mock('../user_client');
describe('UserService', () =>{
const login = jest.fn(async () => 'success');
UserClient.mockImplementation(() => {
return {
login : login
}
})
let userService;
beforeEach(() => {
userService = new UserService(new UserClient());
login.mockClear();
UserClient.mockClear();
})
it('calls login() on UserClient when tries to login ', async () => {
await userService.login('abc', 'abc');
expect(login.mock.calls.length).toBe(1);
});
it('should not call login() on UserClient again if alreay logged in ', async () => {
await userService.login('abc', 'abc');
await userService.login('abc', 'abc');
expect(login.mock.calls.length).toBe(1);
});
});
3. 좋은 테스트 원칙
3-1. 테스트의 비밀
- 한번 작성된 테스트 코드는 영원히 유지보수 해야한다.
- 내부 구현 사항을 테스트 하면 안된다.
- 재사용성을 높이기(테스트 유틸리티)
- 배포용 코드와 철저히 분리
- 테스트코드를 통한 문서화
3-2. 좋은 테스트의 구조
before
- Arrange, Given
- Act, When
- Assert, Then
after
3-3. 좋은 테스트의 원칙
FIRST
Fast - 느린 것에 대한 의존성 낮추기 (파일, 데이터베이스, 네트워크), mock 이나 stub을 사용하기
Isolated - 최소한의 유닛으로 검증하기(독립적이고, 집중적으로 유지)
Repeatable - 실행할 때마다 동일한 결과를 유지, 환경에 영향을 받지 않도록 작성
Self-Validating - 스스로 결과를 검증하기, 자동화를 통한 검증단계(CI/CD)
Timely - 시기적절하게 테스트 코드 작성, 사용자에게 배포되기 이전에 테스트 코드 작성
3-4. 무엇을 테스트 해야 할지 모를 떄의 원칙
Right-BICEP
모든 요구 사항이 정상 동작 하는지 확인
B - Boundary conditions - 모든 코너 케이스에 대해 테스트를 하기
I - Inverse relationship - 역관계를 적용해서 결과값을 확인
C - Cross-check - 다른 수단을 이용해서 결과값이 맞는지 확인
E - Error conditions - 불행한 경로에 대해 우아하게 처리 하는가?
P - Performance characteristics - 성능 확인은 테스트를 통해 정확한 수치로 확인
3-5. 좋은 테스트의 커버리지
테스트의 조건
CORRECT
C - Conformance - 특정 포맷을 준수
O - Ordering - 순서 조건 확인하기
R -Range - 숫자의 범위
R - Reference - 외부 의존성 유무, 특정한 조건의 유무
E - Existence - 값이 존재 하지 않을 때 어떻게 동작
C - Cardinality
T - Time - 상대,절대, 동시의 일들
4. TDD 실전
Stack 구현하기
// stack.js
class Stack{
constructor(){
this._size = 0;
this.head = null;
}
size(){
return this._size;
}
push(item){
const node = {item:item, next:this.head};
this.head = node;
this._size++;
}
pop(){
if(this.head === null){
throw new Error('Stack is empty');
}
const node = this.head;
this.head = node.next;
this._size--;
return node.item;
}
peek(){
if(this.head === null){
throw new Error('Stack is empty');
}
return this.head.item;
}
}
module.exports = Stack;
//stack.test.js
const Stack = require('../stack.js');
describe('Stack', () => {
let stack;
beforeEach(() => {
stack = new Stack();
})
it('is created empty', () => {
expect(stack.size()).toBe(0);
});
it('allows to push item', () => {
stack.push('🍌');
expect(stack.size()).toBe(1);
});
describe('pop', () => {
it('throws an error if stack is empty', () => {
expect(() => { stack.pop()}).toThrow('Stack is empty');
});
it('returns the last pushed item and removes it from the stack', () => {
stack.push('🍌');
expect(stack.pop()).toBe('🍌');
expect(stack.size()).toBe(0);
});
})
describe('peek', () => {
it('throws an error if stack is empty', () => {
expect(() => { stack.peek()}).toThrow('Stack is empty');
});
it('returns the last pushed item but keeps it in the stack', () => {
stack.push('🍌');
expect(stack.peek()).toBe('🍌');
expect(stack.size()).toBe(1);
});
})
})
소스 https://github.com/dimorin/unit-basic
5. Tool
CI/CD
Github actions
TDD 라이브러리
Jest