Overview

A thorough automated test suite is important because it:

  • Ensures your application works as expected.
  • Prevents regressions when new features are added and bugs are fixed.
  • Helps new and existing developers understand different parts of the codebase(knowledge sharing).
  • Speeds up development over the long run (the code writes itself!).

Types of tests

We encourage writing tests from a few perspectives, mainlyblack-box testing(acceptance) andwhite-box testing(integration and unit). Tests are usually written using typical patterns such asarrange/act/assertor given/when/then. Bothstyles work well, so pick one that you’re comfortable with and start writingtests!

For an introduction to automated testing, seeDefine your testing strategy.

Important: A great test suite requires you to thinksmaller and favor fast and focused unit tests over slow end-to-end tests.

This article is a reference guide for common types of tests and test helpers.

Project setup

An automated test suite requires a test runner to execute all the tests andproduce a summary report. We use and recommend Mocha.

In addition to a test runner, the test suites generally require:

  • An assertion library (we recommend Should.js).
  • A library for making HTTP calls and verifying their results (we recommendsupertest).
  • A library for creating test doubles (we recommendSinon.JS).The @loopback/testlab moduleintegrates these packages and makes them easy to use together with LoopBack.

Set up testing infrastructure with LoopBack CLI

LoopBack applications that have been generated using the lb4 app command from@loopback/cli come with @loopback/testlab and mocha as a default, so noother testing infrastructure setup is needed.

Setup testing infrastructure manually

If you have an existing application install mocha and @loopback/testlab:

  1. npm install --save-dev mocha @loopback/testlab

Your package.json should then look something like this:

  1. {
  2. // ...
  3. "devDependencies": {
  4. "@loopback/testlab": "^<current-version>",
  5. "@types/mocha": "^<current-version>",
  6. "mocha": "^<current-version>"
  7. },
  8. "scripts": {
  9. "test": "mocha --recursive \"dist/test\""
  10. }
  11. // ...
  12. }

Data handling

Tests accessing a real database often require existing data. For example, amethod listing all products needs some products in the database; a method tocreate a new product instance must determine which properties are required andany restrictions on their values. There are various approaches to address thisissue. Many of them unfortunately make the test suite difficult to understand,difficult to maintain, and prone to test failures unrelated to the changes made.

Our approach to data handling, based on our experience, is described in thissection.

Create a test datasource

As we would prefer full control of the database and even the developmentdatabase is not something we want to clean before each test it’s handy to use anindependent in-memory datasource which is filled appropriately usingtest data builders before each test run.

src/tests/fixtures/datasources/testdb.datasource.ts

  1. import {juggler} from '@loopback/repository';
  2. export const testdb: juggler.DataSource = new juggler.DataSource({
  3. name: 'db',
  4. connector: 'memory',
  5. });

Clean the database before each test

Start with a clean database before each test. This may seem counter-intuitive:why not reset the database after the test has finished? When a test fails andthe database is cleaned after the test has finished, then it’s difficult toobserve what was stored in the database and why the test failed. When thedatabase is cleaned in the beginning, then any failing test will leave thedatabase in the state that caused the test to fail.

To clean the database before each test, set up a beforeEach hook to call ahelper method; for example:

src/tests/helpers/database.helpers.ts

  1. import {ProductRepository, CategoryRepository} from '../../src/repositories';
  2. import {testdb} from '../fixtures/datasources/testdb.datasource';
  3. export async function givenEmptyDatabase() {
  4. await new ProductRepository(testdb).deleteAll();
  5. await new CategoryRepository(testdb).deleteAll();
  6. }

In case a repository includes a relation to another repository, ie. Productbelongs to Category, include it in the repository call, for example:

src/tests/helpers/database.helpers.ts

  1. import {Getter} from '@loopback/context';
  2. import {ProductRepository, CategoryRepository} from '../../src/repositories';
  3. import {testdb} from '../fixtures/datasources/testdb.datasource';
  4. export async function givenEmptyDatabase() {
  5. let categoryRepository: CategoryRepository;
  6. let productRepository: ProductRepository;
  7. categoryRepository = new CategoryRepository(
  8. testdb,
  9. async () => productRepository,
  10. );
  11. productRepository = new ProductRepository(
  12. testdb,
  13. async () => categoryRepository,
  14. );
  15. await productRepository.deleteAll();
  16. await categoryRepository.deleteAll();
  17. }

