Skip to content
On this page

Testing

Learn more about best practices to test CAP Node.js applications using the cds.test toolkit. Find samples for such tests in cap/samples and in CAP SFLIGHT app.

For more details how to test Java applications, see the Java documentation.

Preliminaries

Add optional dependencies required by cds.test:

sh
npm add -D axios chai chai-as-promised chai-subset jest
npm add -D axios chai chai-as-promised chai-subset jest

TIP

If you have cloned cap/samples, you get that for free.

Running a CAP Server

Use function cds.test() to easily launch and test a CAP server as follows:

js
const project = __dirname+'/..' // The project's root folder
const cds = require('@sap/cds/lib')
cds.test(project)
const project = __dirname+'/..' // The project's root folder
const cds = require('@sap/cds/lib')
cds.test(project)

Learn more about tests in the SFLIGHT app.

Behind the Scenes cds.test() ...
  • Ensures the server is launched before tests (→ in before()/beforeAll() hooks)
  • With the equivalent of cds serve --project <...> --in-memory? cli command
  • With a controlled shutdown when all tests have finished
Running in a Specific Folder

By default, the cds APIs read files from the current working directory. To run test simulating whole projects, use cds.test.in(<...>) to specify the test project's root folder.

js
const cds = require('@sap/cds/lib')
cds.test.in(__dirname)
const cds = require('@sap/cds/lib')
cds.test.in(__dirname)

For example, this would have cds.env loading the configuration from package.json and .cdsrc.json files found next to the test file, that is, in the same folder.

DANGER

Important: Don't use process.chdir() in Jest tests, as they may leave test containers in screwed state, leading to failing subsequent tests.

TIP

Prefer using relative filenames derived from __dirname as arguments to cds.test to allow your tests be started from whatever working directory.

Silenced Server Log

To reduce noise, cds.test() by default suppresses the usual bootstrap output of cds serve. You can skip this silent mode programmatically like that:

js
cds.test(project).verbose()
cds.test(project).verbose()

Or by setting process env variable CDS_TEST_VERBOSE, for example like that from the command line:

sh
CDS_TEST_VERBOSE=y mocha
CDS_TEST_VERBOSE=y mocha
cmd
set CDS_TEST_VERBOSE=y
mocha
set CDS_TEST_VERBOSE=y
mocha
powershell
$Env:CDS_TEST_VERBOSE=y
mocha
$Env:CDS_TEST_VERBOSE=y
mocha

To get a completely clutter-free log, check out the test runners for such a feature, like jest --silent.

Testing Service APIs

As cds.test() launches the server in the current process, you can access all services programmatically using the respective Node.js APIs. Here is an example for that taken from cap/samples:

js
it('Allows testing programmatic APIs', async () => {
  const AdminService = await cds.connect.to('AdminService')
  const { Authors } = AdminService.entities
  expect (await SELECT.from(Authors))
  .to.eql(await AdminService.read(Authors))
  .to.eql(await AdminService.run(SELECT.from(Authors)))
})
it('Allows testing programmatic APIs', async () => {
  const AdminService = await cds.connect.to('AdminService')
  const { Authors } = AdminService.entities
  expect (await SELECT.from(Authors))
  .to.eql(await AdminService.read(Authors))
  .to.eql(await AdminService.run(SELECT.from(Authors)))
})

Testing HTTP APIs

To test HTTP APIs we can use the test object returned by cds.test(), which uses axios and mirrors the axios API methods like .get(), .put(), .post() etc.

js
const test = cds.test('@capire/bookshop')
const {data} = await test.get('/browse/Books', { 
  params: { $search: 'Po', $select: `title,author`
}})
const test = cds.test('@capire/bookshop')
const {data} = await test.get('/browse/Books', { 
  params: { $search: 'Po', $select: `title,author`
}})

Learn more about the axios APIs.

In addition we provide uppercase bound function variants like GET or POST, which allow this usage variant:

js
const { GET, POST } = cds.test('@capire/bookshop')
const input = 'Wuthering Heights' // simulating user input
const order = await POST (`/browse/submitOrder`, { 
  book: 201, quantity: 5
})
const { data } = await GET ('/browse/Books', { 
  params: { $search: 'Po', $select: `title,author`
}})
const { GET, POST } = cds.test('@capire/bookshop')
const input = 'Wuthering Heights' // simulating user input
const order = await POST (`/browse/submitOrder`, { 
  book: 201, quantity: 5
})
const { data } = await GET ('/browse/Books', { 
  params: { $search: 'Po', $select: `title,author`
}})

