개요 — jest만으로는 왜 CAP 테스트가 실패하는가
SAP CAP(Cloud Application Programming Model) 프로젝트에서 jest를 설치하고 서비스 핸들러를 테스트하려다 Cannot read properties of undefined, No service definition found 같은 오류를 만나는 경우가 많습니다. 원인은 간단합니다. CAP 서비스는 CDS 모델 로딩, 서비스 프로바이더 등록, 인메모리 DB 배포라는 부트스트랩 과정을 거쳐야 동작하는데, jest 단독으로는 이 과정이 실행되지 않기 때문입니다. 이 글에서는 @sap/cds/test 모듈이 이 문제를 어떻게 해결하는지 내부 동작 원리를 짚고, 주문 처리(OrderService) 시나리오로 단위 테스트를 3단계로 작성합니다.
- jest 단독 설정 시 발생하는 실패 패턴과 원인 이해
cds.test()부트스트랩의 동작 원리 파악- 기본 → 에러 처리 → 프로덕션 수준까지 3단계 테스트 작성
- 테스트 격리(데이터 리셋)와 인증 목(mock) 적용
시작 전에 갖춰야 할 배경
CDS 모델링(entity, service 정의)과 Node.js 기반 CAP 커스텀 핸들러(srv.before, srv.on) 작성 경험이 있으면 충분합니다. jest의 describe/it/expect 기본 문법과 async/await를 이해하고 있다고 가정합니다. REST API 호출 개념(GET/POST, 상태 코드)도 필요합니다.
환경과 준비물
이 글의 예제는 다음 환경 기준으로 작성되었습니다.
- Node.js 18 LTS 이상 (CAP Node.js 런타임 요구사항)
@sap/cds7.x 이상 —@sap/cds/test는 별도 패키지가 아니라@sap/cds에 포함된 서브모듈입니다- jest 29.x (mocha도 지원되지만 이 글은 jest 기준)
- 테스트용 DB: SQLite in-memory (
@cap-js/sqlite, devDependency로 설치 권장)
{
"devDependencies": {
"@cap-js/sqlite": "^1",
"jest": "^29"
},
"scripts": {
"test": "jest --runInBand"
}
}
--runInBand는 여러 테스트 파일이 동시에 CAP 서버를 띄우며 포트 충돌을 일으키는 것을 막기 위해 일반적으로 권장되는 옵션입니다.
핵심 개념 — @sap/cds/test가 하는 일
CAP 서비스 핸들러는 일반 함수처럼 보이지만, 실제로는 CAP 런타임이라는 "무대" 위에서만 동작하는 배우와 같습니다. 무대(런타임)가 없으면 배우(핸들러)는 대본(CDS 모델)도, 소품(DB 커넥션)도 받지 못합니다. jest만 설치하고 핸들러 파일을 require하면 무대 없이 배우만 호출하는 셈이라, req.data가 비어 있거나 SELECT.from(Entity)가 실행할 DB가 없어 실패합니다.
cds.test()를 호출하면 다음 과정이 자동으로 수행됩니다.
- 모델 컴파일 — 지정한 프로젝트 루트의
db/,srv/폴더에서 CDS 모델을 로드하고 CSN으로 컴파일합니다. - 인메모리 DB 배포 — 테스트 프로파일에서 SQLite in-memory DB에 스키마를 배포하고,
db/data/*.csv초기 데이터를 적재합니다. - 서비스 서빙 — 서비스 구현 파일(핸들러)을 연결해 임의의 포트로 실제 HTTP 서버를 띄웁니다.
- 테스트 클라이언트 제공 — axios 기반의
GET,POST,PATCH,DELETE헬퍼를 반환해, 띄워진 서버에 바로 요청을 보낼 수 있게 합니다.
즉 cds.test()는 단위 테스트 프레임워크를 대체하는 것이 아니라, jest가 실행할 수 있도록 CAP 런타임 무대를 세팅해 주는 부트스트랩 도구입니다. 이 방식은 핸들러를 고립시켜 목(mock)으로 감싸는 전통적 단위 테스트보다 실제 요청 경로(프로토콜 어댑터 → 디스패치 → before/on/after 핸들러 → DB)를 그대로 통과하므로, CAP 커뮤니티에서는 이런 스타일을 흔히 "적정 수준의 통합이 포함된 단위 테스트"로 부르며 권장하는 편입니다.
실전 코드 3단계 — OrderService 테스트
다음 CDS 모델과 핸들러를 테스트 대상으로 삼습니다. 주문 생성 시 재고를 검증하고 차감하는 전형적인 시나리오입니다.
// srv/order-service.cds
using { my.shop as db } from '../db/schema';
service OrderService {
entity SalesOrders as projection on db.SalesOrders;
entity Products as projection on db.Products;
action submitOrder(product: Products:ID, quantity: Integer) returns { orderID: UUID };
}
// srv/order-service.js
const cds = require('@sap/cds')
module.exports = class OrderService extends cds.ApplicationService {
init() {
const { Products, SalesOrders } = this.entities
this.on('submitOrder', async (req) => {
const { product, quantity } = req.data
if (quantity <= 0) return req.error(400, 'INVALID_QUANTITY')
const p = await SELECT.one.from(Products, product).columns('stock')
if (!p) return req.error(404, `Product ${product} not found`)
if (p.stock < quantity) return req.error(409, 'SOLD_OUT')
await UPDATE(Products, product).with({ stock: { '-=': quantity } })
const [order] = await INSERT.into(SalesOrders)
.entries({ product_ID: product, quantity, status: 'NEW' })
return { orderID: order.ID }
})
return super.init()
}
}
1단계: 부트스트랩과 기본 조회 테스트. 가장 먼저 서버가 정상 기동되고 엔티티가 서빙되는지 확인합니다. cds.test()는 반드시 테스트 파일의 최상단(모듈 스코프)에서 호출해야 jest의 라이프사이클 훅에 올바르게 연결됩니다.
// test/order-service.test.js
const cds = require('@sap/cds')
const { GET, expect: cexpect } = cds.test(__dirname + '/..')
describe('OrderService 기본 동작', () => {
it('Products 엔티티를 서빙한다', async () => {
const { status, data } = await GET('/odata/v4/order/Products')
expect(status).toBe(200)
expect(data.value.length).toBeGreaterThan(0)
})
it('서비스 인스턴스에 직접 연결할 수 있다', async () => {
const srv = await cds.connect.to('OrderService')
const products = await srv.read('Products')
expect(products[0]).toHaveProperty('stock')
})
})
여기서 두 가지 스타일을 볼 수 있습니다. HTTP 헬퍼(GET)는 프로토콜 계층까지 검증하고, cds.connect.to()는 HTTP를 거치지 않고 서비스 API를 직접 호출해 더 빠릅니다. 핸들러 로직 자체가 관심사라면 후자가 단위 테스트에 가깝습니다.
2단계: 커스텀 핸들러의 정상/에러 경로 테스트. 실무에서는 성공 케이스보다 에러 케이스가 더 중요합니다. submitOrder 액션의 재고 부족, 잘못된 수량, 존재하지 않는 상품을 각각 검증합니다.
describe('submitOrder 액션', () => {
let srv, PRODUCT_ID = 'a1b2c3d4-0000-0000-0000-000000000001'
beforeAll(async () => { srv = await cds.connect.to('OrderService') })
it('재고가 충분하면 주문을 생성하고 재고를 차감한다', async () => {
const before = await SELECT.one.from('my.shop.Products', PRODUCT_ID)
const res = await srv.send('submitOrder', { product: PRODUCT_ID, quantity: 2 })
expect(res.orderID).toBeDefined()
const after = await SELECT.one.from('my.shop.Products', PRODUCT_ID)
expect(after.stock).toBe(before.stock - 2)
})
it('재고 부족 시 409를 반환한다', async () => {
await expect(
srv.send('submitOrder', { product: PRODUCT_ID, quantity: 99999 })
).rejects.toMatchObject({ code: '409' })
})
it('수량이 0 이하이면 400을 반환한다', async () => {
await expect(
srv.send('submitOrder', { product: PRODUCT_ID, quantity: -1 })
).rejects.toMatchObject({ message: expect.stringContaining('INVALID_QUANTITY') })
})
it('존재하지 않는 상품이면 404를 반환한다', async () => {
await expect(
srv.send('submitOrder', { product: 'ffffffff-0000-0000-0000-00000000dead', quantity: 1 })
).rejects.toMatchObject({ code: '404' })
})
})
3단계: 프로덕션 수준 — 데이터 격리와 권한 테스트. 2단계 코드에는 함정이 있습니다. 첫 테스트가 재고를 차감하면 이후 테스트가 오염된 데이터 위에서 돌아갑니다. cds.test()가 반환하는 객체의 data.autoReset(true)를 쓰면 각 테스트 전에 CSV 초기 데이터로 DB를 되돌려 테스트 간 독립성을 확보할 수 있습니다. 또한 @requires 권한이 걸린 서비스는 목 인증으로 검증합니다.
const cds = require('@sap/cds')
const test = cds.test(__dirname + '/..')
test.data.autoReset(true)
describe('격리된 반복 주문 테스트', () => {
it.each([1, 3, 5])('수량 %i 주문이 매번 동일 초기 재고에서 시작한다', async (qty) => {
const srv = await cds.connect.to('OrderService')
const res = await srv.send('submitOrder', {
product: 'a1b2c3d4-0000-0000-0000-000000000001', quantity: qty
})
expect(res.orderID).toBeDefined()
})
})
describe('권한 검증 (mocked auth)', () => {
const { GET, axios } = test
it('인증 없는 사용자는 401을 받는다', async () => {
axios.defaults.auth = undefined
await expect(GET('/odata/v4/order/SalesOrders'))
.rejects.toMatchObject({ response: { status: 401 } })
})
it('mock 사용자 alice는 조회에 성공한다', async () => {
axios.defaults.auth = { username: 'alice', password: '' }
const { status } = await GET('/odata/v4/order/SalesOrders')
expect(status).toBe(200)
})
})
흔한 실수와 트러블슈팅 FAQ
- Q1.
cds.test()를beforeAll안에서 호출했더니 서버가 안 뜹니다. —cds.test()는 내부적으로 스스로beforeAll/afterAll훅을 등록합니다. 훅 안에서 다시 호출하면 등록 시점이 어긋나므로, 반드시 테스트 파일의 모듈 최상단에서 호출해야 합니다. - Q2.
GET("/Products")가 404를 반환합니다. — CDS 7 이후 OData 경로 기본값은/odata/v4/<service-path>입니다. 서비스명이OrderService면 경로는/odata/v4/order가 됩니다.@path어노테이션을 지정했다면 그 값을 사용하세요. - Q3. 첫 테스트는 통과하는데 두 번째부터 재고 검증이 깨집니다. — 테스트 간 데이터 오염입니다.
test.data.autoReset(true)또는 개별 테스트에서await test.data.reset()을 호출해 초기 CSV 상태로 되돌리세요. - Q4. 여러 테스트 파일을 돌리면 간헐적으로 포트/커넥션 오류가 납니다. — jest는 기본적으로 파일을 병렬 실행합니다.
--runInBand(또는maxWorkers: 1)로 직렬 실행하는 것이 일반적으로 안전합니다. - Q5.
rejects단언이 그냥 통과해 버립니다. —await expect(promise).rejects...에서await를 빠뜨리면 단언이 평가되기 전에 테스트가 끝납니다. 비동기 단언에는 항상await를 붙이세요.
더 깊이 가려면 — 이어서 볼 주제
단위 테스트가 자리 잡았다면 다음 주제로 확장해 보세요. 원격 서비스(S/4HANA API) 호출을 cds.connect.to 목으로 대체하는 원격 서비스 모킹, 이벤트 기반 로직을 검증하는 messaging 테스트(file-based messaging), CI 파이프라인에서 SQLite 대신 HANA Cloud로 검증하는 hybrid 프로파일 테스트, 그리고 jest coverage와 SonarQube를 연동한 품질 게이트 구성이 자연스러운 다음 단계입니다.
함께 보면 좋은 문서
댓글 0
아직 댓글이 없습니다.