src/tests/integration/controllers/product.controller.integration.ts

  1. // in your test file
  2. import {givenEmptyDatabase} from '../../helpers/database.helpers';
  3. describe('ProductController (integration)', () => {
  4. before(givenEmptyDatabase);
  5. // etc.
  6. });

Use test data builders

To avoid duplicating code for creating model data that is complete with requiredproperties, use sharedtest data builders. This enablestests to provide the small subset of properties that is strictly required by thetested scenario. Using shared test builders will help your tests to be:

  • Easier to understand, since it’s immediately clear what model properties arerelevant to the tests. If the tests set the required properties, it isdifficult to tell whether the properties are actually relevant to the testedscenario.

  • Easier to maintain. As your data model evolves, you will need to add morerequired properties. If the tests build the model instance data manually, allthe tests must be manually updated to set a new required property. With ashared test data builder, you update a single location with the new property.

See@loopback/openapi-spec-builderfor an example of how to apply this design pattern for building OpenAPI Specdocuments.

In practice, a simple function that adds missing required properties issufficient.

src/tests/helpers/database.helpers.ts

  1. // ...
  2. export function givenProductData(data?: Partial<Product>) {
  3. return Object.assign(
  4. {
  5. name: 'a-product-name',
  6. slug: 'a-product-slug',
  7. price: 1,
  8. description: 'a-product-description',
  9. available: true,
  10. },
  11. data,
  12. );
  13. }
  14. export async function givenProduct(data?: Partial<Product>) {
  15. return new ProductRepository(testdb).create(givenProductData(data));
  16. }
  17. // ...

Avoid sharing the same data for multiple tests

It’s tempting to define a small set of data to be shared by all tests. Forexample, in an e-commerce application, you might pre-populate the database witha few categories, some products, an admin user and a customer. This approach hasseveral downsides:

  • When trying to understand any individual test, it’s difficult to tell whatpart of the pre-populated data is essential for the test and what’sirrelevant. For example, in a test checking the method counting the number ofproducts in a category using a pre-populated category “Stationery”, is itimportant that “Stationery” contains nested sub-categories or is that factirrelevant? If it’s irrelevant, then what are the other tests that depend onit?

  • As the application grows and new features are added, it’s easier to add moreproperties to existing model instances rather than create new instances usingonly the properties required by the new features. For example, when adding acategory image, it’s easier to add image to an existing category “Stationery”and perhaps keep another category “Groceries” without any image, rather thancreating two new categories “CategoryWithAnImage” and “CategoryMissingImage”.This further amplifies the previous problem, because it’s not clear that“Groceries” is the category that should be used by tests requiring a categorywith no image - the category name does not provide any hints on that.

  • As the shared dataset grows (together with the application), the time requiredto bring the database into its initial state grows too. Instead of running afew “DELETE ALL” queries before each test (which is relatively fast), you mayhave to run tens or hundreds of different commands used to create differentmodel instances, thus triggering slow index rebuilds along the way and slowingdown the test suite considerably.

Use the test data builders described in the previous section to populate yourdatabase with the data specific to your test only.Write higher-level helpers to share the code for re-creating common scenarios.For example, if your application has two kinds of users (admins and customers),then you may write the following helpers to simplify writing acceptance testschecking access control:

  1. async function givenAdminAndCustomer() {
  2. return {
  3. admin: await givenUser({role: Roles.ADMIN}),
  4. customer: await givenUser({role: Roles.CUSTOMER}),
  5. };
  6. }

Unit testing

Unit tests are considered “white-box” tests because they use an “inside-out”approach where the tests know about the internals and control all the variablesof the system being tested. Individual units are tested in isolation and theirdependencies are replaced withTest doubles.

Use test doubles

Test doubles are functions or objects that look and behave like the realvariants used in production, but are actually simplified versions that give thetest more control of the behavior. For example, reproducing the situation wherereading from a file failed because of a hard-drive error is pretty muchimpossible. However, using a test double to simulate the file-system API willprovide control over what each call returns.