Using Mocha or Jest

Mocha and Jest are the most used test runners at the moment, with each having its fan base. The cds.test library is designed to write tests that run with both, as shown in the following sample code:

js
const { GET, expect } = cds.test('@capire/bookshop')
describe('my test suite', ()=>{
  beforeAll(()=>{ })  // Jest style
  before(()=>{ })     // Mocha style
  test ('something', ()=>{}) // Jest style
  it ('should test', ()=>{ // Jest & Mocha style
    const { data } = await GET ('/browse/Books', {
      params: { $search: 'Po', $select: `title,author` }
    })
    expect(data.value).to.eql([ // Chai tests, working in Jest and Mocha
      { ID: 201, title: 'Wuthering Heights', author: 'Emily Brontë' },
      { ID: 207, title: 'Jane Eyre', author: 'Charlotte Brontë' },
      { ID: 251, title: 'The Raven', author: 'Edgar Allen Poe' },
      { ID: 252, title: 'Eleonora', author: 'Edgar Allen Poe' },
    ])
  })
})
const { GET, expect } = cds.test('@capire/bookshop')
describe('my test suite', ()=>{
  beforeAll(()=>{ })  // Jest style
  before(()=>{ })     // Mocha style
  test ('something', ()=>{}) // Jest style
  it ('should test', ()=>{ // Jest & Mocha style
    const { data } = await GET ('/browse/Books', {
      params: { $search: 'Po', $select: `title,author` }
    })
    expect(data.value).to.eql([ // Chai tests, working in Jest and Mocha
      { ID: 201, title: 'Wuthering Heights', author: 'Emily Brontë' },
      { ID: 207, title: 'Jane Eyre', author: 'Charlotte Brontë' },
      { ID: 251, title: 'The Raven', author: 'Edgar Allen Poe' },
      { ID: 252, title: 'Eleonora', author: 'Edgar Allen Poe' },
    ])
  })
})

To be portable, you need to use a specific implementation of expect, like the one from chai provided through cds.test(), as shown in the previous sample. You can use Mocha-style before/after or Jest-style beforeAll/afterAll in your tests, as well as the common describe, test, it methods.

TIP

All tests in cap/samples are written in that portable way.
Run them with npm run jest or with npm run mocha.

Using Watchers

You can also start the tests in watch mode, for example:

sh
jest --watchAll
jest --watchAll

which should give you green tests, when running in cap/samples root:

PASS  test/cds.ql.test.js
PASS  test/hierarchical-data.test.js
PASS  test/hello-world.test.js
PASS  test/messaging.test.js
PASS  test/consuming-services.test.js
PASS  test/custom-handlers.test.js
PASS  test/odata.test.js
PASS  test/localized-data.test.js

Test Suites: 8 passed, 8 total
Tests:       65 passed, 65 total
Snapshots:   0 total
Time:        3.611 s, estimated 4 s
Ran all test suites.

Similarly, you can use other test watchers like mocha -w.

Using cds.test in REPL

You can use cds.test in REPL, for example, by running this from your command line:

