📖 개요와 목표
CAP Node.js 프로젝트에서 jest만 설치하고 서비스 핸들러를 테스트하면 service is undefined, no model loaded 같은 에러로 시작부터 막히는 경우가 많습니다. 이 글은 구매 주문(PurchaseOrder) 서비스를 예제로, @sap/cds/test 모듈을 사용해 CAP 서비스 핸들러 단위 테스트를 3단계로 완성하는 실전 예제입니다.
- jest 단독 테스트가 CAP에서 실패하는 3가지 패턴을 이해한다
cds.test()의 내부 동작 원리(인프로세스 서버 부트스트랩)를 파악한다- before/after 훅, 핸들러 목(mock), 엔티티 read/write 테스트를 직접 작성한다
- 프로덕션 수준의 테스트 격리·인증·성능 설정까지 적용한다
📚 시작 전에 알아둘 것
CDS 모델링(entity, service 정의)과 CAP Node.js 핸들러 등록 방식(srv.on, srv.before, srv.after)에 대한 기본 이해가 필요합니다. jest의 describe / it / expect 문법과 async/await 패턴을 알고 있다면 코드를 바로 따라올 수 있습니다. OData 요청 구조(GET/POST, $filter)를 알면 검증 코드 이해에 도움이 됩니다.
🔧 환경 / 버전 / 준비물
이 예제는 다음 환경을 기준으로 작성했습니다.
- Node.js 20 LTS (18 이상 권장)
- @sap/cds 8.x — CAP Node.js 런타임 (7.x에서도 대부분 동일하게 동작)
- @cap-js/sqlite — 테스트용 인메모리 DB (cds 8부터 권장되는 SQLite 어댑터)
- jest 29.x — 테스트 러너
{
"devDependencies": {
"@cap-js/sqlite": "^1",
"jest": "^29"
},
"scripts": {
"test": "jest --runInBand"
}
}
@sap/cds/test는 별도 패키지가 아니라 @sap/cds 안에 포함된 테스트 유틸리티입니다. 즉 추가 설치 없이 const cds = require('@sap/cds') 후 cds.test(...)를 호출하면 됩니다. mocha에서도 동일하게 사용할 수 있지만 이 글은 jest 기준으로 설명합니다.
💡 핵심 개념 — jest 단독 vs cds.test
CAP 서비스는 단순한 JS 모듈이 아닙니다. 서비스 핸들러(srv/purchase-service.js)는 CDS 모델이 로드되고, 서비스가 serve되고, DB가 배포된 뒤에야 의미를 갖는 코드입니다. 비유하자면 핸들러 파일은 "배우의 대본"이고, CAP 런타임은 "무대와 조명"입니다. jest만으로 require('../srv/purchase-service') 하는 것은 무대 없이 대본만 낭독시키는 것과 같습니다.
jest 단독 테스트가 실패하는 대표적인 3가지 패턴은 다음과 같습니다.
- 서비스 미부트스트랩 — 핸들러 모듈을 직접 require하면
cds.ApplicationService인스턴스가 없어srv.on is not a function, 혹은 모델이 없어no model loaded가 발생합니다. - DB 미배포 — 핸들러 내부의
SELECT.from(PurchaseOrders)가 실행될 DB가 없습니다. 인메모리 SQLite에 스키마를 배포(deploy)하는 단계가 빠져 있으면 쿼리는 즉시 실패합니다. - 싱글톤 상태 오염 —
cds는 프로세스 전역 싱글톤입니다. 테스트 파일마다 수동으로 서버를 띄우면 포트 충돌, open handle 경고(jest가 종료되지 않음), 파일 간 상태 공유 문제가 생깁니다.
cds.test()는 이 세 가지를 한 번에 해결합니다. 내부 동작을 요약하면 이렇습니다.
- 인프로세스 서버 기동 — 별도 프로세스를 fork하지 않고, 테스트와 같은 Node 프로세스 안에서
cds serve와 동일한 부트스트랩을 수행합니다. 그래서 테스트 코드에서cds.connect.to()로 서비스 인스턴스에 직접 접근하고, 핸들러 내부에 breakpoint를 걸 수도 있습니다. - 인메모리 DB 자동 배포 — 프로파일에 따라 SQLite
:memory:DB에 CDS 스키마와test/data·db/data의 CSV 초기 데이터를 배포합니다. - 훅 자동 연결 — jest의
beforeAll / afterAll에 서버 시작·종료를 자동 등록하므로 open handle 없이 깔끔하게 종료됩니다. - HTTP 파사드 제공 — 반환 객체에서 구조 분해한
GET, POST, PATCH, DELETE는 axios 기반 헬퍼로, 기동된 서버의 임의 포트를 자동으로 바라봅니다.
정리하면, jest는 "러너"로 그대로 두고 CAP 세계의 부트스트랩은 cds.test()에 위임하는 것이 핵심입니다.
💻 실전 코드 3단계 — PurchaseOrder 서비스 테스트
예제 모델과 서비스는 다음과 같다고 가정합니다.
// db/schema.cds
namespace acme.procure;
entity PurchaseOrders {
key ID : UUID;
orderNo : String(20);
supplier : String(60);
totalAmount : Decimal(15,2);
status : String(10) default 'OPEN'; // OPEN | APPROVED | REJECTED
}
// srv/purchase-service.cds
using acme.procure as db from '../db/schema';
service PurchaseService {
entity PurchaseOrders as projection on db.PurchaseOrders;
action approve(ID: UUID) returns String;
}
1단계 — 기본 예제: 서버 기동과 엔티티 read/write
// test/purchase-service.test.js
const cds = require('@sap/cds');
describe('PurchaseService 기본 동작', () => {
// 프로젝트 루트를 지정하면 serve + in-memory deploy까지 수행
const test = cds.test(__dirname + '/..');
const { GET, POST, expect: cexpect } = test;
it('구매 주문 목록을 조회한다', async () => {
const { status, data } = await GET('/odata/v4/purchase/PurchaseOrders');
expect(status).toBe(200);
expect(Array.isArray(data.value)).toBe(true);
});
it('신규 구매 주문을 생성한다', async () => {
const { status, data } = await POST('/odata/v4/purchase/PurchaseOrders', {
orderNo: 'PO-1001', supplier: 'Hanbit Parts', totalAmount: 2500.0
});
expect(status).toBe(201);
expect(data.status).toBe('OPEN'); // default 값 검증
});
});
cds.test(root) 한 줄이 서버 기동, DB 배포, 종료 훅 등록을 모두 처리합니다. 포트는 0번(임의 포트)으로 열리므로 병렬 실행 시 충돌 걱정이 없습니다.
2단계 — 실무 시나리오: 에러 처리와 로깅 검증
승인 액션에 검증 로직이 있는 핸들러를 테스트합니다. 에러 응답 코드와 로그 출력까지 검증하는 것이 실무 포인트입니다.
// srv/purchase-service.js (핸들러 요지)
const cds = require('@sap/cds');
const LOG = cds.log('purchase');
module.exports = class PurchaseService extends cds.ApplicationService {
init() {
const { PurchaseOrders } = this.entities;
this.before('CREATE', PurchaseOrders, (req) => {
if (req.data.totalAmount <= 0)
req.reject(400, '금액은 0보다 커야 합니다');
});
this.on('approve', async (req) => {
const po = await SELECT.one.from(PurchaseOrders).where({ ID: req.data.ID });
if (!po) return req.error(404, '구매 주문이 없습니다');
if (po.status !== 'OPEN') return req.reject(409, '이미 처리된 주문입니다');
await UPDATE(PurchaseOrders, req.data.ID).with({ status: 'APPROVED' });
LOG.info('approved', po.orderNo);
return 'APPROVED';
});
return super.init();
}
};
// test/purchase-errors.test.js
const cds = require('@sap/cds');
describe('에러와 로그 검증', () => {
const test = cds.test(__dirname + '/..');
const { POST } = test;
let orderId;
beforeAll(async () => {
const srv = await cds.connect.to('PurchaseService');
const [row] = await srv.create('PurchaseOrders').entries({
orderNo: 'PO-2001', supplier: 'K-Steel', totalAmount: 900
});
orderId = row.ID;
});
it('금액 0 이하 생성은 400을 반환한다', async () => {
await expect(
POST('/odata/v4/purchase/PurchaseOrders',
{ orderNo: 'PO-BAD', supplier: 'X', totalAmount: -1 })
).rejects.toThrow(/400/);
});
it('승인 시 로그가 남고, 중복 승인은 409', async () => {
const logs = test.log(); // 콘솔 출력 캡처
await POST(`/odata/v4/purchase/approve`, { ID: orderId });
expect(logs.output).toMatch(/approved.*PO-2001/);
await expect(POST(`/odata/v4/purchase/approve`, { ID: orderId }))
.rejects.toThrow(/409/);
logs.release();
});
});
test.log()는 콘솔 출력을 가로채 logs.output으로 노출하므로, 감사 로그가 실제로 기록되는지까지 단위 테스트로 잡아낼 수 있습니다.
3단계 — 프로덕션: 핸들러 목·데이터 리셋·인증
// test/purchase-prod.test.js
const cds = require('@sap/cds');
describe('프로덕션 수준 테스트', () => {
const test = cds.test(__dirname + '/..');
const { GET, POST } = test;
// 각 테스트 후 DB를 초기 CSV 상태로 되돌려 테스트 간 격리 확보
afterEach(test.data.reset);
it('외부 승인 API 호출을 목으로 대체한다', async () => {
const approval = await cds.connect.to('ApprovalService'); // 원격 서비스
const spy = jest.spyOn(approval, 'send')
.mockResolvedValue({ decision: 'APPROVED' });
const { data } = await POST('/odata/v4/purchase/PurchaseOrders',
{ orderNo: 'PO-3001', supplier: 'Mock Co', totalAmount: 10 });
expect(spy).not.toHaveBeenCalledWith('reject');
expect(data.status).toBe('OPEN');
spy.mockRestore();
});
it('권한 없는 사용자는 403을 받는다', async () => {
// mocked auth: package.json의 cds.requires.auth.users에 정의된 테스트 사용자
await expect(
GET('/odata/v4/purchase/PurchaseOrders',
{ auth: { username: 'viewer-only', password: '' } })
).rejects.toThrow(/403/);
});
});
핵심은 세 가지입니다. (1) test.data.reset으로 테스트 간 데이터 격리 — 순서 의존적 테스트를 방지합니다. (2) 원격 서비스는 jest.spyOn(srv, 'send')로 목 처리 — 네트워크 없이 결정적(deterministic) 테스트가 됩니다. (3) mocked authentication으로 권한 시나리오 검증 — 테스트 사용자에 롤을 부여해 @requires 애너테이션까지 커버합니다. 성능 측면에서는 jest --runInBand 또는 maxWorkers 제한이 일반적으로 권장되는데, 파일마다 서버가 새로 부트되므로 워커 과다 시 오히려 느려질 수 있기 때문입니다.
⚠️ 흔한 실수 / 트러블슈팅 FAQ
- Q1.
cds.test is not a function이 나옵니다. — 프로젝트 로컬이 아닌 전역@sap/cds가 로드됐거나 구버전(6.x 미만)일 가능성이 큽니다.npm ls @sap/cds로 버전을 확인하고 devDependencies가 아닌 dependencies에 있는지 점검하세요. - Q2. "Jest did not exit one second after..." 경고가 뜹니다. —
cds.test()를describe밖 최상위에서 호출했거나, 수동으로cds.serve()를 병행 호출한 경우입니다.cds.test()하나만 사용하고 종료는 자동 훅에 맡기세요. - Q3. 두 번째 테스트 파일부터 데이터가 꼬입니다. — jest는 파일별로 별도 워커를 쓰지만, 같은 파일 안에서는 DB 상태가 공유됩니다.
afterEach(test.data.reset)으로 CSV 초기 상태로 리셋하는 것이 일반적으로 권장됩니다. - Q4.
GET결과 검증에서 404가 납니다. — cds 7 이후 기본 OData 경로가/odata/v4/<service>로 바뀌었습니다. 구버전 예제의/purchase/...경로를 그대로 쓰면 실패합니다. - Q5. 핸들러 파일을 직접 require해서 단위 테스트하면 안 되나요? — 클래스 기반 핸들러라면 가능은 하지만, CQL 쿼리·이벤트 컨텍스트가 모두 목이 되어 테스트 가치가 낮아집니다. 인프로세스 서버 기반의
cds.test가 비용 대비 신뢰도가 높습니다.
🚀 이후 확장 주제
단위 테스트가 자리 잡았다면 다음 주제로 확장해 보세요. (1) cds bind와 하이브리드 테스트 — 실제 SAP HANA Cloud 인스턴스에 바인딩한 통합 테스트, (2) CI 파이프라인 연동 — SAP Continuous Integration and Delivery 서비스에서 npm test 단계 추가, (3) OData 계약 테스트 — $metadata 스냅샷 비교로 API 호환성 회귀 감지, (4) @cap-js/audit-logging 플러그인 동작 검증. 특히 CI에서는 SQLite와 HANA의 SQL 방언 차이를 감안해 하이브리드 테스트를 병행하는 것이 일반적으로 권장됩니다.
📚 참고 링크 모음
- CAP 문서 — Testing with cds.test (Node.js)
- CAP 문서 — Providing Services (핸들러 등록)
- CAP 문서 — Mocked Authentication
- SAP Help Portal — Cloud Application Programming Model
- SAP Help Portal — Developing Applications and Services on SAP BTP
- SAP Help Portal — Continuous Integration and Delivery
- Jest 공식 문서 — Getting Started
댓글 0
아직 댓글이 없습니다.