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
:
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:
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.
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:
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:
CDS_TEST_VERBOSE=y mocha
CDS_TEST_VERBOSE=y mocha
set CDS_TEST_VERBOSE=y
mocha
set CDS_TEST_VERBOSE=y
mocha
$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:
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.
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:
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:
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:
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:
[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
> cds.test('@capire/bookshop')
Test {}
> cds.test('@capire/bookshop')
Test {}
[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 ]
> 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' } ]
> 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):
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:
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:
await data.reset()
await data.reset()
or only delete it:
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..expect
→ expect
Provides the expect function from the chai assertion library.
.chai
→ chai
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.
.axios
→ axios
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 isfalse
..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:
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.
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