1 | # LoopBack Test Lab
|
2 |
|
3 | A collection of test utilities we use to write LoopBack tests.
|
4 |
|
5 | ## Overview
|
6 |
|
7 | Test 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
|
24 | npm 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
|
33 | import {expect} from '@loopback/testlab';
|
34 |
|
35 | describe('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 |
|
45 | Table 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 |
|
70 | Spies, mocks and stubs. Learn more at <http://sinonjs.org/>.
|
71 |
|
72 | ### `shot`
|
73 |
|
74 | Stub implementation of HTTP Request and Response objects, useful for unit tests.
|
75 |
|
76 | Besides the API provided by `shot` module (see
|
77 | [API Reference](https://github.com/hapijs/shot/blob/master/API.md)), we provide
|
78 | additional APIs to better support async/await flow control and usage in
|
79 | Express-based code.
|
80 |
|
81 | There are three primary situations where you can leverage stub objects provided
|
82 | by 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 |
|
90 | Use the factory function `stubServerRequest` to create a stub request that can
|
91 | be passed to methods expecting core HTTP Request on input.
|
92 |
|
93 | ```ts
|
94 | import {stubServerRequest, expect} from '@loopback/testlab';
|
95 |
|
96 | describe('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 |
|
114 | Use the factory function `stubHandlerContext` to create request & response stubs
|
115 | and a promise to observe the actual response as received by clients.
|
116 |
|
117 | ```ts
|
118 | import {stubHandlerContext, expect} from '@loopback/testlab';
|
119 |
|
120 | describe('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 |
|
148 | Express modifies core HTTP request and response objects with additional
|
149 | properties and methods, it also cross-links request with response and vice
|
150 | versa. As a result, it's not possible to create Express Request object without
|
151 | the accompanying Response object.
|
152 |
|
153 | Use the factory function `stubExpressContext` to create Express-flavored request
|
154 | & response stubs and a promise to observe the actual response as received by
|
155 | clients.
|
156 |
|
157 | If your tested function is expecting a request object only:
|
158 |
|
159 | ```ts
|
160 | import {stubExpressContext, expect} from '@loopback/testlab';
|
161 |
|
162 | describe('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 |
|
186 | Tests verifying code producing HTTP response can await `context.result` to
|
187 | receive the response as returned to clients.
|
188 |
|
189 | ```ts
|
190 | import {stubExpressContext, expect} from '@loopback/testlab';
|
191 |
|
192 | describe('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 |
|
207 | Helper function for skipping tests when a certain condition is met. Use this
|
208 | helper together with `it` or `describe`.
|
209 |
|
210 | ```ts
|
211 | skipIf(someCondition, it, 'does something', async () => {
|
212 | // the test code
|
213 | });
|
214 | ```
|
215 |
|
216 | Unfortunately, type inference does not work well for `describe`, you have to
|
217 | help the compiler to figure out the correct types.
|
218 |
|
219 | ```ts
|
220 | skipIf<[(this: Suite) => void], void>(
|
221 | someCondition,
|
222 | describe,
|
223 | 'some suite name',
|
224 | () => {
|
225 | // define the test cases
|
226 | },
|
227 | );
|
228 | ```
|
229 |
|
230 | Under the hood, `skipIf` invokes the provided test verb by default (e.g. `it`).
|
231 | When the provided condition was true, then it calls `.skip` instead (e.g.
|
232 | `it.skip`).
|
233 |
|
234 | ### `skipOnTravis`
|
235 |
|
236 | Helper function for skipping tests on Travis environment. If you need to skip
|
237 | testing on Travis for any reason, use this helper together with `it` or
|
238 | `describe`.
|
239 |
|
240 | ```ts
|
241 | skipOnTravis(it, 'does something when some condition', async () => {
|
242 | // the test code
|
243 | });
|
244 | ```
|
245 |
|
246 | Under 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 |
|
252 | Helper function to create a `supertest` client connected to a running
|
253 | RestApplication. It is the responsibility of the caller to ensure that the app
|
254 | is running and to stop the application after all tests are done.
|
255 |
|
256 | Example use:
|
257 |
|
258 | ```ts
|
259 | import {Client, createRestAppClient} from '@loopback/testlab';
|
260 |
|
261 | describe('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 |
|
279 | Helper function for generating Travis-friendly host (127.0.0.1). This is
|
280 | required because Travis is not able to handle IPv6 addresses.
|
281 |
|
282 | ### `httpGetAsync`
|
283 |
|
284 | Async wrapper for making HTTP GET requests.
|
285 |
|
286 | ```ts
|
287 | import {httpGetAsync} from '@loopback/testlab';
|
288 | const response = await httpGetAsync('http://example.com');
|
289 | ```
|
290 |
|
291 | ### `httpsGetAsync`
|
292 |
|
293 | Async wrapper for making HTTPS GET requests.
|
294 |
|
295 | ```ts
|
296 | import {httpsGetAsync} from '@loopback/testlab';
|
297 | const response = await httpsGetAsync('https://example.com');
|
298 | ```
|
299 |
|
300 | ### `toJSON`
|
301 |
|
302 | JSON encoding does not preserve properties that are undefined. As a result,
|
303 | `deepEqual` checks fail because the expected model value contains these
|
304 | undefined property values, while the actual result returned by REST API does
|
305 | not. Use this function to convert a model instance into a data object as
|
306 | returned by REST API.
|
307 |
|
308 | ```ts
|
309 | import {createClientForHandler, toJSON} from '@loopback/testlab';
|
310 |
|
311 | it('gets a todo by ID', () => {
|
312 | return client
|
313 | .get(`/todos/${persistedTodo.id}`)
|
314 | .expect(200, toJSON(persistedTodo));
|
315 | });
|
316 | ```
|
317 |
|
318 | ### `validateApiSpec`
|
319 |
|
320 | Verify that your application API specification is a valid OpenAPI spec document.
|
321 |
|
322 | ```js
|
323 | import {validateApiSpec} from '@loopback/testlab';
|
324 | import {RestServer} from '@loopback/rest';
|
325 |
|
326 | describe('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 |
|
337 | An error logger that logs the error only when the HTTP status code is not the
|
338 | expected HTTP status code. This is useful when writing tests for error
|
339 | responses:
|
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
|
349 | import {createUnexpectedHttpErrorLogger} from '@loopback/testlab';
|
350 | import {RestApplication} from '@loopback/rest';
|
351 |
|
352 | describe('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 |
|
379 | Many tests need use a temporary directory as the sandbox to mimic a tree of
|
380 | files. The `TestSandbox` class provides such facilities to create and manage a
|
381 | sandbox on the file system.
|
382 |
|
383 | #### Create a sandbox
|
384 |
|
385 | ```ts
|
386 | // Create a sandbox as a unique temporary subdirectory under the rootPath
|
387 | const sandbox = new TestSandbox(rootPath);
|
388 | const sandbox = new TestSandbox(rootPath, {subdir: true});
|
389 |
|
390 | // Create a sandbox in the root path directly
|
391 | // This is same as the old behavior
|
392 | const sandbox = new TestSandbox(rootPath, {subdir: false});
|
393 |
|
394 | // Create a sandbox in the `test1` subdirectory of the root path
|
395 | const sandbox = new TestSandbox(rootPath, {subdir: 'test1'});
|
396 |
|
397 | // To access the target directory of a sandbox
|
398 | console.log(sandbox.path);
|
399 | ```
|
400 |
|
401 | #### Reset a sandbox
|
402 |
|
403 | All files inside a sandbox will be removed when the sandbox is reset. We also
|
404 | try to remove cache from `require`.
|
405 |
|
406 | ```ts
|
407 | await sandbox.reset();
|
408 | ```
|
409 |
|
410 | #### Delete a sandbox
|
411 |
|
412 | Removes all files and mark the sandbox unusable.
|
413 |
|
414 | ```ts
|
415 | await sandbox.delete();
|
416 | ```
|
417 |
|
418 | #### Create a directory
|
419 |
|
420 | Recursively creates a directory within the sandbox.
|
421 |
|
422 | ```ts
|
423 | await sandbox.mkdir(dir);
|
424 | ```
|
425 |
|
426 | #### Copy a file
|
427 |
|
428 | Copies a file from src to the TestSandbox. If copying a `.js` file which has an
|
429 | accompanying `.js.map` file in the src file location, the dest file will have
|
430 | its sourceMappingURL updated to point to the original file as an absolute path
|
431 | so you don't need to copy the map file.
|
432 |
|
433 | ```ts
|
434 | await sandbox.copyFile(src, dest);
|
435 | ```
|
436 |
|
437 | #### Write a json file
|
438 |
|
439 | Creates a new file and writes the given data serialized as JSON.
|
440 |
|
441 | ```ts
|
442 | await sandbox.writeJsonFile(dest, data);
|
443 | ```
|
444 |
|
445 | #### Write a file
|
446 |
|
447 | Creates a new file and writes the given data as a UTF-8-encoded text.
|
448 |
|
449 | ```ts
|
450 | await sandbox.writeFile(dest, data);
|
451 | ```
|
452 |
|
453 | ## Related resources
|
454 |
|
455 | For 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 |
|
465 | Run `npm test` from the root folder.
|
466 |
|
467 | ## Contributors
|
468 |
|
469 | See
|
470 | [all contributors](https://github.com/loopbackio/loopback-next/graphs/contributors).
|
471 |
|
472 | ## License
|
473 |
|
474 | MIT
|