Sinon.JS has become the de-facto standard for testdoubles in Node.js and JavaScript/TypeScript in general. The @loopback/testlabpackage comes with Sinon preconfigured with TypeScript type definitions andintegrated with Should.js assertions.

There are three kinds of test doubles provided by Sinon.JS:

  • Test spies are functions thatrecord arguments, the return value, the value of this, and exceptions thrown(if any) for all its calls. There are two types of spies: Some are anonymousfunctions, while others wrap methods that already exist in the system undertest.

  • Test stubs are functions (spies)with pre-programmed behavior. As spies, stubs can be either anonymous, or wrapexisting functions. When wrapping an existing function with a stub, theoriginal function is not called.

  • Test mocks (and mockexpectations) are fake methods (like spies) with pre-programmed behavior (likestubs) as well as pre-programmed expectations. A mock will fail your test ifit is not used as expected.

Note:

We recommend against using test mocks. With testmocks, the expectations must be defined before the tested scenario is executed,which breaks the recommended test layout ‘arrange-act-assert’ (or‘given-when-then’) and also produces code that’s difficult to comprehend.

Create a stub Repository

When writing an application that accesses data in a database, the best practiceis to use repositories to encapsulate alldata-access/persistence-related code. Other parts of the application (typicallycontrollers) can then depend on these repositories for dataaccess. To test Repository dependents (for example, Controllers) in isolation,we need to provide a test double, usually as a test stub.

In traditional object-oriented languages like Java or C#, to enable unit teststo provide a custom implementation of the repository API, the controller needsto depend on an interface describing the API, and the repository implementationneeds to implement this interface. The situation is easier in JavaScript andTypeScript. Thanks to the dynamic nature of the language, it’s possible tomock/stub entire classes.

Creating a test double for a repository class is very easy using the Sinon.JSutility function createStubInstance. It’s important to create a new stubinstance for each unit test in order to prevent unintended re-use ofpre-programmed behavior between (unrelated) tests.

  1. describe('ProductController', () => {
  2. let repository: ProductRepository;
  3. beforeEach(givenStubbedRepository);
  4. // your unit tests
  5. function givenStubbedRepository() {
  6. repository = sinon.createStubInstance(ProductRepository);
  7. }
  8. });

In your unit tests, you will usually want to program the behavior of stubbedmethods (what they should return) and then verify that the Controller (unitunder test) called the right method with the correct arguments.

Configure stub’s behavior at the beginning of your unit test (in the “arrange”or “given” section):

  1. // repository.find() will return a promise that
  2. // will be resolved with the provided array
  3. const findStub = repository.find as sinon.SinonStub;
  4. findStub.resolves([{id: 1, name: 'Pen'}]);

Verify how was the stubbed method executed at the end of your unit test (in the“assert” or “then” section):

  1. // expect that repository.find() was called with the first
  2. // argument deeply-equal to the provided object
  3. sinon.assert.calledWithMatch(findStub, {where: {id: 1}});

See Unit test your controllers for a fullexample.

Create a stub Service

If your controller relies on service proxy for service oriented backends such asREST APIs, SOAP Web Services, or gRPC microservices, then we can create stubsfor the service akin to the steps outlined in the abovestub repository section. Consider a dependency on aGeoCoder service that relies on a remote REST API for returning coordinatesfor a specific address.

  1. export interface GeocoderService {
  2. geocode(address: string): Promise<GeoPoint[]>;
  3. }

The first step is to create a mocked instance of the GeocoderService API andconfigure its geocode method as a Sinon stub:

  1. describe('GeocoderController', () => {
  2. let geoService: GeoCoderService;
  3. let geocode: sinon.SinonStub;
  4. beforeEach(givenMockGeoCoderService);
  5. // your unit tests
  6. function givenMockGeoCoderService() {
  7. // this creates a stub with GeocoderService API
  8. // in a way that allows the compiler to verify type correctness
  9. geoService = {geocode: sinon.stub()};
  10. // this creates a reference to the stubbed "geocode" method
  11. // because "geoService.geocode" has type from GeocoderService
  12. // and does not provide Sinon APIs
  13. geocode = geoService.geocode as sinon.SinonStub;
  14. }
  15. });

