회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
AWS 이용 중이라면 최대 700만 원 지원받으세요
개발자에게 있어 테스트 코드는 제품 안정성을 올려주고, 버그를 사전에 잡아줄 수 있는 중요한 역할을 합니다. 그러나 막상 개발을 하다 보면 테스트 코드를 작성할 여유가 많지 않습니다. 중요하다고 하면서도 정작 테스트 코드를 작성할 시간은 많이 주어지지 않죠. 그래서 가독성이 좋고, 오래 유지할 수 있는 테스트 코드를 작성하는 것이 중요해졌습니다. (참고: 가독성 좋은 테스트 코드를 작성하는 방법)
회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
회원가입을 하면
성장에 도움이 되는 콘텐츠를
스크랩할 수 있어요!
확인
개발자에게 있어 테스트 코드는 제품 안정성을 올려주고, 버그를 사전에 잡아줄 수 있는 중요한 역할을 합니다. 그러나 막상 개발을 하다 보면 테스트 코드를 작성할 여유가 많지 않습니다. 중요하다고 하면서도 정작 테스트 코드를 작성할 시간은 많이 주어지지 않죠. 그래서 가독성이 좋고, 오래 유지할 수 있는 테스트 코드를 작성하는 것이 중요해졌습니다. (참고: 가독성 좋은 테스트 코드를 작성하는 방법)
위 표는 주니어 프론트엔드 개발자 채용 공고(토스뱅크, 버킷플레이스, 무신사, 컬리, 라프텔, 야놀자 등) 30개를 분석해 본 결과인데요. 자격 요건을 살펴보면 리액트가 뷰에 비해 약 3배 정도 많았고, 그 외에도 타입스크립트의 비중이 자바스크립트의 비중을 뒤쫓아가고 있습니다. 또한 깃(Git)의 사용 능력도 점차 중요해지고 있습니다.
이 중 제가 주목한 기술은 바로 테스팅입니다. 테스팅 관련 기술은 주니어 개발자를 대상으로는 자격 요건 비중이 크지 않지만, 이에 관한 이해도가 있거나 사용할 줄 안다면 충분히 가산점을 받을 수 있습니다. 테스트 코드를 어느 정도 작성할 줄 안다면, 부족한 시간 대비 빠르게 결과물을 만들어낼 수 있기 때문이죠.
이번 글에서는 프론트엔드 개발자가 알고 있어야 할 테스트 기법 중 ‘유닛 테스트(Unit Testing)’에 관해 알아보겠습니다. 자바스크립트 테스트 프레임워크 중하나인 ‘Jest’ 문법을 예시로 소개합니다. (Jest를 몰라도 내용을 이해하는 데 큰 문제는 없지만, 기본적인 문법을 알고 있다면 더욱 재밌게 읽으실 수 있습니다.)
개발자가 할 수 있는 테스트에는 여러 종류가 있습니다. 버튼을 눌렀을 때 모달이 화면에 보이는지 측정하는 컴포넌트 테스트, 코드를 수정한 후 화면의 UI가 그대로 보는 시각(Visual) 테스트, 사용자가 직접 사이트를 탐방하는 것처럼 정해진 출발점부터 종료 지점까지 약속된 행동을 하는지 검사하는 종단 간(End-to-End) 테스트 등이 있죠.
이 모든 테스트의 본질은 ‘검증하고 싶은 무언가를 검증하는 것’이고, 검증하는 방법에 따라 불리는 이름이 다릅니다. 그중 유닛 테스트(Unit Testing)는 특정 모듈이 목표한 기능을 올바르게 수행하고 있는지 검증하는 절차입니다. 즉, 모든 함수와 메소드에 대한 테스트 케이스를 작성하는 절차를 말하죠.
그렇다면 유닛 테스트는 왜 필요할까요? 예를 들어, 은행 앱을 만들고 있다고 가정해 볼게요. 여러분은 누군가에게 돈을 송금하려고 합니다. 돈을 보내기 전 계좌 잔액을 조회하는 로직이 먼저 실행되어야 합니다. 이 기능이 제대로 동작하지 않는다면, 계좌에 잔액이 남아있지 않아도 송금할 수 있게 되는 등 은행의 신뢰성에 매우 치명적인 결과를 낳게 될 겁니다. 그래서 특정 계좌의 잔액을 조회하는 함수를 만들었고, 이 함수가 제대로 동작하는지 검증하려고 합니다. 매번 앱에서 버튼을 누르며 테스트하기엔 시간적인 여유가 부족하고, 유닛 테스트가 적합하죠. 다음과 같이 유닛 테스트를 해 볼 수 있습니다.
// 계좌 번호를 받아 잔액을 조회하는 함수입니다.
// 우리는 이 함수가 항상 올바르게 잔액을 조회하는지 확인하고 싶습니다.
function getBalanceOfAccount(accountNumber) {
const balance = server.getBalanceByAccount(accountNumber);
return balance;
}
function isBalanceEnough(moneyToSend, balance) {
return moneyToSend <= balance;
}
// 원하는 금액만큼 송금하고자 합니다.
function transferMoney(amount, myAccount, accountToSend) {
if (isBalanceEnough(amount, getBalanceOfAccount(myAccount))) {
// 충분한 잔액이 있는 경우에만 송금합니다.
} else {
…
}
}
코드를 작성할 때 모든 내용을 하나의 파일, 하나의 함수에 전부 작성하지 않는 이유가 무엇일까요? 많은 것들이 하나에 전부 들어가 있으면 읽기도 힘들뿐더러, 여러 기능이 모여있어 관리가 힘들어지고 덩달아 디버깅도 힘들어지기 때문입니다. 따라서 독립적인 기능 위주로 분리하는 것이 바람직하다고 알려져 있고, 이러한 내용은 테스트에도 영향을 미칩니다.
위에서 예시로 등장한 계좌 잔액을 조회하는 getBalanceOfAccount를 다시 살펴볼까요? 이 함수는 전달받은 계좌(account)의 잔액만을 조회하는 기능을 수행하고 있습니다. 이 함수 내부에서 조회한 잔액이 특정 금액 이상인지를 검사하는 isBalanceEnough의 동작까지도 같이 수행한다면, getBalanceOfAccount는 더 이상 계좌의 잔액만을 확인하는 함수가 아니게 됩니다. 이는 우리가 예측 가능한 범위 내에서의 동작 결과를 보장해 주지 않을지도 모릅니다. 그래서 개발자들은 주어진 요구사항만을 만족하는 기능 단위의 함수로 코드를 분리하는 것을 지향하게 되죠. 이렇게 잘 분리된 함수는 ‘순수하다’라는 표현을 붙입니다.
코드가 순수하다는 것은 잘 정제되고 다듬어진 형태의 코드를 말합니다. 특정 기능(오로지 계좌의 잔액을 확인하는)을 만족하는 코드의 집합체인 함수 역시 구성품인 코드가 순수하다면, 함수 그 자체도 순수해질 수 있는데요. 이때 함수가 순수해지기 위해서는 필요조건이 있습니다.
첫 번째로 “동일한 입력값에는 동일한 결괏값이 보장되어야 한다.”는 것입니다. 널리 알려진 내용으로 한 번쯤은 들어봤을 것 같은데요. 수학으로 빗대어 보면 이렇습니다. 평면상의 좌표 x, y에 대하여 둘 사이의 관계를 나타내는 방정식 f를 f(x) = y로 표현할 수 있다면, 이는 x에 대한 함수 f(x)의 값은 항상 y임을 보장한다고 할 수 있습니다. 매우 유명한 구절인 만큼 순수 함수에 대해 반드시 알아두어야 하는 가장 핵심적인 내용입니다.
// moneyToSend와 balance는 모두 숫자형 자료입니다.
// 두 수의 크기를 비교하는 함수에 임의의 숫자 두 개를 전달하면 결과는 항상 동일하게 나옵니다.
function isBalanceEnough(moneyToSend, balance) {
return moneyToSend <= balance;
}
두 번째는 “함수 외부 스코프의 값을 변경하지 않아야 한다.”인데요. 함수가 실행되는 컨텍스트(Function Context) 안에서 변수를 만들어 사용하는 경우가 아닌, 함수 외부의 무언가를 변경하는 작업은 사이드 이펙트를 불러일으킬 수 있습니다. 함수 외부에 존재하는 값을 변경하게 될 경우, 그 값을 사용하는 또 다른 곳에서 예상하지 못한 결과를 만들어낼 수 있으므로, 함수 영역 안에서 주어진 값만 활용하는 것이 바람직합니다.
// 송금을 마친 뒤에 실행하는 completeMoneyTransfer 함수는 송금액과 계좌의 직전 잔액을 인자로 받습니다.
// 송금 후에 계좌의 잔액을 변경하는 로직을 수행하고 있습니다.
// completeMoneyTransfer 함수 입장에서는 account의 정보를 함수 외부에서 가져오고 있는 모습인데
// 이 경우는 account를 사용하는 다른 곳에서 의도하지 않은 결과를 낳을 수 있기에
// completeMoneyTransfer 함수는 순수하지 않다고 표현합니다.
function completeMoneyTransfer(moneyToSend, previousBalance) {
account.setBalanace(previousBalance - moneyToSend);
}
세 번째는 “함수 외부 스코프의 값을 참조하지 않아야 한다.”입니다. 외부 스코프의 값을 변경하는 것이 함수를 순수하지 않게 만드는 원인이었다면, 이 조건은 그보다 조금 더 근본적인 내용에 해당합니다. 프로그래밍에서 어떤 변수의 값을 변경한다는 것은 그 값에 먼저 접근한다는 행동을 포함합니다. 새로운 값으로 변경하기 위해서 정해진 주소에 접근해야 하기 때문입니다. 그래서 함수가 순수해질 수 있는 조건의 관점에서 바라보는 경우, 함수의 영역 밖에서 무언가를 참조하는 행위는 사이드 이펙트의 가능성을 내포하게 됩니다.
let externalBalance = 1000;
function isBalanceEnough(moneyToSend, balance) {
return moneyToSend <= balance;
}
function transferMoney(amount, myAccount, accountToSend) {
// 여기서 외부 스코프의 externalBalance 값을 참조하고 있습니다.
// externalBalance의 값은 외부에 선언이 되어 있어 누구나 변경이 가능한 상태입니다.
// 외부에서 선언되고 변경이 가능한 변수를 사용하고 있으므로
// transferMoney는 externalBalance에 의존하게 되는 코드가 생겼습니다.
// 이 경우 사이드 이펙트의 가능성이 높아졌기에 transferMoney는 순수하지 않다고 표현합니다.
if (isBalanceEnough(amount, externalBalance)) {
// 충분한 잔액이 있는 경우에만 송금합니다.
// ...
} else {
// ...
}
}
마지막으로 “함수 내부에서 함수의 결괏값에 영향을 주는 예측 불가능한 임의의 값을 사용하지 않는다.”입니다. 함수가 순수하기 위해선 가능한 모든 사이드 이펙트의 가능성을 차단하는 것이 좋습니다. 그중 마지막 조건은 함수 내부에서 예상하기 어려운 혹은 예상할 수 없는 값을 만들어, 함수의 반환 값에 영향을 주도록 하지 않는 것입니다. 함수 내부에서 임의의 값을 사용하기 시작하면, 같은 입력값을 받아와도 항상 동일한 결과를 보장할 수 없기에 놓치기 쉽지만 매우 중요한 항목이라고 볼 수 있습니다.
// 송금 수수료 할인 혜택을 위해 사용하는 함수입니다.
// 할인율을 만드는 과정에서 임의의 난수를 생성하고 있고 이는 결괏값에 영향을 주게 됩니다.
// 이러한 경우는 calculateTransferFeeReduction 함수에 동일한 인자를 전달하더라도
// 결괏값이 달라질 수 있음을 뜻합니다.
function calculateTransferFeeReduction(transferAmount) {
// 1%에서 10% 사이의 난수 생성
const reductionRate = (Math.floor(Math.random() * 10) + 1) / 100;
return transferAmount * reductionRate;
}
앞서 함수가 언제 순수해질 수 있는지 필요조건을 살펴봤는데요. 이제 예시에 등장했던 함수를 사용해, 유닛 테스트를 어떻게 작성할 수 있는지 연습해 보겠습니다.
function getBoyscouts(students) {
return students.filter(student => student.isBoyscout);
}
간단한 함수로 유닛 테스트 작성을 시작해 보겠습니다.
// 책의 목차 중 주제에 해당합니다.
// 테스트 코드가 어떤 것을 테스트하는지 알려주는 부분으로 간략하게 작성하셔도 됩니다.
describe("보이스카웃 학생을 구하는 함수 테스트", () => {
// 테스트 블록은 중첩으로 작성이 가능합니다.
// 중첩된 테스트 블록에선 보다 상세한 내용을 적어주는 것이 좋습니다.
// getBoyscouts 함수를 테스트하는데 그중 어떤 카테고리/주제에 대한 테스트가 모여있는지 알 수 있게 말이에요.
describe("재학생의 경우", () => {
// test, it 중 하나로 작성하실 수 있고 각 항목당 하나의 테스트 케이스로 등록이 되는 부분입니다.
// 이 부분은 "A를 만족하면 B를 반환한다"와 같이 하나의 기능만을 명확하게 작성해 주시는 것이 중요합니다.
it("보이스카웃의 가입한 학생의 목록을 반환한다.", () => {
// 테스트는 Given-When-Then의 순서를 따라 작성하시는 것을 추천드립니다.
// 단계별로 목적이 있어 코드 가독성을 올릴 수 있고 유지 보수성을 증가시킵니다.
// Given - 테스트에 사용할 데이터를 준비하는 단계입니다.
const students = [...];
// When - 선언한 데이터를 사용해 테스트 대상 함수를 실행합니다.
const boyscouts = getBoyscouts(students);
// Then - 테스트 대상이 되는 함수를 실행한 결과가 기대한 결과와 일치하는지 확인하는 구간입니다.
boyscouts.forEach(boyscout => {
// boyscouts에 포함된 학생은 모두 isBoyscout이 true를 보장해야 합니다.
expect(boyscout.isBoyscout).toBe(true);
});
})
})
})
간단한 예시지만 테스트를 작성해 보니 제법 많은 규칙과 가이드가 있음이 느껴집니다. 그렇지만 테스트 코드를 작성하는 것에 익숙해질수록 코드의 가독성이 더욱 높게 느껴질 겁니다. 테스트 코드의 가독성이 좋아지려면 마찬가지로 함수가 순수해야 하는데요. 함수가 순수할수록 전달하는 인자와 반환하는 결과를 예상 가능한 범위에 둘 수 있습니다.
또한 테스트 가독성을 끌어올리는데 좋은 또 다른 팁은 Given-When-Then 기법을 사용해, 하나의 테스트 케이스 내에서 목적에 맞는 영역 구분을 명시적으로 해주는 것입니다.
지금까지 함수가 순수 함수가 되기 위한 조건과 이에 기반한 유닛 테스트를 작성해 보았습니다. 이번에는 반대로 함수가 순수하지 않을 때, 테스트 코드에서 어떤 문제가 생길 수 있는지 살펴보겠습니다.
계좌 잔액을 조회하는 getBalanceOfAccount를 예시로 들어보겠습니다.
function getBalanceOfAccount(accountNumber) {
const balance = server.getBalanceByAccount(accountNumber);
return balance;
}
getBalanceOfAccount는 계좌 정보를 인자로 받지만 외부 시스템(서버)에 의존하고 있습니다. 외부 시스템에 해당하는 서버가 주어진 입력(계좌)의 남은 잔액을 조회한 결과를 반환해 주고 있지만, 이 경우 어느 시점에 잔액을 조회하는지에 따라 잔액이 다르게 조회될 수 있습니다. 즉, 동일한 입력에 대해 항상 동일한 결과를 보장할 수 없기에, getBalanceOfAccount는 순수 함수가 아니라고 할 수 있습니다.
describe('잔액 조회 테스트', () => {
it('계좌의 현재 잔액을 반환한다.', () => {
// Given const accountNumber = "110-xxx-2345";
// When
const balance = getBalanceOfAccount(accountNumber);
// Then
// 검증: 특정 시점의 잔액을 예상할 수 없으므로 일관된 결과를 보장하기 어렵습니다.
// 예상 결과는 외부 시스템의 상태에 따라 달라질 수 있습니다.
expect(balance).toBe(/* 어떤 특정 값 */);
})
})
테스트 코드는 다음과 같이 작성할 수 있습니다. 테스트 대상이 되는 함수가 순수하지 않기 때문에 검증(Then) 단계에서 결과를 항상 참이라고 보장할 수가 없게 됩니다. 이 문제를 해결하려면 getBalanceOfAccount의 외부 의존성을 끊어내야 합니다.
// accountNumber를 받아 서버에 전달하는 과정을 거치지 않고
// 이미 잔액 계좌의 완료된 계좌 정보를 받아 그 안의 잔액만을 반환하도록 수정했습니다.
function getBalanceOfAccount(accountInfo) {
return accountInfo.balance;
}
이렇게 변경하면 어느 시점에 계좌 정보를 받아도, 항상 동일한 계좌 정보엔 동일한 잔액만이 남아있을 테니 동일한 결과를 보장할 수 있게 됩니다.
순수 함수의 또 다른 조건 중 하나인 외부의 값을 변경하지 않아야 함을 위반하는 경우도 테스트에 영향을 미칠 수 있습니다.
// 현재 원화 대비 달러의 환율 정보
let currentExchangeRate = 1200;
// 환율을 업데이트하는 함수입니다.
function updateExchangeRate(newRate) {
// 외부 스코프의 값을 변경하고 있습니다.
// 그래서 현재 updateExchangeRate는 순수 함수가 아닙니다.
currentExchangeRate = newRate;
}
// 환전을 계산하는 함수입니다.
function convertUSDToKRW(usd) {
return usd * currentExchangeRate;
}
현재 환율에 따른 환전을 수행하는 convertUSDToKRW 함수는 어떤 문제를 갖고 있을까요? 환전하려는 달러는 함수의 인자로 받아오고 있지만, 환율을 처리하는 과정에서 함수 외부의 변수에 접근하고 있습니다.
describe('환전 함수 테스트', () => {
let currentExchangeRate = 1200;
describe("환율이 변경되기 전", () => {
it("100달러를 환전하면 120,000원을 받는다.", () => {
// Given
const usd = 100;
// When
const krw = convertUSDToKRW(usd);
// Then
expect(krw).toBe(120000);
})
})
describe("환율이 변경된 후", () => {
// beforeAll 은 test 또는 it으로 작성된 모든 테스트 케이스가 실행되기 전에 한 번만 실행되는 영역입니다.
beforeAll(() => {
// 환율이 1:1200이었던 것을 1:1000으로 변경하였습니다.
// 하지만 convertUSDToKRW 함수는 이 사실을 알지 못하고 문제는 여기에서 발생합니다.
updateExchangeRate(1000);
})
// 테스트 블록에 해당하는 describe에서 이미 환율이 변경되었음을 알리고 있지만
// convertUSDToKRW는 아직 이 사실을 알지 못합니다.
it("100달러를 환전하면 100,000원을 받는다.", () => {
// Given
const usd = 100;
// When
const krw = convertUSDToKRW(usd);
// Then
expect(krw).toBe(100000);
})
})
})
테스트는 통과했지만 아직 문제가 있는데요. convertUSDToKRW 함수가 외부 스코프에 존재하는 currentExchangeRate를 참조하고 있으므로, 이 값이 변하면 자신이 반환하는 결과도 변할 수 있습니다. 또 다른 테스트 케이스를 작성해야 한다고 가정하고, 아래 코드를 작성해 보았습니다.
describe('환전 함수 테스트', () => {
let currentExchangeRate = 1200;
describe("환율이 변경된 후", () => {
beforeAll(() => {
currentExchangeRate = 900;
})
it("100달러를 환전하면 90,000원을 받는다.", () => {
// Given
const usd = 100;
// When
const krw = convertUSDToKRW(usd);
// Then
expect(krw).toBe(90000);
})
})
describe("환율이 변경되기 전", () => {
// 이제 이 테스트는 실패합니다.
it("100달러를 환전하면 120,000원을 받는다.", () => {
// Given
const usd = 100;
// When
const krw = convertUSDToKRW(usd);
// Then
expect(krw).toBe(120000);
})
})
describe("환율이 변경된 후", () => {
/* 위의 예시와 코드 동일 ... */
})
})
이제 결과가 달라졌습니다. 첫 번째 테스트 케이스인 “100달러를 환전하면 120,000원을 받는다”가 테스트를 통과할 수 있었던 이유는 그 시점에서의 환율이 1,200원이었기 때문인데요. 하지만 새로운 테스트 블록이 가장 상위에 위치하게 되었고, 테스트는 가장 위에서 선언된 것부터 아래로 순차적으로 하나씩 실행하게 됩니다. 다시 말해 첫 테스트 블록에서 환율은 900원으로 변경된 상태로 첫 번째 테스트 케이스인 “100달러를 환전하면 90,000원을 받는다.”가 실행이 됩니다.
첫 번째 테스트 케이스는 환율이 900원으로 변경된 사실을 알고 있었기에 통과할 수 있었지만, 두 번째 테스트 케이스인 "100달러를 환전하면 120,000원을 받는다."라는 사실을 알지 못한 채 테스트를 수행하게 됩니다. 이 시점에서의 환율 정보는 1,200원이 아닌 900원이라서 테스트에 실패하게 됩니다.
이제 차이점이 보이시나요? 함수의 근본이 되는 로직이 변경하지 않았음에도, 반환 값이 언제든지 변경될 수 있다는 점의 원인은 함수 외부의 값을 반환 값에 영향이 주도록 관계를 맺어버렸기 때문입니다. 이는 매우 치명적인 결함으로, 이러한 문제를 발견했다면 빠르게 수정하는 것이 좋습니다.
먼저, convertUSDToKRW 함수를 수정해야 합니다. 더 이상 외부의 값을 참조하지 않고 함수의 인자로 전달받은 환율을 참조하도록 변경해 주는 작업이 필요합니다.
function convertUSDToKRW(usd, exchangeRate) {
return usd * exchangeRate;
}
그다음으로 테스트를 수정할 필요가 있습니다. 먼저 작성한 테스트 코드는 외부의 값을 참조하고 있던 함수의 동작 때문에, 테스트 환경 역시 그에 맞게 만들어줄 수밖에 없었습니다. 이제는 함수 인자로 전달해 테스트를 진행할 수 있도록 변경했으니, 그에 맞게 테스트 코드도 수정이 필요해졌습니다.
describe('환전 함수 테스트', () => {
it("100달러를 1:1200 환율로 환전하면 120,000원을 받는다.", () => {
// Given
const usd = 100;
const exchangeRate = 1200;
// When
const krw = convertUSDToKRW(usd, exchangeRate);
// Then
expect(krw).toBe(120000);
});
it("100달러를 1:1000 환율로 환전하면 100,000원을 받는다.", () => {
// Given
const usd = 100;
const exchangeRate = 1000;
// When
const krw = convertUSDToKRW(usd, exchangeRate);
// Then
expect(krw).toBe(100000);
});
it("100달러를 1:900 환율로 환전하면 90,000원을 받는다.", () => {
// Given
const usd = 100;
const exchangeRate = 900;
// When
const krw = convertUSDToKRW(usd, exchangeRate);
// Then
expect(krw).toBe(90000);
});
});
이제 어떤 입력값을 전달해도 항상 같은 값을 유지하는 함수로 재탄생했습니다. 추가로 convertUSDToKRW는 동일한 달러와 동일한 환율을 전달하면, 언제든지 몇 번을 호출하든 항상 같은 결과를 반환하게 되었습니다. 이러한 특성을 소프트웨어에서는 멱등성이라고 부릅니다. (아래에서 다시 한번 설명하겠습니다.)
함수 외부 스코프를 변경하는 것만큼 주의해야 할 점은 바로 외부 스코프를 참조하지 않는 것입니다. 값을 변경하는 것 역시 그 과정 속에 참조를 포함하고 있지만, 값을 변경해야 사이드 이펙트가 발생하지 않을까 하고 헷갈리기 쉽습니다. 하지만 참조하는 것만으로도 함수의 순수성이 깨지는 상황이 발생할 수 있는데, 이와 관련된 예시를 살펴보겠습니다.
// 한국의 은행이 현재 이용 가능한 시간인지 확인하는 함수입니다.
function isBankingHour() {
const now = new Date();
const hour = now.getHours();
// 오전 9시부터 오후 5시까지 은행 업무 가능
return hour >= 9 && hour < 17;
}
이 함수는 외부의 값을 변경하고 있진 않지만, 전역 객체(Window)에 포함된 Date 객체를 사용하고 있습니다. 이 역시도 isBankingHour 함수 입장에서는 외부에 존재하는 데이터인 셈이니 외부 스코프에 접근했다 볼 수 있습니다.
describe('은행 업무 시간 판단 함수 테스트', () => {
beforeAll(() => {
// useFakerTimer는 setTimer, Date 등 시간과 관련된 값을 임의로 조작할 수 있는 매우 유용한 함수입니다.
jest.useFakeTimers();
})
describe("한국에서 실행한 경우", () => {
beforeAll(() => {
// 한국 시간대 - 오전 10시
// setSystemTime 은 임의로 시간을 설정할 수 있는 Jest 함수입니다.
// useFakeTimer 함수를 사용하고 이 함수를 사용하면 원하는 시간대로 조작할 수 있습니다.
jest.setSystemTime(new Date('2023-01-01T10:00:00+09:00'));
})
it("한국에서는 현재 은행을 이용할 수 있다.", () => {
// Given - 없으면 생략 가능합니다.
// When
const isAvailable = isBankingHour();
// Then
expect(isAvailable).toBe(true);
})
})
describe("유럽에서 실행한 경우", () => {
beforeAll(() => {
// 유럽 시간대 - 오전 10시
// 한국은 오후 6시
jest.setSystemTime(new Date('2023-01-01T10:00:00+01:00'));
})
it("유럽에서는 현재 은행을 이용할 수 있다.", () => {
// Given - 없으면 생략 가능합니다.
// When
const isAvailable = isBankingHour();
// Then
expect(isAvailable).toBe(true);
})
})
});
이 테스트를 같이 살펴볼까요? 한국과 유럽, 각각의 지역을 설정해 오전 10시로 맞춰놓고 테스트를 진행했습니다. 이 테스트는 올바르게 진행됐을까요? 그렇지 않습니다. 코드는 정상 동작했지만 isBankingHour는 유럽이 아닌 한국의 은행 이용 가능 시간을 확인하는 함수입니다. 그렇기 때문에 유럽에서 오전 10시인 경우, 이때 한국은 오후 6시로 은행 업무 시간이 아니죠. 그래서 테스트는 실패했어야 합니다.
그럼에도 테스트가 통과할 수 있었던 이유는 isBankingHour는 Date 객체를 사용하고 있고, 이 객체의 특징은 코드가 실행되는 시스템의 시간을 따르게 됩니다. 그래서 어느 지역에서 실행하냐에 따라 다른 시간대를 얻게 되는 것입니다. UTC 시간대는 같아도 말이죠. 이 문제를 바로잡으려면 isBankingHour 함수가 Date 객체를 외부에서 조회하는 것이 아니라, 함수의 인자로 받게 변경하는 것이 필요합니다.
function isBankingHour(currentTime) {
const hour = currentTime.getHours();
return hour >= 9 && hour < 17;
}
이제 시간을 받아 계산하는 더욱 단순한 함수로 바뀌었으니, 테스트 코드도 변경해 주는 일이 남았습니다.
describe('은행 업무 시간 판단 함수 테스트', () => {
describe("한국에서 실행한 경우", () => {
it("한국에서는 오전 10시에 은행을 이용할 수 있다.", () => {
const currentTime = new Date('2023-01-01T10:00:00+09:00');
const isAvailable = isBankingHour(currentTime);
expect(isAvailable).toBe(true);
});
});
describe("유럽에서 실행한 경우", () => {
it("유럽에서는 오전 10시에 은행을 이용할 수 없다.", () => {
// 시간대는 여전히 유럽 시간대지만
// isBankingHour는 여기서 시간만을 추출하기에 오후 6시를 얻어낼 수 있습니다.
const currentTime = new Date('2023-01-01T10:00:00+01:00');
const isAvailable = isBankingHour(currentTime);
// 한국 시간으로 오후 6시이므로 은행 업무 시간이 아니고 결과는 false로 변경되었습니다.
expect(isAvailable).toBe(false);
});
});
});
순수 함수의 필요조건 중 나머지 세 가지 조건을 만족하면 함수는 제법 순수하게 보이게 됩니다. 마지막으로 함수 내에서 한 번 더 검증해야 하는 부분이 있는데, 바로 함수 내부에서 결과에 영향을 주는 임의의 값을 만들지 않는 것입니다. 아래 예시를 살펴볼까요?
function withdraw(amount, account) {
// 계좌에서 일정 금액을 출금하는 메소드를 호출하고 있습니다.
const updatedAccount = account.withdraw(amount);
// 출금을 완료하면 현재 시간으로 출금 기록을 시스템에 남깁니다.
logAccountActivity(updatedAccount, new Date());
return updatedAccount;
}
// 시스템에 로그를 남기는 함수입니다.
function logAccountActivity(account, date) {
// 현재 시간의 타임스탬프
const timestamp = date.getTime();
console.log(`계좌 활동: ${account.id}, 타임스탬프: ${timestamp}`);
}
출금을 하고 나면 출금 기록을 남기기 위해 logAccountActivity 함수를 실행합니다. 이 함수는 인자를 받아 로그를 남기는 작업이 유일한 기능입니다. 이 함수는 Date 객체를 전달받아 밀리세컨드로 현재 시간을 변환하고 있습니다. 위의 함수는 계좌 정보와 시간 객체를 받아 항상 undefined를 반환합니다. 외부의 데이터를 사용하고 있지도, 참조하고 있지도 않기에 순수 함수처럼 보이기도 합니다.
logAccountActivity 함수는 로그를 남긴다는(timestamp 반환) 기능적 측면에선 멱등성을 띠고 있습니다. 멱등성이란 특정 기능을 여러 차례 반복적으로 수행해도, 항상 매번 같은 결과로 이어져야 한다는 것을 말합니다. 로그를 남기는 저 함수는 어느 시간대에 호출이 되어도, 항상 성실하게 본인의 할 일만 묵묵히 수행할 뿐입니다. 훌륭한 멱등성을 지닌 함수라고 표현할 수 있죠.
그러나 순수 함수의 관점에서 보면, logAccountActivity는 순수하지 않습니다. Date 객체를 받아오지만 현재 시간 값을 반환하는 getTime 함수를 호출하면 호출하는 시점마다 다른 시간대가 나오기 때문에, 시스템에 적재되는 로그 값은 항상 달라집니다. 이처럼 순수 함수는 멱등성을 띠지만, 멱등성을 띤다고 항상 순수 함수인 건 아닙니다. 이 둘의 차이를 이해하는 것이 중요합니다.
describe('로깅 함수 테스트', () => {
beforeEach(() => {
// jest.fn 함수는 undefined를 반환하는 빈 함수로 Jest에서 제공하는 모킹 함수입니다.
// 이 함수를 사용하게 되면 함수가 몇 번 호출되었는지, 어떤 인자 값을 전달받았는지 등을 확인할 수 있습니다.
// console.log 호출을 가로채기 위해 함수를 모킹합니다.
console.log = jest.fn();
});
// 로깅 간격을 5분이라고 가정해 보겠습니다.
// 5분이 지나기 전에 로깅 함수가 실행되면 이전의 로그 기록으로 출력하는 함수라고 해보겠습니다.
// 시간에 대한 처리는 따로 하지 않았기 때문에
// 출금 함수 withdraw에서 계좌 잔액이 처리되는 부분은 이 테스트에선 제외하고 살펴보겠습니다.
it('출금을 시도하면 시스템에 로그를 남긴다.', () => {
// Given
const amount = 500;
const account = { number: '123456', withdraw: jest.fn() };
const date = new Date();
// When
withdraw(amount, account)
// Then
expect(console.log).toHaveBeenNthCalledWith(1, `계좌 활동: 123456, 타임스탬프: ${date.getTime()}`);
});
});
이 테스트는 통과하지 못할 가능성이 높습니다. withdraw 함수를 호출하기 전 현재 시간을 date 변수에 만들어두었고, withdraw 함수 내부에서는 출금 처리를 하고 나서 로그를 남기기 위해 또 다른 시간 데이터를 생성합니다. 그리고 logAccountActivity 함수는 withdraw 함수에서 만들어진 새로운 시간을 받아서 로그를 남기게 됩니다. 테스트 결과는 어떻게 될까요?
withdraw 함수가 생성하는 Date 객체는 테스트 실행 이전에 생성한 Date 객체와 약간의 시간 차이를 두고 생성됐을 가능성이 높습니다. 컴퓨팅 속도가 아주 빠르지만 약간의 시간이 흘렀기에, 다른 시간을 가리키고 있을 확률이 높습니다. 이처럼 외부의 값을 사용하지 않더라도 호출할 때마다 달라지는 값을 사용해 결과에 영향을 주면, 그 함수는 순수하지 않을 가능성이 높아지니 이 역시 주의해야 합니다.
지금까지 유닛 테스트를 작성하는 방법, 유닛 테스트를 작성할 때 주의할 점 등을 알아보았습니다. 유닛 테스트를 작성하기 위해 필요한 작업은 테스트 대상이 되도록 순수한 형태의 함수로 존재해야 한다는 것입니다. 함수가 순수하지 않으면 예상하지 못한 곳에서 사이드 이펙트를 일으킬 수 있어, 코드 흐름에 좋지 않은 영향을 줍니다.
이처럼 유닛 테스트는 간단한 듯 보여도 깊이 이해해야 하는 내용도 있으니, 시간을 들여 천천히 학습해 보시길 바랍니다. 다음에 또 다른 테스트에 관한 내용으로 찾아오겠습니다.
요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.