sh
[samples](https://github.com/sap-samples/cloud-cap-samples) cds repl
Welcome to cds repl v5.5.0
[samples](https://github.com/sap-samples/cloud-cap-samples) cds repl
Welcome to cds repl v5.5.0
js
> cds.test('@capire/bookshop')
Test {}
> cds.test('@capire/bookshop')
Test {}
sh
[cds](cds) - model loaded from 6 file(s):

  ./bookshop/db/schema.cds
  ./bookshop/srv/admin-service.cds
  ./bookshop/srv/cat-service.cds
  ./bookshop/app/services.cds
  ./../../cds/common.cds
  ./common/index.cds

[cds](cds) - connect to db > sqlite { database: ':memory:' }
 > filling sap.capire.bookshop.Authors from ./bookshop/db/data/sap.capire.bookshop-Authors.csv
 > filling sap.capire.bookshop.Books from ./bookshop/db/data/sap.capire.bookshop-Books.csv
 > filling sap.capire.bookshop.Books.texts from ./bookshop/db/data/sap.capire.bookshop-Books_texts.csv
 > filling sap.capire.bookshop.Genres from ./bookshop/db/data/sap.capire.bookshop-Genres.csv
 > filling sap.common.Currencies from ./common/data/sap.common-Currencies.csv
 > filling sap.common.Currencies.texts from ./common/data/sap.common-Currencies_texts.csv
/> successfully deployed to sqlite in-memory db

[cds](cds) - serving AdminService { at: '/admin', impl: './bookshop/srv/admin-service.js' }
[cds](cds) - serving CatalogService { at: '/browse', impl: './bookshop/srv/cat-service.js' }

[cds](cds) - server listening on { url: 'http://localhost:64914' }
[cds](cds) - launched at 9/8/2021, 5:36:20 PM, in: 767.042ms
[ terminate with ^C ]
[cds](cds) - model loaded from 6 file(s):

  ./bookshop/db/schema.cds
  ./bookshop/srv/admin-service.cds
  ./bookshop/srv/cat-service.cds
  ./bookshop/app/services.cds
  ./../../cds/common.cds
  ./common/index.cds

[cds](cds) - connect to db > sqlite { database: ':memory:' }
 > filling sap.capire.bookshop.Authors from ./bookshop/db/data/sap.capire.bookshop-Authors.csv
 > filling sap.capire.bookshop.Books from ./bookshop/db/data/sap.capire.bookshop-Books.csv
 > filling sap.capire.bookshop.Books.texts from ./bookshop/db/data/sap.capire.bookshop-Books_texts.csv
 > filling sap.capire.bookshop.Genres from ./bookshop/db/data/sap.capire.bookshop-Genres.csv
 > filling sap.common.Currencies from ./common/data/sap.common-Currencies.csv
 > filling sap.common.Currencies.texts from ./common/data/sap.common-Currencies_texts.csv
/> successfully deployed to sqlite in-memory db

[cds](cds) - serving AdminService { at: '/admin', impl: './bookshop/srv/admin-service.js' }
[cds](cds) - serving CatalogService { at: '/browse', impl: './bookshop/srv/cat-service.js' }

[cds](cds) - server listening on { url: 'http://localhost:64914' }
[cds](cds) - launched at 9/8/2021, 5:36:20 PM, in: 767.042ms
[ terminate with ^C ]
js
> await SELECT `title` .from `Books` .where `exists author[name like '%Poe%']`
[ { title: 'The Raven' }, { title: 'Eleonora' } ]
> await SELECT `title` .from `Books` .where `exists author[name like '%Poe%']`
[ { title: 'The Raven' }, { title: 'Eleonora' } ]
js
> var { CatalogService } = cds.services
> await CatalogService.read `title, author` .from `ListOfBooks`
[
  { title: 'Wuthering Heights', author: 'Emily Brontë' },
  { title: 'Jane Eyre', author: 'Charlotte Brontë' },
  { title: 'The Raven', author: 'Edgar Allen Poe' },
  { title: 'Eleonora', author: 'Edgar Allen Poe' },
  { title: 'Catweazle', author: 'Richard Carpenter' }
]
> var { CatalogService } = cds.services
> await CatalogService.read `title, author` .from `ListOfBooks`
[
  { title: 'Wuthering Heights', author: 'Emily Brontë' },
  { title: 'Jane Eyre', author: 'Charlotte Brontë' },
  { title: 'The Raven', author: 'Edgar Allen Poe' },
  { title: 'Eleonora', author: 'Edgar Allen Poe' },
  { title: 'Catweazle', author: 'Richard Carpenter' }
]

Providing Test Data

Data can be supplied:

  • Programmatically as part of the test code
  • In CSV files from db/data folders

This following example shows how data can be inserted into the database using regular CDS service APIs (using CQL INSERT under the hood):

js
beforeAll(async () => {
  const db = await cds.connect.to('db')  
  const {Books} = db.model.entities('my.bookshop') 
  await db.create(Books).entries([ 
    {ID:401, title: 'Book 1'}, 
    {ID:402, title: 'Book 2'} 
  ])
  // verify new data through API
  const { data } = await GET `/catalog/Books`
  expect(data.value).to.containSubset([{ID: 401}, {ID: 402}])
})
beforeAll(async () => {
  const db = await cds.connect.to('db')  
  const {Books} = db.model.entities('my.bookshop') 
  await db.create(Books).entries([ 
    {ID:401, title: 'Book 1'}, 
    {ID:402, title: 'Book 2'} 
  ])
  // verify new data through API
  const { data } = await GET `/catalog/Books`
  expect(data.value).to.containSubset([{ID: 401}, {ID: 402}])
})

This example also demonstrates the difference of accessing the database or the service layer: inserting data through the latter would fail because CatalogService.Books is read-only. In contrast, accessing the database as part of such test fixture code is fine. Just keep in mind that the data is not validated through your custom handler code, and that the database layer, that is, the table layout, is no API for users.

Data Reset

Using the cds.test.data API, you can have all data deleted and redeployed before each test:

js
const { GET, expect, data } = cds.test ('@capire/bookshop')
data.autoReset(true) // delete + redeploy from CSV before each test
const { GET, expect, data } = cds.test ('@capire/bookshop')
data.autoReset(true) // delete + redeploy from CSV before each test

or reset it whenever needed:

js
await data.reset()
await data.reset()

or only delete it:

js
await data.delete()
await data.delete()

cds.test Reference

cds.test (projectDir)Test

Launches a CDS server with an arbitrary port and returns a subclass which also acts as an Axios lookalike, providing methods to send requests. Launch a server in the given project folder, using a default command of cds serve --in-memory?.

The server is shut down after all tests have been executed.

cds.test (command, ...args)Test

Launch a server with the given command and arguments.
Example: cds.test ('serve', '--in-memory', '--project', <dir>)

class Test

Instances of this class are returned by cds.test(). See below for its functions and properties.

.GET/PATCH/POST/PUT/DELETE (url, ...)response

Aliases for corresponding get/patch/... methods from Axios. For calls w/o additional parameters, a simplified call style is available where the () can be omitted. For example,

GET /foo
and GET(/foo) are equivalent.

.expectexpect

Provides the expect function from the chai assertion library.

.chaichai

Provides the chai assertion library. It is preconfigured with the chai-subset and chai-as-promised plugins. These plugins contribute the containSubset and eventually APIs, respectively.

.axiosaxios

Provides the Axios instance that is used as HTTP client. It comes preconfigured with the base URL of the running server, that is, http://localhost:.... This way, you only need to specify host-relative URLs in tests, like /catalog/Books.

.data{ }

Provides utilities to manage test data:

  • .autoReset (boolean) enables automatic deletion and redeployment of CSV data before each test. Default is false.
  • .delete () deletes data in all database tables
  • .reset () deletes data in all database tables and deploys CSV data again

.in (...paths)Test

Sets the given path segments as project root.
Example: cds.test.in(__dirname, '..').run('serve', '--in-memory')

.verbose (boolean)Test

Sets verbose mode, so that, for example, server logs are shown.

Best Practices

Check Response Data Instead of Status Codes

Avoid checking for single status codes. Instead simply check the response data:

js
const { data, status } = await GET `/catalog/Books`
expect(status).to.equal(200)   // <-- DON'T
expect(data.value).to.containSubset([{ID: 1}])  // just do this
const { data, status } = await GET `/catalog/Books`
expect(status).to.equal(200)   // <-- DON'T
expect(data.value).to.containSubset([{ID: 1}])  // just do this

This makes a difference in case of an error: with the status code check, your test will abort with a useless Expected: 200, received: xxx error, while without it, it fails with a richer error that includes a status text.

Note that by default, Axios yields errors for status codes < 200 and >= 300. This can be configured, though.

Be Relaxed When Checking Error Messages

When expecting errors, compare their text in a relaxed fashion. Don't hard-wire the exact error text, as this might change over time, breaking your test unnecessarily.

js
await expect(POST(`/catalog/Books`,{ID:333})).to.be.rejectedWith(
  'Entity "CatalogService.Books" is read-only')  // DON'T hard-wire entire texts
await expect(POST(`/catalog/Books`,{ID:333})).to.be.rejectedWith(
  /read?only/i)  // better: check for the essential information, use regexes
await expect(POST(`/catalog/Books`,{ID:333})).to.be.rejectedWith(
  'Entity "CatalogService.Books" is read-only')  // DON'T hard-wire entire texts
await expect(POST(`/catalog/Books`,{ID:333})).to.be.rejectedWith(
  /read?only/i)  // better: check for the essential information, use regexes