Testing with cds.test
Getting Started
Project Setup
Add @cap-js/cds-test as a dev dependency to your project:
npm add -D @cap-js/cds-testCheck whether it works as expected with your globally installed @sap/cds-dk, which should show some help output as below:
cds test -?Usage:
cds test [ options ] [ patterns ]
Options:
-l, --list List found test files
-s, --silent Suppress output via console.log
-q, --quiet Suppress all output to stdout
...Writing Tests
A typical usage in your tests looks like this:
const cds = require ('@sap/cds')
const { GET, expect, defaults } = cds.test ('@capire/bookshop')
defaults.auth = { username: 'alice' }
defaults.path = '/odata/v4/browse'
describe ('browse books', ()=>{
it ('should allow fetching lists of books', async () => {
const { data } = await GET `Books? $select=ID,title`
expect (data.value) .to.deep.equal ([
{ ID: 201, title: 'Wuthering Heights' },
{ ID: 207, title: 'Jane Eyre' },
{ ID: 251, title: 'The Raven' },
{ ID: 252, title: 'Eleonora' },
{ ID: 271, title: 'Catweazle' },
])
})
//...
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
This is an excerpt from capire/bookstore/test/odata.test.js
Let's analyze the highlighted the code above line by line:
const ... cds.test... // > loads the cds-test module- By accessing
cds.testthecds-testmodule is loaded, which ensures that... - Functions like
describe,test,it, etc. are made available in test scope.
const { GET, ... } = cds.test ('@capire/bookshop')- Calling the
cds.test()function launches a CAP server for the given CAP project.
defaults.auth = { username: 'alice' }
defaults.path = '/odata/v4/browse'4
- Sets some
defaultsused for subsequent HTTP requests.
const { data } = await GET `Books? $select=ID,title`- Uses the
GETfunction obtained in line 2 to send an HTTP request.
expect (data.value) .to.deep.equal ([ ... ])- Uses the
expectfunction obtained in line 2 to assert expected results.
Testing Services
To test HTTP APIs, we can use the provided HTTP shorthand functions like so:
const { GET, POST } = cds.test(...)
const { data } = await GET ('/browse/Books')
await POST (`/browse/submitOrder`, { book: 201, quantity: 5 })Instead of sending HTTP requests, we can also use the CAP runtime's Service APIs to access services programmatically, which is especially useful for testing service implementations, excluding the protocols layer. Here's an example for that:
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)))
})Running Tests
You can run tests with the test runner of your choice, such as:
For example, you can use either of the following commands to run tests:
Try it with @capire/samples...
git clone --recursive http://github.com/capire/samples
cd samples
npm installnode --testnpx vitest --silentnpx jest --silentnpx mocha --parallel bookstore/testcds testThe last one, cds test is a thin wrapper around Node's built-in test runner, which makes it easier to fetch tests and provides a cleaner output.
Writing runner-agnostic tests
To keep your tests portable across different test runners, it's recommended to avoid using runner-specific features and stick to the common APIs provided by cds.test, in particular via cds.test.expect and cds.test.defaults, which are designed to work across different runners. This way, you can easily switch between different test runners as shown above without having to change your test code.
Dos and Don'ts
Don't load cds.env before cds.test()
To ensure cds.env, and hence all plugins, are loaded from the test's target folder, the call to cds.test() is the first thing you do in your tests. Any references to cds sub modules or any imports of which have to go after. → See also: CDS_TEST_ENV_CHECK.
Avoid Jest's bells and whistles
Certain Jest helpers might cause conflicts with generic features of @sap/cds. For example, jest.resetModules() might leave the server in an inconsistent state, and jest.useFakeTimers() can interfere with the server shutdown, leading to test timeouts.
To ensure smooth testing, it's best to steer clear of such features, also to keep things simple, and portable.
Avoid process.chdir() -> prefer cds.test.in()
Using process.chdir() in tests may leave test containers in failed state, leading to failing subsequent tests. -> Use cds.test.in() instead.
Class cds.test.Test
Instances of this class are returned by cds.test(), for example:
const test = cds.test(_dirname)You can also use this class and create instances yourself, for example, like that:
const { Test } = cds.test
let test = new Test
test.run().in(_dirname)cds.test()
This method is the most convenient way to start a test server. It's actually just a convenient shortcut to construct a new instance of class Test and call test.run(), defined as follows:
const { Test } = cds.test
cds.test = (...args) => (new Test).run(...args)Run cds.test once per test file
@sap/cds relies on server state like cds.model. Running cds.test multiple times within the same test file can lead to a conflicting state and erratic behavior.
.defaults
This property provides default values for HTTP requests, which can be set like this:
const { defaults } = cds.test
defaults.auth = { username: 'alice', password: '...' }
defaults.validateStatus = status => status >= 500To stay portable across different HTTP clients, it's recommended to only use these options, which cds.test supports across all clients:
baseURLas defined in Axiosauthas defined in Axiosheadersas defined in Fetch API and AxiosvalidateStatusas defined in Axios (default:status < 200 && status >= 300)
In addition, you can use all of the config options understood by the underlying HTTP client, that is, for Fetch API, its RequestInit options, and for Axios, its request config options options.
.expect
Returns the expect() function as known from the Chai Assertion Library, preconfigured with the chai-subset and chai-as-promised plugins, which contribute the containSubset and eventually APIs, respectively.
const { GET, expect } = cds.test()
it ('uses chai.expect', ()=>{
expect({foo:'bar'}).to.have.property('foo','bar')
expect({foo:'bar'}.foo).to.equal('bar')
})If you prefer Jest's expect() functions, you can just use the respective global:
const { GET } = cds.test() // excluding expect, as we want to use Jest's
it('uses jest.expect', ()=>{
expect({foo:'bar'}).toHaveProperty('foo','bar')
expect({foo:'bar'}.foo).toBe('bar')
})WARNING
As chai is an ESM library since version 5, and Jest is still struggling to support ESM, the expect function returned by cds.test is a simple emulation of the original Chai expect function. It supports the most commonly used Chai APIs, but not all of them.
GET / PUT / POST ...
These are bound variants of the test.get/put/post/... methods allowing to write HTTP requests like that:
const { GET, POST } = cds.test()
const { data } = await GET('/browse/Books')
await POST('/browse/submitOrder',
{ book:201, quantity:1 },
{ auth: { username: 'alice' }}
)For single URL arguments, the functions can be used in tagged template string style, which allows omitting the parentheses from function calls:
let { data } = await GET('/browse/Books')
let { data } = await GET `/browse/Books`Authentication
You can set the Authentication header for individual requests like this:
await GET('/admin/Books', { auth: { username: 'alice', password: '...' } })Alternatively, you can set a default user for all requests like this:
defaults.auth = { username: 'alice', password: '...' }Learn how to explicitly configure mock users in your package.json file.
test. get/put/post/...()
These are mirrored version of the corresponding methods from axios, which prefix each request with the started server's url and port, which simplifies your test code:
const test = cds.test() //> served at localhost with an arbitrary port
const { data } = await test.get('/browse/Books')
await test.post('/browse/submitOrder',
{ book:201, quantity:1 },
{ auth: { username: 'alice' }}
)Using Fetch API under the hood
Under the hood, these methods use Fetch API, natively supported through the global fetch() function in Node.js since version 18.
Using Axios instead of Fetch API
Former versions of cds.test used Axios as the HTTP client. With the move to Fetch API, Axios is no longer included as a dependency in @cap-js/cds-test. However, you can still use Axios in your tests if you prefer it over Fetch API. Simply add Axios as a dependency to your project, and it will be used automatically by cds.test instead of Fetch API.
test .data .reset()
This is a bound method, which can be used in a beforeEach handler to automatically reset and redeploy the database for each test like so:
const { data } = cds.test()
beforeEach (data.reset)Instead of using the bound variant, you can also call this method the standard way:
beforeEach (async()=>{
await data.reset()
//...
})test. log()
Allows to capture console output in the current test scope. The method returns an object to control the captured logs:
function cds.test.log() => {
output : string
clear()
release()
}Usage examples:
describe('cds.test.log()', ()=>{
let log = cds.test.log()
it ('should capture log output', ()=>{
expect (log.output.length).to.equal(0)
console.log('foo',{bar:2})
expect (log.output.length).to.be.greaterThan(0)
expect (log.output).to.contain('foo')
})
it('should support log.clear()', ()=> {
log.clear()
expect (log.output).to.equal('')
})
it('should support log.release()', ()=> {
log.release() // releases captured log
console.log('foobar') // not captured
expect (log.output).to.equal('')
})
})The implementation redirects any console operations in a beforeAll() hook, clears log.output before each test, and releases the captured console in an afterAll() hook.
test. run (...)
This is the method behind cds.test() to start a CDS server, that is the following are equivalent:
cds.test(...)(new cds.test.Test).run(...)It asynchronously launches a CDS server in a beforeAll() hook with an arbitrary port, with controlled shutdown when all tests have finished in an afterAll() hook.
The arguments are the same as supported by the cds serve CLI command.
Specify the command 'serve' as the first argument to serve specific CDS files or services:
cds.test('serve','srv/cat-service.cds')
cds.test('serve','CatalogService')You can optionally add test.in(folder) in fluent style to run the test in a specific folder:
cds.test('serve','srv/cat-service.cds').in('/cap/samples/bookshop')If the first argument is not 'serve', it's interpreted as a target folder:
cds.test('/cap/samples/bookshop')This variant is a convenient shortcut for:
cds.test('serve','all','--in-memory?').in('/cap/samples/bookshop')
cds.test().in('/cap/samples/bookshop') //> equivalenttest. in (folder, ...)
Safely switches cds.root to the specified target folder. Most frequently you'd use it in combination with starting a server with cds.test() in fluent style like that:
let test = cds.test(...).in(__dirname)It can also be used as static method to only change cds.root without starting a server:
cds.test.in(__dirname)CDS_TEST_ENV_CHECK
It's important to ensure cds.env, and hence all plugins, are loaded from the test's target folder. To ensure this, any references to or imports of cds sub modules have to go after all plugins are loaded. For example if you had a test like that:
cds.env.fiori.lean_draft = true //> cds.env loaded from ./
cds.test(__dirname) //> target folder: __dirnameThis would result in the test server started from __dirname, but erroneously using cds.env loaded from ./.
As these mistakes end up in hard-to-resolve follow up errors, test.in() can detect this if environment variable CDS_TEST_ENV_CHECK is set. The previous code will then result into an error like that:
CDS_TEST_ENV_CHECK=y jest cds.test.test.jsDetected cds.env loaded before running cds.test in different folder:
1. cds.env loaded from: ./
2. cds.test running in: cds/tests/bookshop
at Test.in (node_modules/@sap/cds/lib/utils/cds-test.js:65:17)
at test/cds.test.test.js:9:41
at Object.describe (test/cds.test.test.js:5:1)
5 | describe('cds.test', ()=>{
> 6 | cds.env.fiori.lean_draft = true
| ^
7 | cds.test(__dirname)
at env (test/cds.test.test.js:7:7)
at Object.describe (test/cds.test.test.js:5:1)A similar error would occur if one of the cds sub modules would be accessed, which frequently load cds.env in their global scope, like cds.Service in the following snippet:
class MyService extends cds.Service {} //> cds.env loaded from ./
cds.test(__dirname) //> target folder: __dirnameTo fix this, always ensure your calls to cds.test.in(folder) or cds.test(folder) goes first, before anything else loading cds.env:
cds.test(__dirname) //> always should go first
// anything else goes after that:
cds.env.fiori.lean_draft = true
class MyService extends cds.Service {} Do switch on CDS_TEST_ENV_CHECK !
We recommended to switch on CDS_TEST_ENV_CHECK in all your tests to detect such errors. It's likely to become default in upcoming releases.
Deprecated APIs
.axios
Used to provide access to the Axios instance used as HTTP client. With the move from Axios to Fetch API as the default HTTP client, this property is no longer available. If you want to use Axios in your tests, add it as a dependency to your project, and import it directly in your test file like this:
import axios from 'axios'const axios = require('axios').chai
Used to provide direct access to the Chai library, which cannot be provided any longer with Jest as test runner, as Jest is still struggling to support ESM, and Chai is an ESM library since version 5.
Use expect from cds.test, if you only need that. If you need access to more from the original Chai library, add it as a dependency to your project, and import it directly in your test file like this:
import chai from 'chai'const chai = require('chai').assert
Used to provide access to the chai.assert() function.
=> Use it directly from the Chai library, which you can import as described above.
.should
Used to provide access to the chai.should() function.
=> Use it directly from the Chai library, which you can import as described above.
Best Practices
Check Status Codes Last
Avoid checking for single status codes. Instead, simply check the response data:
const { data, status } = await GET `/catalog/Books`
expect(status).to.equal(200) //> DON'T do that upfront
expect(data).to.equal(...) //> do this to see what's wrong
expect(status).to.equal(200) //> Do it at the end, if at allThis makes a difference if there are errors: with the status code check, your test aborts 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, errors are thrown for status codes < 200 and >= 300. This can be configured, though.
Minimal Assumptions
When checking expected errors messages, only check for significant keywords. Don't hardwire the exact error text, as this might change over time, breaking your test unnecessarily.
DON'T hardwire on overly specific error messages:
await expect(POST(`/catalog/Books`,...)).to.be.rejectedWith(
'Entity "CatalogService.Books" is readonly'
)DO check for the essential information only:
await expect(POST(`/catalog/Books`,...)).to.be.rejectedWith(
/readonly/i
)Keep Test Code Environment Agnostic
Environment setup shouldn't be part of the test code itself. That should be handled by setup scripts like CI/CD pipelines. This way, your tests remain isolated and reproducible across different setups.
// NO service bindings, env. variables, profiles, etc. here
// Do this outside in setup scripts etc.
describe(() => { cds.test(...) })Learn how to setup integration tests with cds bind.
Using cds.test in REPL
You can use cds.test in REPL, for example, by running this from your command line in cap/samples:
[cap/samples] cds repl
Welcome to cds repl v7.1> var test = await cds.test('bookshop')[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] - 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] - serving AdminService { at: '/admin', impl: './bookshop/srv/admin-service.js' }
[cds] - serving CatalogService { at: '/browse', impl: './bookshop/srv/cat-service.js' }
[cds] - server listening on { url: 'http://localhost:64914' }
[cds] - launched at 9/8/2021, 5:36:20 PM, in: 767.042ms
[ terminate with ^C ]> await SELECT `title` .from `Books` .where `exists author[name like '%Poe%']`
[ { title: 'The Raven' }, { title: 'Eleonora' } ]> 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 Allan Poe' },
{ title: 'Eleonora', author: 'Edgar Allan Poe' },
{ title: 'Catweazle', author: 'Richard Carpenter' }
]