Testing with cds.test
Overview
The cds.test
library provides best practice utils for writing tests for CAP Node.js applications.
Find examples in cap/samples and in the SFlight sample.
Running a CAP Server
Use function cds.test()
to easily launch and test a CAP server. For example, given your CAP application has a ./test
subfolder containing tests as follows:
project/ # your project's root folder
├─ srv/
├─ db/
├─ test/ # your .test.js files go in here
└─ package.json
Start your app's server in your .test.js
files like that:
const cds = require('@sap/cds')
describe(()=>{
const test = cds.test(__dirname+'/..')
})
This launches a server from the specified target folder in a beforeAll()
hook, with controlled shutdown when all tests have finished in an afterAll()
hook.
Don't use process.chdir()
!
Doing so in Jest tests may leave test containers in failed state, leading to failing subsequent tests. Use cds.test.in()
instead.
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. → Learn more in CDS_TEST_ENV_CHECK
.
Testing Service APIs
As cds.test()
launches the server in the current process, you can access all services programmatically using the respective Node.js Service APIs. Here's an example for that taken from cap/samples:
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 bound functions like so:
const { GET, POST } = cds.test(...)
const { data } = await GET ('/browse/Books')
await POST (`/browse/submitOrder`, { book: 201, quantity: 5 })
Authenticated Endpoints
cds.test()
uses the standard authentication strategy in development mode, which is the mocked authentication. This also includes the usage of pre-definded mock users
You can set the user for an authenticated request like this:
await GET('/admin/Books', { auth: { username: 'alice', password: '' } })
This is the same as setting the HTTP Authorization
header with values for basic authentication:
GET http://localhost:4004/admin/Books
Authorization: Basic alice:
Learn how to explicitly configure mock users in your package.json file.
Using Jest or Mocha
Mocha and Jest are the most used test runners at the moment, with each having its user base.
The cds.test
library is designed to allow you to write tests that can run with both. Here's an example:
describe('my test suite', ()=>{
const { GET, expect } = cds.test(...)
it ('should test', ()=>{ // Jest & Mocha
const { data } = await GET ('/browse/Books')
expect(data.value).to.eql([ // chai style expect
{ ID: 201, title: 'Wuthering Heights', author: 'Emily Brontë' },
{ ID: 252, title: 'Eleonora', author: 'Edgar Allen Poe' },
//...
])
})
})
To ensure that your tests run with both
jest
andmocha
, start a test server withcds.test(...)
inside adescribe
block of the test.
You can use Mocha-style before/after
or Jest-style beforeAll/afterAll
in your tests, as well as the common describe, test, it
methods. In addition, to be portable, you should use the Chai Assertion Library's variant of expect
.
All tests in cap/samples are written in that portable way.
Run them with npm run jest
or with npm run mocha
.
Helpers can cause conflicts
jest helpers might cause conflicts with the generic implementation of @sap/cds
.
To avoid such conflicts, do not use the following helpers:
- jest.resetModules as it leaves the server in an inconsistent state.
- jest.useFakeTimers as it intercepts the server shutdown causing test timeouts.
Using Test Watchers
You can also start the tests in watch mode, for example:
jest --watchAll
This 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
.
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.
.chai, ...
To write tests that run in Mocha as well as in Jest, you should use the Chai Assertion Library through the following convenient methods.
Using chai
requires these dependencies added to your project:
npm add -D chai@4 chai-as-promised@7 chai-subset jest
.expect
Shortcut to the chai.expect()
function, used like that:
const { expect } = cds.test(), foobar = {foo:'bar'}
it('should support chai.except style', ()=>{
expect(foobar).to.have.property('foo')
expect(foobar.foo).to.equal('bar')
})
If you prefer Jest's expect()
functions, you can just use the respective global:
cds.test()
it('should use jest.expect', ()=>{
expect({foo:'bar'}).toHaveProperty('foo')
})
.assert
Shortcut to the chai.assert()
function, used like that:
const { assert } = cds.test(), foobar = {foo:'bar'}
it('should use chai.assert style', ()=>{
assert.property(foobar,'foo')
assert.equal(foobar.foo,'bar')
})
.should
Shortcut to the chai.should()
function, used like that:
const { should } = cds.test(), foobar = {foo:'bar'}
it('should support chai.should style', ()=>{
foobar.should.have.property('foo')
foobar.foo.should.equal('bar')
should.equal(foobar.foo,'bar')
})
.chai
This getter provides access to the chai library, preconfigured with the chai-subset and chai-as-promised plugins. These plugins contribute the containSubset
and eventually
APIs, respectively. The getter is implemented like this:
get chai() {
return require('chai')
.use (require('chai-subset'))
.use (require('chai-as-promised'))
}
.axios
Provides access to the Axios instance used as HTTP client. It comes preconfigured with the base URL of the running server, that is, http://localhost:<port>
. This way, you only need to specify host-relative URLs in tests, like /catalog/Books
.
Using axios
requires adding this dependency:
npm add -D axios
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`
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' }}
)
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 { test } = cds.test()
beforeEach (test.data.reset)
Instead of using the bound variant, you can also call this method the standard way:
beforeEach (async()=>{
await test.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') //> equivalent
test. 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: __dirname
This 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.js
Detected 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: __dirname
To 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.
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 all
This 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, Axios throws errors 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
)
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 Allen Poe' },
{ title: 'Eleonora', author: 'Edgar Allen Poe' },
{ title: 'Catweazle', author: 'Richard Carpenter' }
]