Afterwards, we can configure the geocode stub’s behaviour before the actphase of our test(s):

  1. // geoService.geocode() will return a promise that
  2. // will be resolved with the provided array
  3. geocode.resolves([<GeoPoint>{y: 41.109653, x: -73.72467}]);

Lastly, we’ll verify how the stub was executed:

  1. // expect that geoService.geocode() was called with the first
  2. // argument equal to the provided address string
  3. sinon.assert.calledWithMatch(geocode, '1 New Orchard Road, Armonk, 10504');

Check outTodoController unit testsillustrating the above points in action for more information.

Unit test your Controllers

Unit tests should apply to the smallest piece of code possible to ensure thatother variables and state changes do not pollute the result. A typical unit testcreates a controller instance with dependencies replaced by test doubles anddirectly calls the tested method. The example below gives the controller a stubimplementation of its repository dependency using the testlabcreateStubInstance function, ensures the controller calls the repository’sfind() method with a correct query, and returns back the query results. SeeCreate a stub repository for a detailedexplanation.

src/tests/unit/controllers/product.controller.unit.ts

  1. import {
  2. createStubInstance,
  3. expect,
  4. sinon,
  5. StubbedInstanceWithSinonAccessor,
  6. } from '@loopback/testlab';
  7. import {ProductRepository} from '../../../src/repositories';
  8. import {ProductController} from '../../../src/controllers';
  9. describe('ProductController (unit)', () => {
  10. let repository: StubbedInstanceWithSinonAccessor<ProductRepository>;
  11. beforeEach(givenStubbedRepository);
  12. describe('getDetails()', () => {
  13. it('retrieves details of a product', async () => {
  14. const controller = new ProductController(repository);
  15. repository.stubs.find.resolves([{name: 'Pen', slug: 'pen'}]);
  16. const details = await controller.getDetails('pen');
  17. expect(details).to.containEql({name: 'Pen', slug: 'pen'});
  18. sinon.assert.calledWithMatch(repository.stubs.find, {
  19. where: {slug: 'pen'},
  20. });
  21. });
  22. });
  23. function givenStubbedRepository() {
  24. repository = createStubInstance(ProductRepository);
  25. }
  26. });

Unit test your models and repositories

In a typical LoopBack application, models and repositories rely on behaviorprovided by the framework (@loopback/repository package) and there is no needto test LoopBack’s built-in functionality. However, any additionalapplication-specific APIs do need new unit tests.

For example, if the Person Model has properties firstname, middlename andsurname and provides a function to obtain the full name, then you should writeunit tests to verify the implementation of this additional method.

Remember to use Test data builders whenever you needvalid data to create a new model instance.

src/tests/unit/models/person.model.unit.ts

  1. import {Person} from '../../../src/models';
  2. import {givenPersonData} from '../../helpers/database.helpers';
  3. import {expect} from '@loopback/testlab';
  4. describe('Person (unit)', () => {
  5. // we recommend to group tests by method names
  6. describe('getFullName()', () => {
  7. it('uses all three parts when present', () => {
  8. const person = givenPerson({
  9. firstname: 'Jane',
  10. middlename: 'Smith',
  11. surname: 'Brown',
  12. });
  13. const fullName = person.getFullName();
  14. expect(fullName).to.equal('Jane Smith Brown');
  15. });
  16. it('omits middlename when not present', () => {
  17. const person = givenPerson({
  18. firstname: 'Mark',
  19. surname: 'Twain',
  20. });
  21. const fullName = person.getFullName();
  22. expect(fullName).to.equal('Mark Twain');
  23. });
  24. });
  25. function givenPerson(data: Partial<Person>) {
  26. return new Person(givenPersonData(data));
  27. }
  28. });

Writing a unit test for custom repository methods is not as straightforwardbecause CrudRepository is based on legacyloopback-datasource-jugglerwhich was not designed with dependency injection in mind. Instead, useintegration tests to verify the implementation of custom repository methods. Formore information, refer toTest your repositories against a real databasein Integration Testing.

