CAP for Node

jest vs cds.test — CAP 테스트 실패 3원인 #shorts #SAP #CAP

📖 개요와 목표

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가지 패턴은 다음과 같습니다.

  1. 서비스 미부트스트랩 — 핸들러 모듈을 직접 require하면 cds.ApplicationService 인스턴스가 없어 srv.on is not a function, 혹은 모델이 없어 no model loaded가 발생합니다.
  2. DB 미배포 — 핸들러 내부의 SELECT.from(PurchaseOrders)가 실행될 DB가 없습니다. 인메모리 SQLite에 스키마를 배포(deploy)하는 단계가 빠져 있으면 쿼리는 즉시 실패합니다.
  3. 싱글톤 상태 오염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 방언 차이를 감안해 하이브리드 테스트를 병행하는 것이 일반적으로 권장됩니다.

📚 참고 링크 모음

댓글 0

아직 댓글이 없습니다.