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. 테스트의 비밀

  1. 한번 작성된 테스트 코드는 영원히 유지보수 해야한다.
  2. 내부 구현 사항을 테스트 하면 안된다.
  3. 재사용성을 높이기(테스트 유틸리티)
  4. 배포용 코드와 철저히 분리
  5. 테스트코드를 통한 문서화

3-2. 좋은 테스트의 구조

before

  1. Arrange, Given
  2. Act, When
  3. 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

https://jestjs.io/