Unit test your Sequence

While it’s possible to test a custom Sequence class in isolation, it’s better torely on acceptance-level tests in this exceptional case. The reason is that acustom Sequence class typically has many dependencies (which can make test setuplong and complex), and at the same time it provides very little functionality ontop of the injected sequence actions. Bugs are much more likely to be caused bythe way the real sequence action implementations interact together (which is notcovered by unit tests), instead of the Sequence code itself (which is the onlything covered).

See Test Sequence customizations inAcceptance Testing.

Integration testing

Integration tests are considered “white-box” tests because they use an“inside-out” approach that tests how multiple units work together or withexternal services. You can use test doubles to isolate tested units fromexternal variables/state that are not part of the tested scenario.

Test your repositories against a real database

There are two common reasons for adding repository tests:

  • Your models are using an advanced configuration, for example, custom columnmappings, and you want to verify this configuration is correctly picked up bythe framework.
  • Your repositories have additional methods.Integration tests are one of the places to put the best practices inData handling to work:

  • Clean the database before each test

  • Use test data builders
  • Avoid sharing the same data for multiple testsHere is an example showing how to write an integration test for a customrepository method findByName:

src/tests/integration/repositories/category.repository.integration.ts

  1. import {
  2. givenEmptyDatabase,
  3. givenCategory,
  4. } from '../../helpers/database.helpers';
  5. import {CategoryRepository} from '../../../src/repositories';
  6. import {expect} from '@loopback/testlab';
  7. import {testdb} from '../../fixtures/datasources/testdb.datasource';
  8. describe('CategoryRepository (integration)', () => {
  9. beforeEach(givenEmptyDatabase);
  10. describe('findByName(name)', () => {
  11. it('return the correct category', async () => {
  12. const stationery = await givenCategory({name: 'Stationery'});
  13. const repository = new CategoryRepository(testdb);
  14. const found = await repository.findByName('Stationery');
  15. expect(found).to.deepEqual(stationery);
  16. });
  17. });
  18. });

Test controllers and repositories together

Integration tests running controllers with real repositories are important toverify that the controllers use the repository API correctly, and that thecommands and queries produce expected results when executed on a real database.These tests are similar to repository tests with controllers added as anotheringredient.

src/tests/integration/controllers/product.controller.integration.ts

  1. import {expect} from '@loopback/testlab';
  2. import {givenEmptyDatabase, givenProduct} from '../../helpers/database.helpers';
  3. import {ProductController} from '../../../src/controllers';
  4. import {ProductRepository} from '../../../src/repositories';
  5. import {testdb} from '../../fixtures/datasources/testdb.datasource';
  6. describe('ProductController (integration)', () => {
  7. beforeEach(givenEmptyDatabase);
  8. describe('getDetails()', () => {
  9. it('retrieves details of the given product', async () => {
  10. const pencil = await givenProduct({name: 'Pencil', slug: 'pencil'});
  11. const controller = new ProductController(new ProductRepository(testdb));
  12. const details = await controller.getDetails('pencil');
  13. expect(details).to.containEql(pencil);
  14. });
  15. });
  16. });

Test your Services against real backends

When integrating with other services (including our own microservices), it’simportant to verify that our client configuration is correct and the client(service proxy) API is matching the actual service implementation. Ideally,there should be at least one integration test for each endpoint (operation)consumed by the application.

To write an integration test, we need to:

  • Obtain an instance of the tested service proxy. Optionally modify theconnection configuration, for example change the target URL or configure acaching proxy to speed up tests.
  • Execute service proxy methods and verify that expected results were returnedby the backend service.

Obtain a Service Proxy instance

InMake service proxies easier to test,we are suggesting to leverage Providers as a tool allowing both the IoCframework and the tests to access service proxy instances.

