1. 개요 및 이 글에서 다룰 것
UI5 애플리케이션의 단위 테스트를 작성할 때 가장 어려운 부분 중 하나는 외부 의존성을 가진 함수를 어떻게 격리해서 테스트할 것인가입니다. OData 서비스 호출, MessageToast 출력, Router의 navTo 같은 함수들은 실제로 실행되면 테스트가 느려지거나 환경에 의존하게 됩니다. 이 글에서는 sinon.js의 Stub과 Spy를 활용해 UI5 QUnit 테스트에서 함수를 모킹하고 호출을 감시하는 방법을 단계별로 학습합니다.
- Stub과 Spy의 개념적 차이를 설명할 수 있다
- sinon.stub()으로 함수의 반환값을 제어할 수 있다
- sinon.spy()로 함수 호출 횟수와 인자를 검증할 수 있다
- restore와 sandbox 패턴으로 테스트 격리를 구현할 수 있다
- Controller 메서드 테스트에 sinon을 적용할 수 있다
2. 사전 가정
본 글을 원활히 따라가려면 다음 지식이 필요합니다. UI5 QUnit 테스트의 기본 구조(QUnit.module, QUnit.test, assert)를 작성해 본 경험, JavaScript의 함수 일급 객체 및 클로저 개념, UI5 Controller와 sap.ui.define 모듈 시스템에 대한 이해, 그리고 단위 테스트(unit test)와 통합 테스트(integration test)의 차이에 대한 기본적인 감각이 권장됩니다.
3. 환경 / 버전 / 준비물
본 가이드의 코드 예제는 일반적으로 다음 환경에서 동작하도록 작성되었습니다.
- SAPUI5 1.120 LTS (OpenUI5 호환)
- sinon.js 4.x 이상 (UI5 1.96+ 기본 포함)
- QUnit 2.x
- UI5 Tooling (@ui5/cli) 3.x
- Node.js 18 LTS
UI5는 자체 CDN(resources/sap/ui/thirdparty/sinon-4.js)으로 sinon을 제공하므로 별도 설치 없이 sap.ui.define에 의존성으로 추가만 하면 사용 가능합니다. ui5 serve --config ui5-test.yaml로 테스트 러너를 띄우거나 Karma + ui5-middleware-livereload 조합도 일반적으로 사용됩니다.
specVersion: "3.0"
metadata:
name: my.app.test
type: application
framework:
name: SAPUI5
version: "1.120.0"
libraries:
- name: sap.m
- name: sap.ui.core
server:
customMiddleware:
- name: ui5-middleware-livereload
afterMiddleware: compression4. 핵심 개념
4.1 Spy: 카메라처럼 관찰하기
Spy는 실제 함수의 동작을 그대로 유지하면서 호출 정보만 기록합니다. 마치 CCTV처럼 누가, 언제, 어떤 인자로 함수를 호출했는지 감시할 뿐 함수 자체의 흐름은 막지 않습니다. 원본 함수를 보존해야 하는 상황(예: 이미 잘 작동하는 로깅 함수의 호출 여부만 확인)에 적합합니다.
4.2 Stub: 대역 배우로 교체하기
Stub은 원본 함수를 완전히 다른 동작으로 교체합니다. 영화에서 위험한 장면을 대역 배우가 연기하듯, 네트워크 호출이나 DB 접근처럼 테스트하기 어려운 함수를 미리 정해진 반환값으로 바꿔치기합니다. stub.returns(value), stub.throws(error), stub.resolves(promise) 같은 메서드로 동작을 제어할 수 있습니다.
4.3 Mock과의 차이
Spy는 "실제로 일어난 일을 관찰"하고, Stub은 "원하는 결과를 강제"하며, Mock은 "미리 정해진 기대치를 검증"하는 도구입니다. Mock은 stub + 사전 expectation의 조합이라 볼 수 있습니다.
4.4 Sandbox 패턴
여러 stub/spy를 일일이 restore하는 번거로움을 줄이기 위해 sinon은 sandbox라는 컨테이너를 제공합니다. sinon.createSandbox()로 생성한 sandbox에서 만든 모든 fake는 sandbox.restore() 한 번으로 일괄 복원됩니다. QUnit의 beforeEach/afterEach 훅과 결합하면 테스트 격리가 한층 깔끔해집니다.
5. 실전 코드 3단계
5.1 1단계: sinon.stub으로 함수 대체
OData를 호출하는 Controller의 onSave 메서드를 테스트한다고 가정합니다. 실제 백엔드 없이도 "성공" 경로와 "실패" 경로를 검증해야 합니다. sinon.stub으로 모델의 create 메서드를 가짜로 만들고 반환값을 통제합니다.
sap.ui.define([
"sap/ui/thirdparty/sinon",
"sap/ui/thirdparty/sinon-qunit",
"my/app/controller/Detail.controller"
], function (sinon, sinonQunit, DetailController) {
"use strict";
QUnit.module("Detail Controller - onSave");
QUnit.test("create 호출 시 success 콜백이 동작한다", function (assert) {
// Given: Controller 인스턴스와 가짜 모델 준비
var oController = new DetailController();
var oFakeModel = { create: function () {} };
// create 메서드를 stub으로 교체
var oCreateStub = sinon.stub(oFakeModel, "create")
.callsFake(function (sPath, oData, mParams) {
mParams.success({ ID: 42 });
});
oController.getView = function () {
return { getModel: function () { return oFakeModel; } };
};
// When
oController.onSave({ Name: "Test" });
// Then
assert.ok(oCreateStub.calledOnce, "create가 한 번 호출되어야 한다");
assert.strictEqual(oCreateStub.firstCall.args[0], "/Products", "경로 검증");
oCreateStub.restore();
});
});핵심은 callsFake로 stub 내부에서 success 콜백을 즉시 실행시켜 비동기 흐름을 동기처럼 다룬다는 점입니다. calledOnce, firstCall.args로 호출 결과를 검증합니다.
5.2 2단계: sinon.spy로 호출 감시 + 에러 처리
실무에서는 단순 호출 확인을 넘어 "에러 발생 시 MessageToast가 적절한 메시지로 호출되었는가"를 검증해야 합니다. MessageToast.show는 실제로 호출되어도 사이드이펙트가 미미하므로 spy로 감시만 합니다.
sap.ui.define([
"sap/ui/thirdparty/sinon",
"sap/m/MessageToast",
"sap/base/Log",
"my/app/controller/Detail.controller"
], function (sinon, MessageToast, Log, DetailController) {
"use strict";
QUnit.module("Detail Controller - 에러 처리", {
beforeEach: function () {
this.oToastSpy = sinon.spy(MessageToast, "show");
this.oLogSpy = sinon.spy(Log, "error");
this.oController = new DetailController();
},
afterEach: function () {
this.oToastSpy.restore();
this.oLogSpy.restore();
}
});
QUnit.test("create 실패 시 MessageToast와 Log.error가 호출된다", function (assert) {
var oFakeModel = { create: function () {} };
sinon.stub(oFakeModel, "create").callsFake(function (sPath, oData, mParams) {
mParams.error({ message: "Network down" });
});
this.oController.getView = function () {
return { getModel: function () { return oFakeModel; } };
};
this.oController.onSave({ Name: "Broken" });
assert.strictEqual(this.oToastSpy.callCount, 1, "toast가 한 번 호출");
assert.ok(
this.oToastSpy.calledWith(sinon.match(/저장 실패/)),
"toast 메시지가 '저장 실패'를 포함"
);
assert.ok(this.oLogSpy.calledOnce, "Log.error 한 번 호출");
});
});sinon.match를 사용하면 정확한 문자열 비교 대신 정규식이나 부분 일치로 유연한 검증이 가능합니다. calledWith는 인자 매칭에, callCount는 호출 횟수 확인에 사용합니다.
5.3 3단계: sandbox로 테스트 격리 + 성능 고려
프로덕션급 테스트 suite에서는 stub이 누락되어 다음 테스트에 영향을 주는 경우가 잦습니다. sandbox 패턴으로 격리하고, 비동기 흐름은 fake timer로 결정론적으로 다루는 것이 일반적으로 권장됩니다.
sap.ui.define([
"sap/ui/thirdparty/sinon",
"sap/m/MessageToast",
"my/app/controller/List.controller"
], function (sinon, MessageToast, ListController) {
"use strict";
QUnit.module("List Controller - sandbox 패턴", {
beforeEach: function () {
this.oSandbox = sinon.createSandbox();
this.oClock = this.oSandbox.useFakeTimers();
this.oController = new ListController();
},
afterEach: function () {
this.oSandbox.restore(); // stub/spy/timer 일괄 복원
this.oController.destroy && this.oController.destroy();
}
});
QUnit.test("검색 입력 디바운스 후 1회만 검색이 실행된다", function (assert) {
var oSearchStub = this.oSandbox.stub(this.oController, "_executeSearch");
// 빠르게 3번 입력
this.oController.onSearchInput({ getParameter: function () { return "a"; } });
this.oController.onSearchInput({ getParameter: function () { return "ab"; } });
this.oController.onSearchInput({ getParameter: function () { return "abc"; } });
// 300ms 디바운스 가정
this.oClock.tick(299);
assert.strictEqual(oSearchStub.callCount, 0, "디바운스 종료 전 호출 없음");
this.oClock.tick(2);
assert.strictEqual(oSearchStub.callCount, 1, "디바운스 종료 후 1회만 호출");
assert.ok(oSearchStub.calledWith("abc"), "마지막 입력값으로 호출");
});
QUnit.test("sandbox는 테스트 간 격리를 보장한다", function (assert) {
// 이전 테스트의 stub이 여기엔 영향 없음 (sandbox.restore 덕분)
assert.ok(typeof this.oController._executeSearch === "function", "원본 복원됨");
});
});보안 측면에서 sandbox는 의도치 않게 전역 객체(예: window.fetch)를 영구 변조하는 사고를 방지합니다. CI/CD 파이프라인에서 테스트 순서가 바뀌어도 결과가 동일해야 한다는 결정론적 테스트 원칙과도 부합합니다.
6. 흔한 실수 / 트러블슈팅
FAQ 1: restore를 잊어서 다음 테스트가 깨집니다
가장 빈번한 실수입니다. sinon.stub(obj, "method") 직접 호출 후 restore를 누락하면 그 메서드는 테스트 전체에 걸쳐 stub 상태로 남습니다. 해결책은 항상 sandbox를 사용하거나, 변수에 할당해 afterEach에서 restore하는 것입니다. sinon.restore() 전역 함수는 sandbox 외 stub에는 동작하지 않으니 주의가 필요합니다.
FAQ 2: "Already wrapped" 에러가 발생합니다
TypeError: Attempted to wrap method which is already wrapped 메시지는 같은 메서드를 이중으로 stub 했을 때 발생합니다. 보통 이전 테스트의 restore 누락이 원인이며, sandbox 패턴으로 전환하면 대부분 해결됩니다. 일시적 회피책으로 obj.method.restore && obj.method.restore()를 stub 전에 호출하는 방법도 있습니다.
FAQ 3: spy인지 stub인지 헷갈립니다
판단 기준은 명확합니다. 원본 동작이 실행되어도 괜찮은가? 그렇다면 spy, 아니라면 stub입니다. 예: console.log는 spy(찍혀도 무해), fetch는 stub(실제 네트워크 호출 방지). 또한 stub은 spy를 포함하는 상위 개념이라 stub에도 calledOnce, calledWith 같은 spy API가 모두 사용 가능합니다.
FAQ 4: 비동기 함수의 반환값을 stub하고 싶어요
stub.resolves(value)로 Promise를 반환하는 함수를, stub.rejects(error)로 실패 Promise를 시뮬레이션할 수 있습니다. async/await 코드 테스트 시 QUnit.test("...", async function(assert) {...}) 패턴과 결합하면 깔끔합니다.
7. 다음 단계 / 관련 주제
본 가이드에서 다룬 stub/spy/sandbox 외에도 sinon은 풍부한 기능을 제공합니다. 다음 주제로 학습을 확장해 보시기 바랍니다.
- Fake Timers:
useFakeTimers()로 setTimeout, setInterval, Date를 제어해 디바운스/스로틀 테스트 - Fake XHR / fake-server: 실제 OData 응답을 시뮬레이션하는 가짜 서버 구성
- OPA5 + sinon: 통합 테스트에서 백엔드 stub과 UI 자동화를 결합
- MockServer: UI5 자체 제공
sap.ui.core.util.MockServer와 sinon의 역할 분담 - Test Code Coverage: BlanketJS 또는 Karma-coverage와 통합한 커버리지 측정
댓글 0
아직 댓글이 없습니다.