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, 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. -> Learn more in section Runner-Agnostic Tests below.
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.
Keep it simple, stupid!
To keep things simple, and runner-agnostic, avoid excessive use of your test runner's mocking features or alike. The more you mock, the less you test the real thing!
Using these bells and whistles might also 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.
Avoid process.chdir() -> prefer cds.test.in()
CAP servers need to be launched from a specific project home directory. Don't use process.chdir() for this, as that may leave test containers in failed state, leading to failing subsequent tests. -> Specify the target folder in the call to cds.test(), or use cds.test.in() instead.
Class cds.test.Test
Instances of this class are returned by cds.test(), for example:
const test = cds.test()
//> test is an instance of class cds.test.TestYou 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. => We recommend to migrate to Vitest, or use Jest's expect instead, if you need to stick to Jest.
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
.expect in Jest
The expect function provided by cds.test always was the chai.expect function preconfigured with the chai-subset and chai-as-promised plugins, which is still the case when using Vitest, Mocha, or Node's built-in test runner, and recommended to be used across all runners for portable tests.
However, with the move to latest Chai version 6, which is an ESM library, and the fact that Jest is still struggling to support ESM, it's no longer possible to provide the original chai.expect function in Jest. Instead, when called within jest runs, cds.test.expect provides a simple emulation of the original Chai expect function, which supports the most commonly used Chai APIs, but not all of them!
TIP
We recommend to either migrate to Vitest, or use Jest's expect instead, if you need to stick to Jest, or simply use the original chai v4 expect function by adding chai@4 as a dependency to your project and importing it directly in your test file like via const { expect } = requires('chai') instead of using cds.test.expect.
.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
Minimal Assumptions
In your assertions, only check what's really relevant for the functionality you're testing. Make minimal assumptions about irrelevant details. This way, your tests are more robust against changes in underlying implementations.
For example, avoid hardwiring on specific error messages, as these might change without actually breaking the functionality, which would lead to unnecessarily broken tests. Check for guaranteed stable error codes instead, for example:
Don't do that:
expect (error.message) .to.equal ('Entity "CatalogService.Books" is readonly')Do that instead:
expect (error.code) .to.equal ('READONLY_ENTITY')Don't Test Snapshots
Same applies to using equal with whole response objects, which might contain additional properties like timestamps, ids, etc., that are not relevant for the test and might change without actually breaking the functionality. Instead, check for essential information only, for example with containSubset:
Don't do that:
expect (response) .to.deep.equal ({
status: 403,
data: {
error: {
code: 'READONLY_ENTITY',
message: 'Entity "CatalogService.Books" is readonly',
details: { ... }
}
}
})Do that instead:
expect (response.data) .to.containSubset ({
error: {
code: 'READONLY_ENTITY'
}
})Don't Obscure Errors
A common mistake is to check for HTTP status codes upfront, which frequently obscures the actual error if the status code is different than expected. Instead, check the error response data first, which will give you a richer information in case of failing tests including the error messages and stack traces, and only then check for the status code if at all:
Don't do that:
const { data, status } = await GET `/catalog/Books`
expect(status).to.equal(200) //> DON'T do that upfront, ...
expect(data).to.deep.equal({...}) //> as we'd never reach this lineDo that instead:
const { data, status } = await GET `/catalog/Books`
expect(data).to.deep.equal({...}) //> Gives rich error information if it fails
expect(status).to.equal(200) //> Do that at the end, if at allNote that by default, Axios throws errors for status codes < 200 and >= 300. This can be configured, though.
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 by cds.test.
Whenever you the @cap-js/cds-test module is loaded through cds.test the following common test functions and hooks are made available in test scope, and guaranteed to work in the same way, regardless of the test runner you're using:
- The
describefunction for grouping tests - The
test/itfunction for defining test cases - The
beforeAll,afterAll,beforeEach,afterEachhook functions - The
expectfunction fromcds.test, which works across different runners.
If you stick to these common and stable APIs, and avoid using any runner-specific features beyond these, you can easily switch between different test runners without having to change your test code.
Using cds.test in REPL
You can use cds.test in REPL, for example, by running this from your command line in the root of your CAP project:
[cap/samples] cds repl
Welcome to cds repl v10.1var { GET, expect } = cds.test()[cds] - model loaded from 5 file(s):
srv/cat-service.cds
srv/admin-constraints.cds
srv/admin-service.cds
db/schema.cds
node_modules/@sap/cds/common.cds
[cds] - connect to db > sqlite { url: ':memory:' }
> init from db/data/sap.capire.bookshop-Genres.csv
> init from db/data/sap.capire.bookshop-Books.texts.csv
> init from db/data/sap.capire.bookshop-Books.csv
> init from db/data/sap.capire.bookshop-Authors.csv
/> successfully deployed to sqlite in-memory db
[cds] - serving AdminService { ... }
[cds] - serving CatalogService {... }
[cds] - server listening on { url: 'http://localhost:64914' }
[cds] - launched at 9/8/2021, 5:36:20 PM, in: 767.042ms
[ terminate with ^C ]var response = await GET `/odata/v4/browse/Books/201`expect (response.status) .to.equal (200)expect (response.data) .to.contain ({ title: 'Wuthering Heights' })