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

아직 댓글이 없습니다.