In the integration tests, a test helper should be written to obtain an instanceof the service proxy by invoking the provider. This helper should be typicallyinvoked once before the integration test suite begins.

  1. import {
  2. GeoService,
  3. GeoServiceProvider,
  4. } from '../../src/services/geo.service.ts';
  5. import {GeoDataSource} from '../../src/datasources/geo.datasource.ts';
  6. describe('GeoService', () => {
  7. let service: GeoService;
  8. before(givenGeoService);
  9. // to be done: add tests here
  10. function givenGeoService() {
  11. const dataSource = new GeoDataSource();
  12. service = new GeoServiceProvider(dataSource).value();
  13. }
  14. });

If needed, you can tweak the datasource config before creating the serviceinstance:

  1. import {merge} from 'lodash';
  2. import * as GEO_CODER_CONFIG from '../src/datasources/geo.datasource.json';
  3. function givenGeoService() {
  4. const config = merge({}, GEO_CODER_CONFIG, {
  5. // your config overrides
  6. });
  7. const dataSource = new GeoDataSource(config);
  8. service = new GeoServiceProvider(dataSource).value();
  9. }

Test invidivudal service methods

With the service proxy instance available, integration tests can focus onexecuting individual methods with the right set of input parameters; andverifying the outcome of those calls.

  1. it('resolves an address to a geo point', async () => {
  2. const points = await service.geocode('1 New Orchard Road, Armonk, 10504');
  3. expect(points).to.deepEqual([
  4. {
  5. lat: 41.109653,
  6. lng: -73.72467,
  7. },
  8. ]);
  9. });

Acceptance (end-to-end) testing

Automated acceptance (end-to-end) tests are considered “black-box” tests becausethey use an “outside-in” approach that is not concerned about the internals ofthe system. Acceptance tests perform the same actions (send the same HTTPrequests) as the clients and consumers of your API will do, and verify that theresults returned by the system match the expected results.

Typically, acceptance tests start the application, make HTTP requests to theserver, and verify the returned response. LoopBack usessupertest to create test code thatsimplifies both the execution of HTTP requests and the verification ofresponses. Remember to follow the best practices fromData handling when setting up your database for tests:

  • Clean the database before each test
  • Use test data builders
  • Avoid sharing the same data for multiple tests

Validate your OpenAPI specification

The OpenAPI specification is a cornerstone of applications that provide RESTAPIs. It enables API consumers to leverage a whole ecosystem of related tooling.To make the spec useful, you must ensure it’s a valid OpenAPI Spec document,ideally in an automated way that’s an integral part of regular CI builds.LoopBack’s testlab moduleprovides a helper method validateApiSpec that builds on top of the popularswagger-parser package.

Example usage:

src/tests/acceptance/api-spec.acceptance.ts

  1. import {HelloWorldApplication} from '../..';
  2. import {RestServer} from '@loopback/rest';
  3. import {validateApiSpec} from '@loopback/testlab';
  4. describe('API specification', () => {
  5. it('api spec is valid', async () => {
  6. const app = new HelloWorldApplication();
  7. const server = await app.getServer(RestServer);
  8. const spec = server.getApiSpec();
  9. await validateApiSpec(spec);
  10. });
  11. });

Perform an auto-generated smoke test of your REST API

Important: The top-down approach for building LoopBackapplications is not yet fully supported. Therefore, the code outlined in thissection is outdated and may not work out of the box. Check outhttps://github.com/strongloop/loopback-next/issues/1882 for the epic trackingthe feature and OpenAPI generator page for artifactgeneration from OpenAPI specs.

The formal validity of your application’s spec does not guarantee that yourimplementation is actually matching the specified behavior. To keep your spec insync with your implementation, you should use an automated tool likeDredd to run a set of smoke tests toverify your app conforms to the spec.

Automated testing tools usually require hints in your specification to tell themhow to create valid requests or what response data to expect. Dredd inparticular relies on responseexamplesand request parameterx-examplefields. Extending your API spec with examples is a good thing on its own, sincedevelopers consuming your API will find them useful too.

Here is an example showing how to run Dredd to test your API against the spec:

