UNPKG

13.1 kBMarkdownView Raw
1# LoopBack Test Lab
2
3A collection of test utilities we use to write LoopBack tests.
4
5## Overview
6
7Test utilities to help writing LoopBack 4 tests:
8
9- `expect` - behavior-driven development (BDD) style assertions
10- `sinon`
11 - test spies: functions recording arguments and other information for all of
12 their calls
13 - stubs: functions (spies) with pre-programmed behavior
14 - mocks: fake methods (like spies) with pre-programmed behavior (like stubs)
15 as well as pre-programmed expectations
16- Helpers for creating `supertest` clients for LoopBack applications
17- HTTP request/response stubs for writing tests without a listening HTTP server
18- Swagger/OpenAPI spec validation
19- Test sandbox
20
21## Installation
22
23```sh
24npm install --save-dev @loopback/testlab
25```
26
27_This package is typically used in tests, save it to `devDependencies` via
28`--save-dev`._
29
30## Basic use
31
32```ts
33import {expect} from '@loopback/testlab';
34
35describe('Basic assertions', => {
36 it('asserts equal values', => {
37 expect({key: 'value'}).to.deepEqual({key: 'value'});
38 expect.exists(1);
39 });
40});
41```
42
43## API documentation
44
45Table of contents:
46
47- [expect](#expect) - Better assertions.
48- [sinon](#sinon) - Mocks, stubs and more.
49- [shot](#shot) - HTTP Request/Response stubs.
50- [validateApiSpec](#validateapispec) - Open API Spec validator.
51- [skipIf](#skipif) - Skip tests when a condition is met.
52- [skipOnTravis](#skipontravis) - Skip tests on Travis env.
53- [createRestAppClient](#createrestappclient) - Create a supertest client
54 connected to a running RestApplication.
55- [givenHttpServerConfig](#givenhttpserverconfig) - Generate HTTP server config.
56- [httpGetAsync](#httpgetasync) - Async wrapper for HTTP GET requests.
57- [httpsGetAsync](#httpsgetasync) - Async wrapper for HTTPS GET requests.
58- [toJSON](#toJSON) - A helper to obtain JSON data representing a given object.
59- [createUnexpectedHttpErrorLogger](#createunexpectedhttprrrorlogger) - An error
60 logger that only logs errors for unexpected HTTP statuses.
61- [TestSandbox](#testsandbox) - A sandbox directory for tests
62
63### `expect`
64
65[Should.js](https://shouldjs.github.io/) configured in "as-function" mode
66(global `Object.prototype` is left intact) with an extra chaining word `to`.
67
68### `sinon`
69
70Spies, mocks and stubs. Learn more at <http://sinonjs.org/>.
71
72### `shot`
73
74Stub implementation of HTTP Request and Response objects, useful for unit tests.
75
76Besides the API provided by `shot` module (see
77[API Reference](https://github.com/hapijs/shot/blob/master/API.md)), we provide
78additional APIs to better support async/await flow control and usage in
79Express-based code.
80
81There are three primary situations where you can leverage stub objects provided
82by Shot in your unit tests:
83
84- Code parsing core HTTP Request
85- Code modifying core HTTP Response, including full request/response handlers
86- Code parsing Express HTTP Request or modifying Express HTTP Response
87
88#### Test request parsing
89
90Use the factory function `stubServerRequest` to create a stub request that can
91be passed to methods expecting core HTTP Request on input.
92
93```ts
94import {stubServerRequest, expect} from '@loopback/testlab';
95
96describe('parseParams', () => {
97 it('parses query string arguments', () => {
98 const request = stubServerRequest({
99 method: 'GET',
100 url: '/api/products?count=10',
101 });
102
103 const args = parseParams(request, [
104 {name: 'count', in: 'query', type: 'number'},
105 ]);
106
107 expect(args).to.eql([10]);
108 });
109});
110```
111
112#### Test response producers
113
114Use the factory function `stubHandlerContext` to create request & response stubs
115and a promise to observe the actual response as received by clients.
116
117```ts
118import {stubHandlerContext, expect} from '@loopback/testlab';
119
120describe('app REST handler', () => {
121 it('returns 404 with JSON body when URL not found', async () => {
122 const app = express();
123 const context = stubHandlerContext({
124 method: 'GET',
125 url: '/path-does-not-exist',
126 });
127
128 // Invoke Express' request handler with stubbed request/response objects
129 app(context.request, context.response);
130
131 // Wait until Express finishes writing the response
132 const actualResponse = await context.result;
133
134 // Verify the response seen by clients
135 expect(actualResponse.statusCode).to.equal(404);
136 expect(JSON.parse(actualResponse.payload)).to.containEql({
137 error: {
138 statusCode: 404,
139 message: 'Not Found',
140 },
141 });
142 });
143});
144```
145
146#### Test code expecting Express Request or Response
147
148Express modifies core HTTP request and response objects with additional
149properties and methods, it also cross-links request with response and vice
150versa. As a result, it's not possible to create Express Request object without
151the accompanying Response object.
152
153Use the factory function `stubExpressContext` to create Express-flavored request
154& response stubs and a promise to observe the actual response as received by
155clients.
156
157If your tested function is expecting a request object only:
158
159```ts
160import {stubExpressContext, expect} from '@loopback/testlab';
161
162describe('operationArgsParser', () => {
163 it('parses body parameter', async () => {
164 const req = givenRequest({
165 url: '/',
166 payload: {key: 'value'},
167 });
168
169 const spec = givenOperationWithRequestBody({
170 description: 'data',
171 content: {'application/json': {schema: {type: 'object'}}},
172 });
173 const route = givenResolvedRoute(spec);
174
175 const args = await parseOperationArgs(req, route);
176
177 expect(args).to.eql([{key: 'value'}]);
178 });
179
180 function givenRequest(options?: ShotRequestOptions): Request {
181 return stubExpressContext(options).request;
182 }
183});
184```
185
186Tests verifying code producing HTTP response can await `context.result` to
187receive the response as returned to clients.
188
189```ts
190import {stubExpressContext, expect} from '@loopback/testlab';
191
192describe('response writer', () => {
193 it('writes object result to response as JSON', async () => {
194 const context = stubExpressContext();
195
196 writeResultToResponse(context.response, {name: 'Joe'});
197 const result = await context.result;
198
199 expect(result.headers['content-type']).to.eql('application/json');
200 expect(result.payload).to.equal('{"name":"Joe"}');
201 });
202});
203```
204
205### `skipIf`
206
207Helper function for skipping tests when a certain condition is met. Use this
208helper together with `it` or `describe`.
209
210```ts
211skipIf(someCondition, it, 'does something', async () => {
212 // the test code
213});
214```
215
216Unfortunately, type inference does not work well for `describe`, you have to
217help the compiler to figure out the correct types.
218
219```ts
220skipIf<[(this: Suite) => void], void>(
221 someCondition,
222 describe,
223 'some suite name',
224 () => {
225 // define the test cases
226 },
227);
228```
229
230Under the hood, `skipIf` invokes the provided test verb by default (e.g. `it`).
231When the provided condition was true, then it calls `.skip` instead (e.g.
232`it.skip`).
233
234### `skipOnTravis`
235
236Helper function for skipping tests on Travis environment. If you need to skip
237testing on Travis for any reason, use this helper together with `it` or
238`describe`.
239
240```ts
241skipOnTravis(it, 'does something when some condition', async () => {
242 // the test code
243});
244```
245
246Under the hood, `skipOnTravis` invokes the provided test verb by default (e.g.
247`it`). When the helper detects Travis CI environment variables, then it calls
248`.skip` instead (e.g. `it.skip`).
249
250### `createRestAppClient`
251
252Helper function to create a `supertest` client connected to a running
253RestApplication. It is the responsibility of the caller to ensure that the app
254is running and to stop the application after all tests are done.
255
256Example use:
257
258```ts
259import {Client, createRestAppClient} from '@loopback/testlab';
260
261describe('My application', () => {
262 app: MyApplication; // extends RestApplication
263 client: Client;
264
265 before(givenRunningApplication);
266 before(() => {
267 client = createRestAppClient(app);
268 });
269 after(() => app.stop());
270
271 it('invokes GET /ping', async () => {
272 await client.get('/ping?msg=world').expect(200);
273 });
274});
275```
276
277### `givenHttpServerConfig`
278
279Helper function for generating Travis-friendly host (127.0.0.1). This is
280required because Travis is not able to handle IPv6 addresses.
281
282### `httpGetAsync`
283
284Async wrapper for making HTTP GET requests.
285
286```ts
287import {httpGetAsync} from '@loopback/testlab';
288const response = await httpGetAsync('http://example.com');
289```
290
291### `httpsGetAsync`
292
293Async wrapper for making HTTPS GET requests.
294
295```ts
296import {httpsGetAsync} from '@loopback/testlab';
297const response = await httpsGetAsync('https://example.com');
298```
299
300### `toJSON`
301
302JSON encoding does not preserve properties that are undefined. As a result,
303`deepEqual` checks fail because the expected model value contains these
304undefined property values, while the actual result returned by REST API does
305not. Use this function to convert a model instance into a data object as
306returned by REST API.
307
308```ts
309import {createClientForHandler, toJSON} from '@loopback/testlab';
310
311it('gets a todo by ID', () => {
312 return client
313 .get(`/todos/${persistedTodo.id}`)
314 .expect(200, toJSON(persistedTodo));
315});
316```
317
318### `validateApiSpec`
319
320Verify that your application API specification is a valid OpenAPI spec document.
321
322```js
323import {validateApiSpec} from '@loopback/testlab';
324import {RestServer} from '@loopback/rest';
325
326describe('MyApp', () => {
327 it('has valid spec', async () => {
328 const app = new MyApp();
329 const server = await app.getServer(RestServer);
330 await validateApiSpec(server.getApiSpec());
331 });
332});
333```
334
335### `createUnexpectedHttpErrorLogger`
336
337An error logger that logs the error only when the HTTP status code is not the
338expected HTTP status code. This is useful when writing tests for error
339responses:
340
341- When we don't want any error messages printed to the console when the server
342 responds with the expected error and the test passes.
343
344- When something else goes wrong and the server returns an unexpected error
345 status code, and we do want an error message to be printed to the console so
346 that we have enough information to troubleshoot the failing test.
347
348```ts
349import {createUnexpectedHttpErrorLogger} from '@loopback/testlab';
350import {RestApplication} from '@loopback/rest';
351
352describe('MyApp', () => {
353 it('does not log a known 401 error to console', async () => {
354 const app = new RestApplication();
355
356 const errorLogger = createUnexpectedHttpErrorLogger(401);
357 // binds the custom error logger
358 app.bind(SequenceActions.LOG_ERROR).to(errorLogger);
359
360 const spec = {
361 responses: {
362 /*...*/
363 },
364 };
365 function throwUnauthorizedError() {
366 throw new HttpErrors.Unauthorized('Unauthorized!');
367 }
368
369 app.route('get', '/', spec, throwUnauthorizedError);
370
371 await app.start();
372 // make `GET /` request, assert that 401 is returned
373 });
374});
375```
376
377### TestSandbox
378
379Many tests need use a temporary directory as the sandbox to mimic a tree of
380files. The `TestSandbox` class provides such facilities to create and manage a
381sandbox on the file system.
382
383#### Create a sandbox
384
385```ts
386// Create a sandbox as a unique temporary subdirectory under the rootPath
387const sandbox = new TestSandbox(rootPath);
388const sandbox = new TestSandbox(rootPath, {subdir: true});
389
390// Create a sandbox in the root path directly
391// This is same as the old behavior
392const sandbox = new TestSandbox(rootPath, {subdir: false});
393
394// Create a sandbox in the `test1` subdirectory of the root path
395const sandbox = new TestSandbox(rootPath, {subdir: 'test1'});
396
397// To access the target directory of a sandbox
398console.log(sandbox.path);
399```
400
401#### Reset a sandbox
402
403All files inside a sandbox will be removed when the sandbox is reset. We also
404try to remove cache from `require`.
405
406```ts
407await sandbox.reset();
408```
409
410#### Delete a sandbox
411
412Removes all files and mark the sandbox unusable.
413
414```ts
415await sandbox.delete();
416```
417
418#### Create a directory
419
420Recursively creates a directory within the sandbox.
421
422```ts
423await sandbox.mkdir(dir);
424```
425
426#### Copy a file
427
428Copies a file from src to the TestSandbox. If copying a `.js` file which has an
429accompanying `.js.map` file in the src file location, the dest file will have
430its sourceMappingURL updated to point to the original file as an absolute path
431so you don't need to copy the map file.
432
433```ts
434await sandbox.copyFile(src, dest);
435```
436
437#### Write a json file
438
439Creates a new file and writes the given data serialized as JSON.
440
441```ts
442await sandbox.writeJsonFile(dest, data);
443```
444
445#### Write a file
446
447Creates a new file and writes the given data as a UTF-8-encoded text.
448
449```ts
450await sandbox.writeFile(dest, data);
451```
452
453## Related resources
454
455For more info about `supertest`, please refer to
456[supertest](https://www.npmjs.com/package/supertest)
457
458## Contributions
459
460- [Guidelines](https://github.com/loopbackio/loopback-next/blob/master/docs/CONTRIBUTING.md)
461- [Join the team](https://github.com/loopbackio/loopback-next/issues/110)
462
463## Tests
464
465Run `npm test` from the root folder.
466
467## Contributors
468
469See
470[all contributors](https://github.com/loopbackio/loopback-next/graphs/contributors).
471
472## License
473
474MIT