src/tests/acceptance/api-spec.acceptance.ts

  1. import {expect} from '@loopback/testlab';
  2. import {HelloWorldApplication} from '../..';
  3. import {RestServer, RestBindings} from '@loopback/rest';
  4. import {spec} from '../../apidefs/openapi';
  5. const Dredd = require('dredd');
  6. describe('API (acceptance)', () => {
  7. let app: HelloWorldApplication;
  8. /* eslint-disable @typescript-eslint/no-explicit-any */
  9. let dredd: any;
  10. before(initEnvironment);
  11. after(async () => {
  12. await app.stop();
  13. });
  14. it('conforms to the specification', done => {
  15. dredd.run((err: Error, stats: object) => {
  16. if (err) return done(err);
  17. expect(stats).to.containDeep({
  18. failures: 0,
  19. errors: 0,
  20. skipped: 0,
  21. });
  22. done();
  23. });
  24. });
  25. async function initEnvironment() {
  26. app = new HelloWorldApplication();
  27. const server = await app.getServer(RestServer);
  28. // For testing, we'll let the OS pick an available port by setting
  29. // RestBindings.PORT to 0.
  30. server.bind(RestBindings.PORT).to(0);
  31. // app.start() starts up the HTTP server and binds the acquired port
  32. // number to RestBindings.PORT.
  33. await app.boot();
  34. await app.start();
  35. // Get the real port number.
  36. const port = await server.get(RestBindings.PORT);
  37. const baseUrl = `http://localhost:${port}`;
  38. const config: object = {
  39. server: baseUrl, // base path to the end points
  40. options: {
  41. level: 'fail', // report 'fail' case only
  42. silent: false, // false for helpful debugging info
  43. path: [`${baseUrl}/openapi.json`], // to download apiSpec from the service
  44. },
  45. };
  46. dredd = new Dredd(config);
  47. }
  48. });

The user experience needs improvement and we are looking into better solutions.See GitHub issue #644.Let us know if you have any recommendations!

Test your individual REST API endpoints

You should have at least one acceptance (end-to-end) test for each of your RESTAPI endpoints. Consider adding more tests if your endpoint depends on (custom)sequence actions to modify the behavior when the corresponding controller methodis invoked via REST, compared to behavior observed when the controller method isinvoked directly via JavaScript/TypeScript API. For example, if your endpointreturns different responses to regular users and to admin users, then you shouldtwo tests (one test for each user role).

Here is an example of an acceptance test:

src/tests/acceptance/product.acceptance.ts

  1. import {HelloWorldApplication} from '../..';
  2. import {Client, createRestAppClient, expect} from '@loopback/testlab';
  3. import {givenEmptyDatabase, givenProduct} from '../helpers/database.helpers';
  4. import {RestServer, RestBindings} from '@loopback/rest';
  5. import {testdb} from '../fixtures/datasources/testdb.datasource';
  6. describe('Product (acceptance)', () => {
  7. let app: HelloWorldApplication;
  8. let client: Client;
  9. before(givenEmptyDatabase);
  10. before(givenRunningApp);
  11. after(async () => {
  12. await app.stop();
  13. });
  14. it('retrieves product details', async () => {
  15. // arrange
  16. const product = await givenProduct({
  17. name: 'Ink Pen',
  18. slug: 'ink-pen',
  19. price: 1,
  20. category: 'Stationery',
  21. description: 'The ultimate ink-powered pen for daily writing',
  22. label: 'popular',
  23. available: true,
  24. endDate: null,
  25. });
  26. const expected = Object.assign({id: product.id}, product);
  27. // act
  28. const response = await client.get('/product/ink-pen');
  29. // assert
  30. expect(response.body).to.containEql(expected);
  31. });
  32. async function givenRunningApp() {
  33. app = new HelloWorldApplication({
  34. rest: {
  35. port: 0,
  36. },
  37. });
  38. app.dataSource(testdb);
  39. await app.boot();
  40. await app.start();
  41. client = createRestAppClient(app);
  42. }
  43. });

Test Sequence customizations

Custom sequence behavior is best tested by observing changes in behavior of theaffected endpoints. For example, if your sequence has an authentication stepthat rejects anonymous requests for certain endpoints, then you can write a testmaking an anonymous request to those endpoints to verify that it’s correctlyrejected. These tests are essentially the same as the tests verifyingimplementation of individual endpoints as described in the previous section.