├── .github
└── workflows
│ └── nodejs.yml
├── .gitignore
├── .nvmrc
├── .prettierrc.json
├── CONTRIBUTING.md
├── README.md
├── anti-patterns
├── anti-pattern-data-isolation.test.js
├── anti-pattern.error-handling.test.js
├── anti-pattern.isolation.test.js
└── anti-pattern.message-queue.test.js
├── example-application
├── business-logic
│ └── order-service.js
├── data-access
│ ├── config
│ │ └── config.js
│ ├── migrations
│ │ └── 20191229152126-entire-schema.js
│ ├── order-repository.js
│ └── seeders
│ │ └── 20191229151823-countries.js
├── entry-points
│ ├── api.ts
│ └── message-queue-consumer.js
├── error-handling.js
├── libraries
│ ├── authentication-middleware.js
│ ├── config.js
│ ├── fake-message-queue-provider.js
│ ├── logger.js
│ ├── mailer.js
│ ├── message-queue-client.js
│ ├── test-helpers.ts
│ └── types.ts
├── openapi.json
├── readme.md
├── start.js
└── test
│ ├── createOrder.callingOtherServices.test.ts
│ ├── createOrder.messageQueueCheck.test.ts
│ ├── createOrder.observabilityCheck.test.ts
│ ├── createOrder.responseCheck.test.ts
│ ├── createOrder.stateCheck.test.ts
│ ├── order-data-factory.ts
│ ├── pool.ts
│ └── setup
│ ├── docker-compose.yml
│ ├── global-setup.js
│ ├── global-teardown.js
│ └── test-file-setup.ts
├── graphics
├── component-diagram.jpg
├── component-tests-explainer-video.mp4
├── component-tests-header.jpg
├── component-tests-v4.jpg
├── contract-options.png
├── db-clean-options.png
├── exit-doors.png
├── low-value.png
├── main-header.jpg
├── main-header.png
├── mq-comparison.png
├── operations.md
├── team
│ ├── daniel.jpg
│ ├── github-light.png
│ ├── github.png
│ ├── linkedin.png
│ ├── michael.jpg
│ ├── twitter.png
│ ├── website.png
│ └── yoni.jpg
├── test-data-types.png
├── test-report-by-route.png
├── testing-best-practices-banner.png
└── unleash-the-power.png
├── improve.txt
├── jest.config.js
├── mocking.md
├── package-lock.json
├── package.json
├── recipes
├── consumer-driven-contract-test
│ └── readme.md
├── db-optimization
│ ├── docker-compose-mongo.yml
│ ├── docker-compose-mysql.yml
│ ├── docker-compose-postgres.yml
│ └── mysql-init-scripts
│ │ └── init.sql
├── doc-driven-contract-test
│ ├── readme.md
│ └── test
│ │ └── contract-example-with-openapi.test.js
├── friendly-structure-for-reporting
│ ├── The Shop Requirements.docx
│ ├── anti-pattern-flat-report.test.js
│ ├── anti-pattern-installation-instructions.png
│ ├── hierarchy-report.orders-api.test.js
│ ├── jest.config.js
│ └── shop-requirements.png
├── mocha
│ ├── basic-mocha-tests.test.js
│ └── hooks.js
└── nestjs
│ ├── app
│ ├── app.controller.ts
│ └── app.module.ts
│ ├── main.ts
│ ├── test
│ └── basic-nest-tests.test.ts
│ ├── ts-jest.config.js
│ └── tsconfig.json
└── tsconfig.json
/.github/workflows/nodejs.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
3 |
4 | name: Node.js CI
5 |
6 | on:
7 | push:
8 | branches:
9 | - '*'
10 | pull_request:
11 | branches: [master]
12 |
13 | jobs:
14 | build:
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - uses: actions/checkout@v4
19 | - name: Setup Node.js
20 | uses: actions/setup-node@v4
21 | with:
22 | node-version-file: '.nvmrc'
23 | - name: Cache node_modules
24 | uses: actions/cache@v4
25 | with:
26 | path: |
27 | node_modules
28 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
29 | restore-keys: |
30 | ${{ runner.os }}-node-
31 |
32 | - run: npm ci
33 | - run: npm test
34 | - run: npm run test:nestjs
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-error.log*
6 | lerna-debug.log*
7 | **/.DS_Store
8 | .DS_Store
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # TypeScript v1 declaration files
46 | typings/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Microbundle cache
58 | .rpt2_cache/
59 | .rts2_cache_cjs/
60 | .rts2_cache_es/
61 | .rts2_cache_umd/
62 |
63 | # Optional REPL history
64 | .node_repl_history
65 |
66 | # Output of 'npm pack'
67 | *.tgz
68 |
69 | # Yarn Integrity file
70 | .yarn-integrity
71 |
72 | # dotenv environment variables file
73 | .env
74 | .env.test
75 |
76 | # parcel-bundler cache (https://parceljs.org/)
77 | .cache
78 |
79 | # Next.js build output
80 | .next
81 |
82 | # Nuxt.js build / generate output
83 | .nuxt
84 | dist
85 |
86 | # Gatsby files
87 | .cache/
88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
89 | # https://nextjs.org/blog/next-9-1#public-directory-support
90 | # public
91 |
92 | # vuepress build output
93 | .vuepress/dist
94 |
95 | # Serverless directories
96 | .serverless/
97 |
98 | # FuseBox cache
99 | .fusebox/
100 |
101 | # DynamoDB Local files
102 | .dynamodb/
103 |
104 | # TernJS port file
105 | .tern-port
106 |
107 | # VSCode
108 | .vscode
109 |
110 | # IntelliJ
111 | .idea
112 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 22.11
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | { "singleQuote": true, "trailingComma": "all" }
2 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | So you want to join our family, people who appreciate and love testing? 💜
2 | You might get addiced and dramatically push your testing skills, you've been warned. Still interesting? Keep reading
3 |
4 |
5 | ## Pull requests
6 |
7 | **Kindly ask first** before embarking on any significant pull request (e.g.
8 | implementing features, refactoring code, porting to a different language),
9 | otherwise you risk spending a lot of time working on something that the
10 | project's developers might not want to merge into the project.
11 |
12 | Please adhere to the coding conventions used throughout a project (indentation,
13 | accurate comments, etc.) and any other requirements (such as test coverage).
14 |
15 | Adhering to the following process is the best way to get your work
16 | included in the project:
17 |
18 | 1. [Fork](https://help.github.com/articles/fork-a-repo/) the project, clone your
19 | fork, and configure the remotes:
20 |
21 | ```bash
22 | # Clone your fork of the repo into the current directory
23 | git clone https://github.com//integration-tests-a-z.git
24 | # Navigate to the newly cloned directory
25 | cd html5-boilerplate
26 | # Assign the original repo to a remote called "upstream"
27 | git remote add upstream https://github.com/testjavascript/integration-tests-a-z.git
28 | ```
29 |
30 | 2. If you cloned a while ago, get the latest changes from upstream:
31 |
32 | ```bash
33 | git checkout master
34 | git pull upstream master
35 | ```
36 | 3. Make sure you are working with the same node version, with [nvm](https://github.com/nvm-sh/nvm):
37 | ```bash
38 | nvm use
39 | ```
40 |
41 | 3. Create a new topic branch (off the main project development branch) to
42 | contain your feature, change, or fix:
43 |
44 | ```bash
45 | git checkout -b
46 | ```
47 |
48 | 4. Commit your changes in logical chunks. Use Git's
49 | [interactive rebase](https://help.github.com/articles/about-git-rebase/)
50 | feature to tidy up your commits before making them public.
51 |
52 | 5. Locally merge (or rebase) the upstream development branch into your topic branch:
53 |
54 | ```bash
55 | git pull [--rebase] upstream master
56 | ```
57 |
58 | 6. Push your topic branch up to your fork:
59 |
60 | ```bash
61 | git push origin
62 | ```
63 |
64 | 7. [Open a Pull Request](https://help.github.com/articles/using-pull-requests/)
65 | with a clear title and description.
66 |
67 | **IMPORTANT**: By submitting a patch, you agree to allow the project
68 | owners to license your work under the terms of the [MIT License](LICENSE.txt).
69 |
--------------------------------------------------------------------------------
/anti-patterns/anti-pattern-data-isolation.test.js:
--------------------------------------------------------------------------------
1 | // ❌ Anti-Pattern file: This code contains bad practices for educational purposes
2 | const axios = require('axios');
3 | const sinon = require('sinon');
4 | const nock = require('nock');
5 | const {
6 | initializeWebServer,
7 | stopWebServer,
8 | } = require('../../example-application/entry-points/api');
9 | const { getShortUnique } = require('./test-helper');
10 |
11 | // ❌ Anti-Pattern: Test data is a global variable instead of being scoped inside tests
12 | let axiosAPIClient, existingOrderId = 0, existingOrder;
13 |
14 | beforeAll(async () => {
15 | // ️️️✅ Best Practice: Place the backend under test within the same process
16 | const apiConnection = await initializeWebServer();
17 | const axiosConfig = {
18 | baseURL: `http://127.0.0.1:${apiConnection.port}`,
19 | validateStatus: () => true, //Don't throw HTTP exceptions. Delegate to the tests to decide which error is acceptable
20 | };
21 | axiosAPIClient = axios.create(axiosConfig);
22 |
23 | // ❌ Anti-Pattern: Adding global records which are mutated by the tests. This will lead to high coupling and flakiness
24 | existingOrder = await axiosAPIClient.post('/order', {
25 | userId: 1,
26 | mode: 'approved',
27 | externalIdentifier: `some-id-1`,
28 | });
29 | });
30 |
31 | beforeEach(() => {
32 | nock('http://localhost/user/')
33 | .get(`/1`)
34 | .reply(200, {
35 | id: 1,
36 | name: 'John',
37 | })
38 | .persist();
39 | nock('http://localhost').post('/mailer/send').reply(202).persist();
40 | });
41 |
42 | afterEach(async () => {
43 | nock.cleanAll();
44 | sinon.restore();
45 | });
46 |
47 | afterAll(async () => {
48 | // ️️️✅ Best Practice: Clean-up resources after each run
49 | await stopWebServer();
50 | nock.enableNetConnect();
51 | });
52 |
53 | describe('/api', () => {
54 | describe('POST /orders', () => {
55 | test('When adding a new valid order, Then should get back HTTP 200 response', async () => {
56 | //Arrange
57 | const orderToAdd = {
58 | userId: 1,
59 | productId: 2,
60 | externalIdentifier: `100-${getShortUnique()}`,
61 | };
62 |
63 | //Act
64 | const receivedAPIResponse = await axiosAPIClient.post(
65 | '/order',
66 | orderToAdd
67 | );
68 | existingOrderId = receivedAPIResponse.data.id;
69 |
70 | //Assert
71 | expect(receivedAPIResponse.status).toBe(200);
72 | });
73 | });
74 |
75 | describe('GET /order/:id', () => {
76 | test.only('When asked for an existing order, Then get back HTTP 200 response', async () => {
77 | //Arrange
78 |
79 | //Act
80 | // ❌ Anti-Pattern: This test relies on previous tests records and will fail when get executed alone
81 | const receivedResponse = await axiosAPIClient.get(
82 | `/order/${existingOrderId}`
83 | );
84 |
85 | //Assert
86 | expect(receivedResponse.status).toBe(200);
87 | });
88 | });
89 |
90 | describe('DELETE /order/:id', () => {
91 | test('When deleting for an existing order, Then should get back 204 HTTP status', async () => {
92 | //Arrange
93 |
94 | //Act
95 | // ❌ Anti-Pattern: This test relies on previous tests records and will fail when get executed alone
96 | const receivedResponse = await axiosAPIClient.delete(
97 | `/order/${existingOrderId}`
98 | );
99 |
100 | //Assert
101 | expect(receivedResponse.status).toBe(204);
102 | });
103 | });
104 |
105 | test('When updating an already dispatched order, then should get get conflict 409', () => {
106 |
107 | });
108 |
109 | describe('Get /order', () => {
110 | test('When calling get all, Then it returns 10 records', async () => {
111 | //Arrange
112 |
113 | //Act
114 | const receivedResponse = await axiosAPIClient.get();
115 |
116 | //Assert
117 | // ❌ Anti-Pattern: 👽 The mystery-visitor syndrome, something is affecting this test, but where is it?
118 | expect(receivedResponse.data.length).toBe(10);
119 | });
120 |
121 | // ❌ Anti-Pattern: Avoid assuming that only known records exist as other tests run in parallel
122 | // and might add more records to the table
123 | test.todo(
124 | 'When adding 2 orders, then get two orders when querying for all'
125 | );
126 |
127 | // 🤔 Questionable-Pattern: Counting records in the DB. This means the test assuming it owns the
128 | // DB during the runtime
129 | test('When querying for all orders, then get all of them back', async() => {
130 | //Arrange
131 | const orderToAdd = {
132 | userId: 1,
133 | productId: 2,
134 | externalIdentifier: `some-external-id-2`,
135 | };
136 | await axiosAPIClient.post('/order',orderToAdd);
137 | await axiosAPIClient.post('/order',orderToAdd);
138 |
139 | //Act
140 | const receivedResponse = await axiosAPIClient.get();
141 |
142 | //Assert
143 | expect(receivedResponse.data.length).toBe(2);
144 | });
145 | });
146 | });
147 |
--------------------------------------------------------------------------------
/anti-patterns/anti-pattern.error-handling.test.js:
--------------------------------------------------------------------------------
1 | const axios = require('axios');
2 | const sinon = require('sinon');
3 | const nock = require('nock');
4 | const {
5 | initializeWebServer,
6 | stopWebServer,
7 | } = require('../../../example-application/entry-points/api');
8 | const OrderRepository = require('../../../example-application/data-access/order-repository');
9 | const {
10 | metricsExporter,
11 | } = require('../../../example-application/error-handling');
12 | const { AppError } = require('../../../example-application/error-handling');
13 | const logger = require('../../../example-application/libraries/logger');
14 |
15 | let axiosAPIClient;
16 |
17 | beforeAll(async () => {
18 | // ️️️✅ Best Practice: Place the backend under test within the same process
19 | const apiConnection = await initializeWebServer();
20 | const axiosConfig = {
21 | baseURL: `http://127.0.0.1:${apiConnection.port}`,
22 | validateStatus: () => true, //Don't throw HTTP exceptions. Delegate to the tests to decide which error is acceptable
23 | };
24 | axiosAPIClient = axios.create(axiosConfig);
25 |
26 | // ️️️✅ Best Practice: Ensure that this component is isolated by preventing unknown calls except for the Api-Under-Test
27 | nock.disableNetConnect();
28 | nock.enableNetConnect('127.0.0.1');
29 | });
30 |
31 | afterAll(async () => {
32 | // ️️️✅ Best Practice: Clean-up resources after each run
33 | await stopWebServer();
34 | });
35 |
36 | beforeEach(() => {
37 | // ️️️✅ Best Practice: Isolate the service under test by intercepting requests to 3rd party services
38 | nock('http://localhost/user/').get(`/1`).reply(200, {
39 | id: 1,
40 | name: 'John',
41 | });
42 | nock('http://mailer.com').post('/send').reply(202);
43 |
44 | sinon.stub(process, 'exit');
45 | });
46 |
47 | afterEach(() => {
48 | nock.cleanAll();
49 | sinon.restore();
50 | });
51 |
52 | describe('Error Handling', () => {
53 | // ⚠️ Warning: This by itself is a valid test that checks a requirement. However, one must go beyond planned
54 | // and known error types and responses and simulate unknown errors and the entire error handling flow
55 | test('When no user exists, Then get error status 404', async () => {
56 | //Arrange
57 | const orderToAdd = {
58 | userId: 0, // ❌ No such user
59 | productId: 2,
60 | mode: 'approved',
61 | };
62 |
63 | //Act
64 | const receivedResult = await axiosAPIClient.post('/order', orderToAdd);
65 |
66 | //Assert
67 | expect(receivedResult.status).toBe(404);
68 | });
69 |
70 | // ❌ Anti-Pattern: Just checking that some logging happened without verifying the existence
71 | // of imperative fields is not detailed enough
72 | test('When exception is throw during request, Then logger reports the error', async () => {
73 | //Arrange
74 | const orderToAdd = {
75 | userId: 1,
76 | productId: 2,
77 | mode: 'approved',
78 | };
79 | // ️️️✅ Best Practice: Simulate also internal error
80 | sinon
81 | .stub(OrderRepository.prototype, 'addOrder')
82 | .rejects(new AppError('saving-failed', true));
83 | const loggerDouble = sinon.stub(logger, 'error');
84 |
85 | //Act
86 | await axiosAPIClient.post('/order', orderToAdd);
87 |
88 | //Assert
89 | // ❌ Check here the existence of imperative fields, not just that a function was called
90 | expect(loggerDouble.lastCall.firstArg).toEqual(expect.any(Object));
91 | });
92 | });
93 |
--------------------------------------------------------------------------------
/anti-patterns/anti-pattern.isolation.test.js:
--------------------------------------------------------------------------------
1 | // ❌ Anti-Pattern file: This code contains bad practices for educational purposes
2 | const axios = require('axios');
3 | const sinon = require('sinon');
4 | const nock = require('nock');
5 | const {
6 | initializeWebServer,
7 | stopWebServer,
8 | } = require('../../../example-application/entry-points/api');
9 |
10 | let axiosAPIClient;
11 |
12 | beforeAll(async () => {
13 | const apiConnection = await initializeWebServer();
14 | const axiosConfig = {
15 | baseURL: `http://127.0.0.1:${apiConnection.port}`,
16 | validateStatus: () => true, //Don't throw HTTP exceptions. Delegate to the tests to decide which error is acceptable
17 | };
18 | axiosAPIClient = axios.create(axiosConfig);
19 |
20 | // ❌ Anti-Pattern: By default, we allow outgoing network calls -
21 | // If some unknown code locations will issue HTTP request - It will passthrough out
22 | });
23 |
24 | beforeEach(() => {
25 | nock('http://localhost/user/').get(`/1`).reply(200, {
26 | id: 1,
27 | name: 'John',
28 | });
29 | // ❌ Anti-Pattern: There is no default behaviour for the users and email external service, if one test forgets to define a nock than
30 | // there will be an outgoing call
31 | });
32 |
33 | afterEach(() => {
34 | // ❌ Anti-Pattern: No clean-up for the network interceptions, the next test will face the same behaviour
35 | sinon.restore();
36 | });
37 |
38 | afterAll(async () => {
39 | await stopWebServer();
40 | });
41 |
42 | describe('/api', () => {
43 | describe('POST /orders', () => {
44 | test('When order succeed, send mail to store manager', async () => {
45 | //Arrange
46 | process.env.SEND_MAILS = 'true';
47 |
48 | // ❌ Anti-Pattern: The call will succeed regardless if the input, even if no mail address will get provided
49 | // We're not really simulating the integration data
50 | const emailHTTPCall = nock('http://mailer.com')
51 | .post('/send')
52 | // ❌ Anti-Pattern: use fake timers instead of nock delay to simulate long requests
53 | .delay(1000)
54 | .reply(202);
55 | const orderToAdd = {
56 | userId: 1,
57 | productId: 2,
58 | mode: 'approved',
59 | };
60 |
61 | //Act
62 | await axiosAPIClient.post('/order', orderToAdd);
63 |
64 | //Assert
65 | expect(emailHTTPCall.isDone()).toBe(true);
66 | });
67 | });
68 | });
69 |
70 | // ❌ Anti-Pattern: We didn't test the scenario where the mailer reply with error
71 | // ❌ Anti-Pattern: We didn't test the scenario where the mailer does not reply (timeout)
72 | // ❌ Anti-Pattern: We didn't test the scenario where the mailer reply slowly (delay)
73 | // ❌ Anti-Pattern: We didn't test the scenario of occasional one-time response failure which can be mitigated with retry
74 | // ❌ Anti-Pattern: We didn't test that WE send the right payload
75 | // ❌ Anti-Pattern: We have no guarantee that we covered all the outgoing network calls
76 |
--------------------------------------------------------------------------------
/anti-patterns/anti-pattern.message-queue.test.js:
--------------------------------------------------------------------------------
1 | const MessageQueueClient = require('../example-application/libraries/message-queue-client');
2 |
3 | beforeEach(async () => {
4 | const messageQueueClient = new MessageQueueClient();
5 | await messageQueueClient.purgeQueue('user.deleted');
6 | await setTimeout(1000);
7 | await messageQueueClient.purgeQueue('user.deleted');
8 | });
9 |
10 | test('When user deleted message arrives, then all corresponding orders are deleted', async () => {
11 | // Arrange
12 | const orderToAdd = { userId: 1, productId: 2, status: 'approved' };
13 | const addedOrderId = (await axiosAPIClient.post('/order', orderToAdd)).data
14 | .id;
15 | const messageQueueClient = new MessageQueueClient();
16 | await new QueueSubscriber(messageQueueClient, 'user.deleted').start();
17 |
18 | // Act
19 | await messageQueueClient.publish('user.events', 'user.deleted', {
20 | id: addedOrderId,
21 | });
22 |
23 | // Assert
24 | let aQueryForDeletedOrder;
25 | await poller(10, async () => {
26 | aQueryForDeletedOrder = await axiosAPIClient.get(`/order/${addedOrderId}`);
27 | if (aQueryForDeletedOrder.status === 404) {
28 | return true;
29 | }
30 | return false;
31 | });
32 | expect(aQueryForDeletedOrder.status).toBe(404);
33 | });
34 |
35 | /**
36 | * @param {number} frequency
37 | * @param {Function} checkCompletion
38 | */
39 | const poller = async (frequency, checkCompletion) => {};
40 |
--------------------------------------------------------------------------------
/example-application/business-logic/order-service.js:
--------------------------------------------------------------------------------
1 | const util = require('util');
2 | const axios = require('axios').default;
3 | const mailer = require('../libraries/mailer');
4 | const axiosRetry = require('axios-retry').default;
5 | const OrderRepository = require('../data-access/order-repository');
6 | const { AppError } = require('../error-handling');
7 | const MessageQueueClient = require('../libraries/message-queue-client');
8 | const { AxiosError } = require('axios');
9 |
10 | const axiosHTTPClient = axios.create({});
11 |
12 | module.exports.addOrder = async function (newOrder) {
13 | // validation
14 | if (!newOrder.productId) {
15 | throw new AppError('invalid-order', `No product-id specified`, 400);
16 | }
17 |
18 | // verify user existence by calling external Microservice
19 | const userWhoOrdered = await getUserFromUsersService(newOrder.userId);
20 | console.log('userWhoOrdered', userWhoOrdered);
21 | if (!userWhoOrdered) {
22 | console.log('The user was not found');
23 | throw new AppError(
24 | 'user-doesnt-exist',
25 | `The user ${newOrder.userId} doesnt exist`,
26 | 404,
27 | );
28 | }
29 |
30 | // A little logic to calculate the total price
31 | if (newOrder.isPremiumUser) {
32 | newOrder.totalPrice = Math.ceil(newOrder.totalPrice * 0.9);
33 | }
34 |
35 | // save to DB (Caution: simplistic code without layers and validation)
36 | const DBResponse = await new OrderRepository().addOrder(newOrder);
37 |
38 | console.log('DBResponse', DBResponse);
39 | if (process.env.SEND_MAILS === 'true') {
40 | await mailer.send(
41 | 'New order was placed',
42 | `user ${DBResponse.userId} ordered ${DBResponse.productId}`,
43 | 'admin@app.com',
44 | );
45 | }
46 |
47 | // We should notify others that a new order was added - Let's put a message in a queue
48 | await new MessageQueueClient().publish(
49 | 'order.events',
50 | 'order.events.new',
51 | newOrder,
52 | );
53 |
54 | return DBResponse;
55 | };
56 |
57 | module.exports.deleteOrder = async function (id) {
58 | return await new OrderRepository().deleteOrder(id);
59 | };
60 |
61 | module.exports.getOrder = async function (id) {
62 | return await new OrderRepository().getOrderById(id);
63 | };
64 |
65 | async function getUserFromUsersService(userId) {
66 | try {
67 | const getUserResponse = await axiosHTTPClient.get(
68 | `http://localhost/user/${userId}`,
69 | {
70 | timeout: process.env.HTTP_TIMEOUT
71 | ? parseInt(process.env.HTTP_TIMEOUT)
72 | : 2000,
73 | validateStatus: (status) => {
74 | return status < 500;
75 | },
76 | },
77 | );
78 | return getUserResponse.data;
79 | } catch (error) {
80 | if (error?.code === 'ECONNABORTED') {
81 | throw new AppError(
82 | 'user-verification-failed',
83 | `Request to user service failed so user cant be verified`,
84 | 503,
85 | );
86 | }
87 |
88 | throw error;
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/example-application/data-access/config/config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | username: "myuser",
3 | password: "myuserpassword",
4 | database: "shop",
5 | host: "localhost",
6 | port: 54310,
7 | logging: false,
8 | dialect: "postgres",
9 | pool: {
10 | max: 10,
11 | min: 0,
12 | acquire: 30000,
13 | idle: 10000,
14 | },
15 | };
16 |
--------------------------------------------------------------------------------
/example-application/data-access/migrations/20191229152126-entire-schema.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: async (queryInterface, Sequelize) => {
3 | await queryInterface.createTable('Orders', {
4 | id: {
5 | allowNull: false,
6 | autoIncrement: true,
7 | primaryKey: true,
8 | type: Sequelize.INTEGER,
9 | },
10 | externalIdentifier: {
11 | type: Sequelize.STRING,
12 | unique: true,
13 | allowNull: true,
14 | },
15 | mode: {
16 | type: Sequelize.STRING,
17 | },
18 | contactEmail: {
19 | type: Sequelize.STRING,
20 | },
21 | userId: {
22 | type: Sequelize.INTEGER,
23 | },
24 | productId: {
25 | type: Sequelize.INTEGER,
26 | },
27 | totalPrice: {
28 | type: Sequelize.INTEGER,
29 | },
30 | isPremiumUser: {
31 | type: Sequelize.BOOLEAN,
32 | },
33 | createdAt: {
34 | allowNull: false,
35 | type: Sequelize.DATE,
36 | },
37 | updatedAt: {
38 | allowNull: false,
39 | type: Sequelize.DATE,
40 | },
41 | });
42 |
43 | await queryInterface.createTable('Countries', {
44 | id: {
45 | allowNull: false,
46 | autoIncrement: true,
47 | primaryKey: true,
48 | type: Sequelize.INTEGER,
49 | },
50 | name: {
51 | type: Sequelize.STRING,
52 | },
53 | });
54 | },
55 |
56 | down: (queryInterface, Sequelize) => queryInterface.dropTable('Orders'),
57 | };
58 |
--------------------------------------------------------------------------------
/example-application/data-access/order-repository.js:
--------------------------------------------------------------------------------
1 | const Sequelize = require('sequelize');
2 | const sequelizeConfig = require('./config/config');
3 |
4 | let repository;
5 | let orderModel;
6 |
7 | module.exports = class OrderRepository {
8 | constructor() {
9 | if (!repository) {
10 | repository = new Sequelize(
11 | 'shop',
12 | 'myuser',
13 | 'myuserpassword',
14 | sequelizeConfig,
15 | );
16 | orderModel = repository.define('Order', {
17 | id: {
18 | type: Sequelize.INTEGER,
19 | primaryKey: true,
20 | autoIncrement: true,
21 | },
22 | externalIdentifier: {
23 | type: Sequelize.STRING,
24 | unique: true,
25 | allowNull: true,
26 | },
27 | mode: {
28 | type: Sequelize.STRING,
29 | },
30 | userId: {
31 | type: Sequelize.INTEGER,
32 | },
33 | contactEmail: {
34 | type: Sequelize.STRING,
35 | },
36 | productId: {
37 | type: Sequelize.INTEGER,
38 | },
39 | totalPrice: {
40 | type: Sequelize.INTEGER,
41 | },
42 | isPremiumUser: {
43 | type: Sequelize.BOOLEAN,
44 | },
45 | });
46 | }
47 | }
48 |
49 | async getOrderById(id) {
50 | return await orderModel.findOne({ where: { id } });
51 | }
52 |
53 | async addOrder(orderDetails) {
54 | const addingResponse = await orderModel.create(orderDetails);
55 |
56 | return addingResponse.dataValues;
57 | }
58 |
59 | async deleteOrder(orderToDelete) {
60 | await orderModel.destroy({ where: { id: orderToDelete } });
61 | return;
62 | }
63 |
64 | async cleanup() {
65 | await orderModel.truncate();
66 | }
67 | };
68 |
--------------------------------------------------------------------------------
/example-application/data-access/seeders/20191229151823-countries.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: async (queryInterface, Sequelize) => {
3 | // ✅ Best Practice: Seed only metadata and not test record, read "Dealing with data" section for further information
4 | await queryInterface.bulkInsert(
5 | 'Countries',
6 | [
7 | {
8 | name: 'Italy',
9 | name: 'USA',
10 | name: 'India',
11 | },
12 | ],
13 | {},
14 | );
15 |
16 | // ❌ Anti-Pattern: Seed test records, read "Dealing with data" section for further information
17 | const now = new Date();
18 | await queryInterface.bulkInsert(
19 | 'Orders',
20 | [
21 | {
22 | userId: 1,
23 | productId: 5,
24 | createdAt: now,
25 | updatedAt: now,
26 | },
27 | ],
28 | {},
29 | );
30 | },
31 | down: (queryInterface, Sequelize) => {
32 | /*
33 | Add reverting commands here.
34 | Return a promise to correctly handle asynchronicity.
35 |
36 | Example:
37 | return queryInterface.bulkDelete('People', null, {});
38 | */
39 | },
40 | };
41 |
--------------------------------------------------------------------------------
/example-application/entry-points/api.ts:
--------------------------------------------------------------------------------
1 | //@ts-nocheck
2 | import bodyParser from 'body-parser';
3 | import express from 'express';
4 | import { Server } from 'http';
5 | import { AddressInfo } from 'net';
6 | import util from 'util';
7 | import orderService from '../business-logic/order-service';
8 | import { errorHandler } from '../error-handling';
9 | import { authenticationMiddleware } from '../libraries/authentication-middleware';
10 |
11 | let connection: Server | null = null;
12 |
13 | export const startWebServer = (): Promise => {
14 | return new Promise((resolve, reject) => {
15 | const expressApp = express();
16 | expressApp.use(
17 | bodyParser.urlencoded({
18 | extended: true,
19 | }),
20 | );
21 | expressApp.use(bodyParser.json());
22 | expressApp.use(authenticationMiddleware);
23 | defineRoutes(expressApp);
24 | // ️️️✅ Best Practice 8.13: Specify no port for testing, only in production
25 | // 📖 Read more at: bestpracticesnodejs.com/bp/8.13
26 | const webServerPort = process.env.PORT ? process.env.PORT : null;
27 | connection = expressApp.listen(webServerPort, () => {
28 | resolve(connection!.address() as AddressInfo);
29 | });
30 | });
31 | };
32 |
33 | export const stopWebServer = async () => {
34 | return new Promise((resolve, reject) => {
35 | if (connection) {
36 | connection.close(() => {
37 | return resolve();
38 | });
39 | }
40 | return resolve();
41 | });
42 | };
43 |
44 | const defineRoutes = (expressApp: express.Application) => {
45 | const router = express.Router();
46 |
47 | // add new order
48 | router.post(
49 | '/',
50 | async (
51 | req: express.Request,
52 | res: express.Response,
53 | next: express.NextFunction,
54 | ) => {
55 | try {
56 | console.log(
57 | `Order API was called to add new Order ${util.inspect(req.body)}`,
58 | );
59 | const addOrderResponse = await orderService.addOrder(req.body);
60 | return res.json(addOrderResponse);
61 | } catch (error) {
62 | next(error);
63 | }
64 | },
65 | );
66 |
67 | // get existing order by id
68 | router.get('/:id', async (req, res, next) => {
69 | console.log(`Order API was called to get order by id ${req.params.id}`);
70 | const response = await orderService.getOrder(req.params.id);
71 |
72 | if (!response) {
73 | res.status(404).end();
74 | return;
75 | }
76 |
77 | res.json(response);
78 | });
79 |
80 | // delete order by id
81 | router.delete('/:id', async (req, res, next) => {
82 | console.log(`Order API was called to delete order ${req.params.id}`);
83 | await orderService.deleteOrder(req.params.id);
84 | res.status(204).end();
85 | });
86 |
87 | expressApp.use('/order', router);
88 |
89 | expressApp.use(
90 | //An error can be any unknown thing, this is why we're being careful here
91 | async (
92 | error: unknown,
93 | _req: express.Request,
94 | res: express.Response,
95 | _next: express.NextFunction,
96 | ) => {
97 | if (!error || typeof error !== 'object') {
98 | await errorHandler.handleError(error);
99 | return res.status(500).end();
100 | }
101 | const status = 'status' in error ? error.status : 500;
102 | const richError = error as Record;
103 | if (!('isTrusted' in error)) {
104 | //Error during a specific request is usually not catastrophic and should not lead to process exit
105 | richError.isTrusted = true;
106 | }
107 | await errorHandler.handleError(richError);
108 | res.status(status as number).end();
109 | },
110 | );
111 | };
112 |
113 | process.on('uncaughtException', (error) => {
114 | errorHandler.handleError(error);
115 | });
116 |
117 | process.on('unhandledRejection', (reason) => {
118 | errorHandler.handleError(reason);
119 | });
120 |
--------------------------------------------------------------------------------
/example-application/entry-points/message-queue-consumer.js:
--------------------------------------------------------------------------------
1 | const MessageQueueClient = require('../libraries/message-queue-client');
2 | const { errorHandler, AppError } = require('../error-handling');
3 | const orderService = require('../business-logic/order-service');
4 |
5 | // This is message queue entry point. Like API routes but for message queues.
6 | class QueueConsumer {
7 | constructor(messageQueueClient) {
8 | this.messageQueueClient = messageQueueClient;
9 | }
10 |
11 | async start() {
12 | await this.consumeUserDeletionQueue();
13 | }
14 |
15 | async consumeUserDeletionQueue() {
16 | await this.messageQueueClient.consume('user.deleted', async (message) => {
17 | // ️️️Validate message
18 | const newMessageAsObject = JSON.parse(message);
19 | if (!newMessageAsObject.id) {
20 | throw new AppError('invalid-message', 'Unknown message schema');
21 | }
22 |
23 | await orderService.deleteOrder(newMessageAsObject.id);
24 | });
25 | }
26 | }
27 |
28 | process.on('uncaughtException', (error) => {
29 | errorHandler.handleError(error);
30 | });
31 |
32 | process.on('unhandledRejection', (reason) => {
33 | errorHandler.handleError(reason);
34 | });
35 |
36 | module.exports = { QueueConsumer };
37 |
--------------------------------------------------------------------------------
/example-application/error-handling.js:
--------------------------------------------------------------------------------
1 | const mailer = require('./libraries/mailer');
2 | const logger = require('./libraries/logger');
3 |
4 | // This file simulates real-world error handler that makes this component observable
5 | const errorHandler = {
6 | handleError: async (errorToHandle) => {
7 | try {
8 | logger.error(errorToHandle);
9 | metricsExporter.fireMetric('error', {
10 | errorName: errorToHandle.name || 'generic-error',
11 | });
12 | // This is used to simulate sending email to admin when an error occurs
13 | // In real world - The right flow is sending alerts from the monitoring system
14 | await mailer.send(
15 | 'Error occured',
16 | `Error is ${errorToHandle}`,
17 | 'admin@our-domain.io',
18 | );
19 |
20 | // A common best practice is to crash when an unknown error (non-trusted) is being thrown
21 | decideWhetherToCrash(errorToHandle);
22 | } catch (e) {
23 | // Continue the code flow if failed to handle the error
24 | console.log(`handleError threw an error ${e}`);
25 | }
26 | },
27 | };
28 |
29 | const decideWhetherToCrash = (error) => {
30 | if (!error.isTrusted) {
31 | process.exit();
32 | }
33 | };
34 |
35 | class AppError extends Error {
36 | constructor(name, message, HTTPStatus, isTrusted) {
37 | super(message);
38 | this.name = name;
39 | this.status = HTTPStatus || 500;
40 | this.isTrusted = isTrusted === undefined ? true : false;
41 | }
42 | }
43 |
44 | // This simulates a typical monitoring solution that allow firing custom metrics when
45 | // like Prometheus, DataDog, CloudWatch, etc
46 | const metricsExporter = {
47 | fireMetric: async (name, labels) => {
48 | console.log('In real production code I will really fire metrics');
49 | },
50 | };
51 |
52 | module.exports.errorHandler = errorHandler;
53 | module.exports.metricsExporter = metricsExporter;
54 | module.exports.AppError = AppError;
55 |
--------------------------------------------------------------------------------
/example-application/libraries/authentication-middleware.js:
--------------------------------------------------------------------------------
1 | const jwt = require('jsonwebtoken');
2 |
3 | const authenticationMiddleware = (req, res, next) => {
4 | //This is where some authentication & authorization logic comes, for example a place to
5 | //validate JWT integrity
6 | let succeeded = false;
7 | const authenticationHeader = req.headers['authorization'];
8 | try {
9 | const decoded = jwt.verify(authenticationHeader, 'some secret');
10 | req.user = decoded.data;
11 | succeeded = true;
12 | } catch (e) {
13 | console.log(e);
14 | }
15 |
16 | if (succeeded) {
17 | return next();
18 | } else {
19 | res.status(401).end();
20 | return;
21 | }
22 | };
23 |
24 | module.exports.authenticationMiddleware = authenticationMiddleware;
25 |
--------------------------------------------------------------------------------
/example-application/libraries/config.js:
--------------------------------------------------------------------------------
1 | //This simulates production configuration provider where keys are put in JSON and/or
2 | //in env vars
3 |
4 | module.exports = {
5 | JWTSecret: 'secret'
6 | }
7 | process.env.JWT_SECRET = 'secret';
--------------------------------------------------------------------------------
/example-application/libraries/fake-message-queue-provider.js:
--------------------------------------------------------------------------------
1 | const { EventEmitter } = require('events');
2 |
3 | // This class is the heart of the MQ testing - It replaces the MQ provider client library
4 | // and implement the same signature, but each method does nothing but emit an event which the test
5 | // can verify that indeed happened
6 | class FakeMessageQueueProvider extends EventEmitter {
7 | async consume(queueName, messageHandler) {
8 | this.messageHandler = messageHandler;
9 | Promise.resolve();
10 | }
11 |
12 | async publish(exchangeName, routingKey, newMessage) {
13 | if (this.messageHandler) {
14 | this.messageHandler({ content: newMessage });
15 | this.emit('publish', { exchangeName, routingKey, newMessage });
16 | }
17 | Promise.resolve();
18 | }
19 |
20 | async nack() {
21 | const eventDescription = { event: 'message-rejected' };
22 | this.emit('message-rejected', eventDescription); // Multiple events allows the test to filter for the relevant event
23 | this.emit('message-handled', eventDescription);
24 | }
25 |
26 | async ack() {
27 | const eventDescription = { event: 'message-acknowledged' };
28 | this.emit('message-acknowledged', eventDescription);
29 | this.emit('message-handled', eventDescription);
30 | }
31 |
32 | async assertQueue() {}
33 |
34 | async createChannel() {
35 | return this;
36 | }
37 |
38 | async connect() {
39 | return this;
40 | }
41 | }
42 |
43 | module.exports = { FakeMessageQueueProvider };
44 |
--------------------------------------------------------------------------------
/example-application/libraries/logger.js:
--------------------------------------------------------------------------------
1 | // This is not a real logger as its just writes to the console
2 | // but it has the structure of a real logger
3 |
4 | module.exports.info = (message) => {
5 | console.log(message);
6 | };
7 |
8 | module.exports.error = (message) => {
9 | console.error(message);
10 | };
11 |
--------------------------------------------------------------------------------
/example-application/libraries/mailer.js:
--------------------------------------------------------------------------------
1 | const axios = require('axios');
2 |
3 | module.exports.send = async (subject, body, recipientAddress) => {
4 | await axios.post(
5 | `http://mailer.com/send`,
6 | {
7 | subject,
8 | body,
9 | recipientAddress,
10 | },
11 | { timeout: 3000 } //It's a bit slow sometime, we are willing to wait for it
12 | );
13 | };
14 |
--------------------------------------------------------------------------------
/example-application/libraries/message-queue-client.js:
--------------------------------------------------------------------------------
1 | const { EventEmitter } = require('events');
2 | const amqplib = require('amqplib');
3 | const { FakeMessageQueueProvider } = require('./fake-message-queue-provider');
4 | const { errorHandler } = require('../error-handling');
5 |
6 | // This is a simplistic client for a popular message queue product - RabbitMQ
7 | // It's generic in order to be used by any service in the organization
8 | class MessageQueueClient extends EventEmitter {
9 | recordingStarted = false;
10 |
11 | constructor(customMessageQueueProvider) {
12 | super();
13 | this.isReady = false;
14 | this.requeue = true; // Tells whether to return failed messages to the queue
15 |
16 | // To facilitate testing, the client allows working with a fake MQ provider
17 | // It can get one in the constructor here or even change by environment variables
18 | // For the sake of simplicity HERE, since it's demo code - The default is a fake MQ
19 | if (customMessageQueueProvider) {
20 | this.messageQueueProvider = customMessageQueueProvider;
21 | } else if (process.env.USE_FAKE_MQ === 'false') {
22 | this.messageQueueProvider = amqplib;
23 | } else {
24 | this.messageQueueProvider = new FakeMessageQueueProvider();
25 | }
26 |
27 | this.setMaxListeners(50);
28 | this.countEvents();
29 | }
30 |
31 | async connect() {
32 | const connectionProperties = {
33 | protocol: 'amqp',
34 | hostname: 'localhost',
35 | port: 5672,
36 | username: 'guest',
37 | password: 'guest', // This is a demo app, no security considerations. This is the password for the local dev server
38 | locale: 'en_US',
39 | frameMax: 0,
40 | heartbeat: 0,
41 | vhost: '/',
42 | };
43 | this.connection =
44 | await this.messageQueueProvider.connect(connectionProperties);
45 | this.channel = await this.connection.createChannel();
46 | }
47 |
48 | async close() {
49 | if (this.connection) {
50 | await this.connection.close();
51 | }
52 | }
53 |
54 | async publish(exchangeName, routingKey, message, messageId) {
55 | if (!this.channel) {
56 | await this.connect();
57 | }
58 |
59 | const sendResponse = await this.channel.publish(
60 | exchangeName,
61 | routingKey,
62 | Buffer.from(JSON.stringify(message)),
63 | { messageId },
64 | );
65 | this.emit('publish', { exchangeName, routingKey, message });
66 |
67 | return sendResponse;
68 | }
69 |
70 | async deleteQueue(queueName) {
71 | if (!this.channel) {
72 | await this.connect();
73 | }
74 | await this.channel.deleteQueue(queueName);
75 | }
76 |
77 | async assertQueue(queueName, options = {}) {
78 | if (!this.channel) {
79 | await this.connect();
80 | }
81 | await this.channel.assertQueue(queueName, options);
82 | }
83 |
84 | async assertExchange(name, type) {
85 | if (!this.channel) {
86 | await this.connect();
87 | }
88 | await this.channel.assertExchange(name, type, { durable: false });
89 | }
90 |
91 | async bindQueue(queueToBind, exchangeToBindTo, bindingPattern) {
92 | if (!this.channel) {
93 | await this.connect();
94 | }
95 | await this.channel.bindQueue(queueToBind, exchangeToBindTo, bindingPattern);
96 | }
97 |
98 | async consume(queueName, onMessageCallback) {
99 | if (!this.channel) {
100 | await this.connect();
101 | }
102 |
103 | await this.channel.consume(queueName, async (theNewMessage) => {
104 | this.emit('consume', { queueName, message: theNewMessage });
105 |
106 | //Not awaiting because some MQ client implementation get back to fetch messages again only after handling a message
107 | onMessageCallback(theNewMessage.content.toString())
108 | .then(() => {
109 | this.emit('ack', { queueName, message: theNewMessage });
110 | this.channel.ack(theNewMessage);
111 | })
112 | .catch(async (error) => {
113 | this.channel.nack(theNewMessage, false, this.requeue);
114 | this.emit('nack', { queueName, message: theNewMessage });
115 | error.isTrusted = true; //Since it's related to a single message, there is no reason to let the process crash
116 | errorHandler.handleError(error);
117 | });
118 | });
119 | }
120 |
121 | setRequeue(newValue) {
122 | this.requeue = newValue;
123 | }
124 |
125 | // This function stores all the MQ events in a local data structure so later
126 | // one query this
127 | countEvents() {
128 | if (this.recordingStarted === true) {
129 | return; // Already initialized and set up
130 | }
131 |
132 | const eventsName = ['ack', 'nack', 'publish', 'sendToQueue', 'consume'];
133 |
134 | this.records = {};
135 |
136 | eventsName.forEach((eventToListenTo) => {
137 | this.records[eventToListenTo] = {
138 | count: 0,
139 | events: [],
140 | lastEventData: null,
141 | };
142 |
143 | this.on(eventToListenTo, (eventData) => {
144 | // Not needed anymore when having the `events` array
145 | this.records[eventToListenTo].count++;
146 | this.records[eventToListenTo].lastEventData = eventData;
147 |
148 | this.records[eventToListenTo].events.push(eventData);
149 |
150 | this.emit('message-queue-event', {
151 | name: eventToListenTo,
152 | eventsRecorder: this.records,
153 | });
154 | });
155 | });
156 | }
157 |
158 | /**
159 | * Helper methods for testing - Resolves/fires when some event happens
160 | * @param eventName
161 | * @param {number} howMuch how much
162 | * @param {object?} query
163 | * @param {string?} query.exchangeName
164 | * @param {string?} query.queueName
165 | * @returns {Promise}
166 | */
167 | async waitFor(eventName, howMuch, query = {}) {
168 | let options = query || {};
169 | options.howMuch = howMuch;
170 |
171 | return new Promise((resolve) => {
172 | // The first resolve is for cases where the caller has approached AFTER the event has already happen
173 | if (this.resolveIfEventExceededThreshold(eventName, options, resolve)) {
174 | return;
175 | }
176 |
177 | const handler = () => {
178 | if (this.resolveIfEventExceededThreshold(eventName, options, resolve)) {
179 | this.off('message-queue-event', handler);
180 | }
181 | };
182 |
183 | this.on('message-queue-event', handler);
184 | });
185 | }
186 |
187 | /**
188 | * @param eventName
189 | * @param {object} options
190 | * @param {number} options.howMuch
191 | * @param {string?} options.exchangeName
192 | * @param {string?} options.queueName
193 | * @param resolve
194 | * @returns {boolean} Return true if resolve fn called, otherwise false
195 | */
196 | resolveIfEventExceededThreshold(eventName, options, resolve) {
197 | const eventRecords = this.records[eventName];
198 |
199 | // Can be optimized by:
200 | // - Only run it if the options have such filter
201 | // - Check only what asked
202 | const filteredEvents = eventRecords.events.filter((eventData) => {
203 | return (
204 | // If the queue name is the same (in case it was provided)
205 | (!options.queueName || eventData.queueName === options.queueName) &&
206 | // If the exchange name is the same (in case the it was provided)
207 | (!options.exchangeName ||
208 | eventData.exchangeName === options.exchangeName)
209 | );
210 | });
211 |
212 | if (filteredEvents.length >= options.howMuch) {
213 | resolve({
214 | name: eventName,
215 | lastEventData: filteredEvents[filteredEvents.length - 1],
216 | count: filteredEvents.length,
217 | });
218 |
219 | return true;
220 | }
221 |
222 | return false;
223 | }
224 | }
225 |
226 | module.exports = MessageQueueClient;
227 |
--------------------------------------------------------------------------------
/example-application/libraries/test-helpers.ts:
--------------------------------------------------------------------------------
1 | import amqplib from 'amqplib';
2 | import { QueueConsumer } from '../entry-points/message-queue-consumer';
3 | import MessageQueueClient from './message-queue-client';
4 | import { FakeMessageQueueProvider } from './fake-message-queue-provider';
5 |
6 | export async function startMQConsumer(
7 | useFake: boolean,
8 | customMessageQueueClient: MessageQueueClient | undefined = undefined,
9 | ): Promise> {
10 | if (customMessageQueueClient) {
11 | await new QueueConsumer(customMessageQueueClient).start();
12 | return customMessageQueueClient;
13 | }
14 | const messageQueueProvider =
15 | useFake === true ? new FakeMessageQueueProvider() : amqplib;
16 | const newMessageQueueClient = new MessageQueueClient(messageQueueProvider);
17 | await new QueueConsumer(newMessageQueueClient).start();
18 | return newMessageQueueClient;
19 | }
20 |
21 | // This returns a numerical value that is 99.99% unique in a multi-process test runner where the state/DB
22 | // is clean-up at least once a day
23 | export function getShortUnique(): string {
24 | const now = new Date();
25 | // We add this weak random just to cover the case where two test started at the very same millisecond
26 | const aBitOfMoreSalt = Math.ceil(Math.random() * 99);
27 | return `${process.pid}${aBitOfMoreSalt}${now.getMilliseconds()}`;
28 | }
29 |
--------------------------------------------------------------------------------
/example-application/libraries/types.ts:
--------------------------------------------------------------------------------
1 | export type User = {
2 | id: string;
3 | name: string;
4 | };
5 |
6 | export enum Roles {
7 | user,
8 | admin,
9 | }
10 |
11 | export type Address = {
12 | street: string;
13 | city: string;
14 | state: string;
15 | postalCode: string;
16 | country: string;
17 | };
18 |
19 | export type OrderItem = {
20 | productId: number;
21 | productName: string;
22 | productPrice: number;
23 | quantity: number;
24 | };
25 |
26 | export type Order = {
27 | userId: number;
28 |
29 | isPremiumUser: boolean;
30 | productId: number;
31 | mode: 'approved' | 'pending' | 'cancelled';
32 | orderId: number;
33 | orderDate: string; // ISO 8601 format
34 | status: 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
35 | totalPrice: number;
36 | externalIdentifier: string;
37 | currency: string;
38 | quantity: number;
39 | paymentMethod:
40 | | 'credit_card'
41 | | 'paypal'
42 | | 'bank_transfer'
43 | | 'cash_on_delivery';
44 | transactionId: string;
45 | deliveryAddress: Address;
46 | billingAddress: Address;
47 | shippingMethod: 'standard' | 'express' | 'overnight';
48 | estimatedDeliveryDate: string; // ISO 8601 format
49 | trackingNumber?: string; // Optional, only available after shipping
50 | items: OrderItem[];
51 | discountCode?: string; // Optional
52 | discountAmount: number;
53 | taxAmount: number;
54 | shippingCost: number;
55 | giftWrap: boolean;
56 | customerNote?: string; // Optional
57 | isGift: boolean;
58 | refundStatus: 'none' | 'requested' | 'approved' | 'rejected';
59 | loyaltyPointsUsed: number;
60 | loyaltyPointsEarned: number;
61 | contactEmail: string;
62 | contactPhone: string;
63 | };
64 |
--------------------------------------------------------------------------------
/example-application/openapi.json:
--------------------------------------------------------------------------------
1 | {
2 | "openapi": "3.0.3",
3 | "info": {
4 | "title": "Shop",
5 | "description": "This is the API docs of the shop API.",
6 | "termsOfService": "http://swagger.io/terms/",
7 | "license": {
8 | "name": "Apache 2.0",
9 | "url": "http://www.apache.org/licenses/LICENSE-2.0.html"
10 | },
11 | "version": "1.0.0"
12 | },
13 | "externalDocs": {
14 | "description": "Find out more about Swagger",
15 | "url": "http://swagger.io"
16 | },
17 | "paths": {
18 | "/order/{orderId}": {
19 | "get": {
20 | "summary": "Find an Order by ID",
21 | "description": "Returns a single order",
22 | "operationId": "getOrderById",
23 | "parameters": [
24 | {
25 | "name": "orderId",
26 | "in": "path",
27 | "description": "ID of order to return",
28 | "required": true,
29 | "schema": {
30 | "type": "integer"
31 | }
32 | }
33 | ],
34 | "responses": {
35 | "200": {
36 | "description": "successful",
37 | "content": {
38 | "application/json": {
39 | "schema": {
40 | "$ref": "#/components/schemas/Order"
41 | }
42 | }
43 | }
44 | },
45 | "400": {
46 | "description": "Invalid ID",
47 | "content": {}
48 | },
49 | "404": {
50 | "description": "Order not found",
51 | "content": {}
52 | }
53 | }
54 | }
55 | },
56 | "/order": {
57 | "post": {
58 | "summary": "Add a new Order",
59 | "description": "Returns a single order",
60 | "operationId": "addOrder",
61 | "requestBody": {
62 | "description": "New Order To Add",
63 | "content": {
64 | "application/json": {
65 | "schema": {
66 | "$ref": "#/components/schemas/Order"
67 | }
68 | }
69 | }
70 | },
71 | "responses": {
72 | "200": {
73 | "description": "Successful",
74 | "content": {
75 | "application/json": {
76 | "schema": {
77 | "$ref": "#/components/schemas/Order"
78 | }
79 | }
80 | }
81 | },
82 | "400": {
83 | "description": "User Not Found",
84 | "content": {}
85 | },
86 | "404": {
87 | "description": "User Not Found",
88 | "content": {}
89 | },
90 | "500": {
91 | "description": "Unknown error occured",
92 | "content": {}
93 | }
94 | }
95 | }
96 | }
97 | },
98 | "components": {
99 | "schemas": {
100 | "Order": {
101 | "type": "object",
102 | "required": ["productId", "userId", "mode"],
103 | "properties": {
104 | "id": {
105 | "type": "integer",
106 | "format": "int64"
107 | },
108 | "productId": {
109 | "type": "integer",
110 | "format": "int64"
111 | },
112 | "userId": {
113 | "type": "integer",
114 | "format": "int64"
115 | },
116 | "mode": {
117 | "type": "string",
118 | "format": "string"
119 | }
120 | }
121 | }
122 | }
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/example-application/readme.md:
--------------------------------------------------------------------------------
1 | # Running the example app
2 |
3 | ## Prerequisites
4 |
5 | 1. Docker
6 | 2. Node.js
7 | 3. nvm
8 |
9 | ## Installation
10 |
11 | 1. Fork the repo
12 | 2. Run `npm i`
13 | 3. Run `nvm use`
14 | 4. Run the tests `Run t`
15 |
--------------------------------------------------------------------------------
/example-application/start.js:
--------------------------------------------------------------------------------
1 | const { initializeWebServer } = require('./entry-points/api');
2 | const { QueueConsumer } = require('./entry-points/message-queue-consumer');
3 |
4 | async function start() {
5 | await initializeWebServer();
6 | await new QueueConsumer().start();
7 | }
8 |
9 | start()
10 | .then(() => {
11 | console.log('The app has started successfully');
12 | })
13 | .catch((error) => {
14 | console.log('App occured during startup', error);
15 | });
16 |
--------------------------------------------------------------------------------
/example-application/test/createOrder.callingOtherServices.test.ts:
--------------------------------------------------------------------------------
1 | import nock from 'nock';
2 | import sinon from 'sinon';
3 | import OrderRepository from '../data-access/order-repository';
4 | import { testSetup } from './setup/test-file-setup';
5 |
6 | beforeAll(async () => {
7 | await testSetup.start({
8 | startAPI: true,
9 | disableNetConnect: true,
10 | includeTokenInHttpClient: true,
11 | mockGetUserCalls: true,
12 | mockMailerCalls: true,
13 | });
14 | });
15 |
16 | beforeEach(() => {
17 | testSetup.resetBeforeEach();
18 | });
19 |
20 | afterAll(async () => {
21 | // ️️️✅ Best Practice: Clean-up resources after each run
22 | testSetup.tearDownTestFile();
23 | });
24 |
25 | // ️️️✅ Best Practice: Structure tests
26 | describe('/api', () => {
27 | describe('POST /orders', () => {
28 | test('When order succeed, then send mail to store manager', async () => {
29 | //Arrange
30 | process.env.SEND_MAILS = 'true';
31 |
32 | // ️️️✅ Best Practice: Intercept requests for 3rd party services to eliminate undesired side effects like emails or SMS
33 | // ️️️✅ Best Practice: Save the body when you need to make sure you call the external service as expected
34 | testSetup.removeMailNock();
35 | let emailPayload;
36 | nock('http://mailer.com')
37 | .post('/send', (payload: undefined) => ((emailPayload = payload), true))
38 | .reply(202);
39 |
40 | const orderToAdd = {
41 | userId: 1,
42 | productId: 2,
43 | mode: 'approved',
44 | };
45 |
46 | //Act
47 | await testSetup.getHTTPClient().post('/order', orderToAdd);
48 |
49 | //Assert
50 | // ️️️✅ Best Practice: Assert that the app called the mailer service with the right payload
51 | expect(emailPayload).toMatchObject({
52 | subject: expect.any(String),
53 | body: expect.any(String),
54 | recipientAddress: expect.stringMatching(
55 | /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/,
56 | ),
57 | });
58 | });
59 |
60 | test('When the user does not exist, return http 404', async () => {
61 | //Arrange
62 | const orderToAdd = {
63 | userId: 7,
64 | productId: 2,
65 | mode: 'draft',
66 | };
67 |
68 | // ️️️✅ Best Practice: Simulate non-happy external services responses like 404, 422 or 500
69 | // ✅ Best Practice: Override the default response with a custom scenario by triggering a unique path
70 | nock('http://localhost/user/').get(`/7`).reply(404, undefined);
71 |
72 | //Act
73 | const orderAddResult = await testSetup
74 | .getHTTPClient()
75 | .post('/order', orderToAdd);
76 |
77 | //Assert
78 | expect(orderAddResult.status).toBe(404);
79 | });
80 |
81 | test('When order failed, send mail to admin', async () => {
82 | //Arrange
83 | process.env.SEND_MAILS = 'true';
84 | sinon
85 | .stub(OrderRepository.prototype, 'addOrder')
86 | .throws(new Error('Unknown error'));
87 |
88 | testSetup.removeMailNock();
89 | let emailPayload;
90 | nock('http://mailer.com')
91 | .post('/send', (payload) => ((emailPayload = payload), true))
92 | .reply(202);
93 | const orderToAdd = {
94 | userId: 1,
95 | productId: 2,
96 | mode: 'approved',
97 | };
98 |
99 | //Act
100 | await testSetup.getHTTPClient().post('/order', orderToAdd);
101 |
102 | //Assert
103 | // ️️️✅ Best Practice: Assert that the app called the mailer service appropriately with the right input
104 | expect(emailPayload).toMatchObject({
105 | subject: expect.any(String),
106 | body: expect.any(String),
107 | recipientAddress: expect.stringMatching(
108 | /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/,
109 | ),
110 | });
111 | });
112 | });
113 |
114 | test("When users service doesn't reply and times out, then return 503", async () => {
115 | //Arrange
116 | // ✅ Best Practice: Let nock fail fast a timeout scenario (or use "fake timers" to simulate long requests withou actually slowing down the tests)
117 | process.env.HTTP_TIMEOUT = '2000';
118 | testSetup.removeUserNock();
119 | nock('http://localhost')
120 | .get('/user/1')
121 | .delay(3000)
122 | .reply(200, { id: 1, name: 'John' });
123 | const orderToAdd = {
124 | userId: 1,
125 | productId: 2,
126 | mode: 'approved',
127 | };
128 |
129 | //Act
130 | const response = await testSetup.getHTTPClient().post('/order', orderToAdd);
131 |
132 | // //Assert
133 | expect(response.status).toBe(503);
134 | });
135 |
136 | //TODO: Fix a bug here
137 | test.skip('When users service replies with 503 once and retry mechanism is applied, then an order is added successfully', async () => {
138 | //Arrange
139 | testSetup.removeUserNock();
140 | nock('http://localhost/user/')
141 | .get('/1')
142 | .times(1)
143 | .reply(503, undefined, { 'Retry-After': '100' });
144 | nock('http://localhost/user/').get('/1').reply(200, {
145 | id: 1,
146 | name: 'John',
147 | });
148 | const orderToAdd = {
149 | userId: 1,
150 | productId: 2,
151 | mode: 'approved',
152 | };
153 |
154 | //Act
155 | const response = await testSetup.getHTTPClient().post('/order', orderToAdd);
156 |
157 | //Assert
158 | expect(response.status).toBe(200);
159 | });
160 | });
161 |
--------------------------------------------------------------------------------
/example-application/test/createOrder.messageQueueCheck.test.ts:
--------------------------------------------------------------------------------
1 | import sinon from 'sinon';
2 | import * as testHelpers from '../libraries/test-helpers';
3 |
4 | import { QueueConsumer } from '../entry-points/message-queue-consumer';
5 | import { FakeMessageQueueProvider } from '../libraries/fake-message-queue-provider';
6 | import MessageQueueClient from '../libraries/message-queue-client';
7 | import { testSetup } from './setup/test-file-setup';
8 |
9 | beforeAll(async () => {
10 | await testSetup.start({
11 | startAPI: true,
12 | disableNetConnect: true,
13 | includeTokenInHttpClient: true,
14 | mockGetUserCalls: true,
15 | mockMailerCalls: true,
16 | });
17 | process.env.USE_FAKE_MQ = 'true';
18 | });
19 |
20 | beforeEach(() => {
21 | testSetup.resetBeforeEach();
22 | });
23 |
24 | afterAll(async () => {
25 | // ️️️✅ Best Practice: Clean-up resources after each run
26 | testSetup.tearDownTestFile();
27 | });
28 |
29 | // ️️️✅ Best Practice: Test a flow that starts via a queue message and ends with removing/confirming the message
30 | test('Whenever a user deletion message arrive, then his orders are deleted', async () => {
31 | // Arrange
32 | const orderToAdd = {
33 | userId: 1,
34 | productId: 2,
35 | mode: 'approved',
36 | };
37 | const addedOrderId = (
38 | await testSetup.getHTTPClient().post('/order', orderToAdd)
39 | ).data.id;
40 | const messageQueueClient = await testHelpers.startMQConsumer(true);
41 |
42 | // Act
43 | await messageQueueClient.publish('user.events', 'user.deleted', {
44 | id: addedOrderId,
45 | });
46 |
47 | // Assert
48 | await messageQueueClient.waitFor('ack', 1);
49 | const aQueryForDeletedOrder = await testSetup
50 | .getHTTPClient()
51 | .get(`/order/${addedOrderId}`);
52 | expect(aQueryForDeletedOrder.status).toBe(404);
53 | });
54 |
55 | test('When a poisoned message arrives, then it is being rejected back', async () => {
56 | // Arrange
57 | const messageWithInvalidSchema = { nonExistingProperty: 'invalid❌' };
58 | const messageQueueClient = await testHelpers.startMQConsumer(true);
59 |
60 | // Act
61 | await messageQueueClient.publish(
62 | 'user.events',
63 | 'user.deleted',
64 | messageWithInvalidSchema,
65 | );
66 |
67 | // Assert
68 | await messageQueueClient.waitFor('nack', 1);
69 | });
70 |
71 | test('When user deleted message arrives, then all corresponding orders are deleted', async () => {
72 | // Arrange
73 | const orderToAdd = { userId: 1, productId: 2, status: 'approved' };
74 | const addedOrderId = (
75 | await testSetup.getHTTPClient().post('/order', orderToAdd)
76 | ).data.id;
77 | const messageQueueClient = new MessageQueueClient(
78 | new FakeMessageQueueProvider(),
79 | );
80 | await new QueueConsumer(messageQueueClient).start();
81 |
82 | // Act
83 | await messageQueueClient.publish('user.events', 'user.deleted', {
84 | id: addedOrderId,
85 | });
86 |
87 | // Assert
88 | await messageQueueClient.waitFor('ack', 1);
89 | const aQueryForDeletedOrder = await testSetup
90 | .getHTTPClient()
91 | .get(`/order/${addedOrderId}`);
92 | expect(aQueryForDeletedOrder.status).toBe(404);
93 | });
94 |
95 | // ️️️✅ Best Practice: Verify that messages are put in queue whenever the requirements state so
96 | test('When a valid order is added, then a message is emitted to the new-order queue', async () => {
97 | //Arrange
98 | const orderToAdd = {
99 | userId: 1,
100 | productId: 2,
101 | mode: 'approved',
102 | };
103 | const spyOnSendMessage = sinon.spy(MessageQueueClient.prototype, 'publish');
104 |
105 | //Act
106 | await testSetup.getHTTPClient().post('/order', orderToAdd);
107 |
108 | // Assert
109 | expect(spyOnSendMessage.lastCall.args[0]).toBe('order.events');
110 | expect(spyOnSendMessage.lastCall.args[1]).toBe('order.events.new');
111 | });
112 |
113 | test.todo('When an error occurs, then the message is not acknowledged');
114 | test.todo(
115 | 'When a new valid user-deletion message is processes, then the message is acknowledged',
116 | );
117 | test.todo(
118 | 'When two identical create-order messages arrives, then the app is idempotent and only one is created',
119 | );
120 | test.todo(
121 | 'When occasional failure occur during message processing , then the error is handled appropriately',
122 | );
123 | test.todo(
124 | 'When multiple user deletion message arrives, then all the user orders are deleted',
125 | );
126 | test.todo(
127 | 'When multiple user deletion message arrives and one fails, then only the failed message is not acknowledged',
128 | );
129 |
--------------------------------------------------------------------------------
/example-application/test/createOrder.observabilityCheck.test.ts:
--------------------------------------------------------------------------------
1 | import sinon from 'sinon';
2 | import OrderRepository from '../data-access/order-repository';
3 | import { AppError, metricsExporter } from '../error-handling';
4 | import logger from '../libraries/logger';
5 | import { testSetup } from './setup/test-file-setup';
6 |
7 | let processExitStub: sinon.SinonStub;
8 |
9 | beforeAll(async () => {
10 | await testSetup.start({
11 | startAPI: true,
12 | disableNetConnect: true,
13 | includeTokenInHttpClient: true,
14 | mockGetUserCalls: true,
15 | mockMailerCalls: true,
16 | });
17 | });
18 |
19 | beforeEach(() => {
20 | testSetup.resetBeforeEach();
21 | processExitStub = sinon.stub(process, 'exit');
22 | });
23 |
24 | afterAll(async () => {
25 | // ️️️✅ Best Practice: Clean-up resources after each run
26 | testSetup.tearDownTestFile();
27 | });
28 |
29 | describe('Error Handling', () => {
30 | describe('Selected Examples', () => {
31 | test('When exception is throw during request, Then logger reports the mandatory fields', async () => {
32 | //Arrange
33 | const orderToAdd = {
34 | userId: 1,
35 | productId: 2,
36 | mode: 'approved',
37 | };
38 |
39 | sinon
40 | .stub(OrderRepository.prototype, 'addOrder')
41 | .rejects(
42 | new AppError('saving-failed', 'Order could not be saved', 500),
43 | );
44 | const loggerDouble = sinon.stub(logger, 'error');
45 |
46 | //Act
47 | await testSetup.getHTTPClient().post('/order', orderToAdd);
48 |
49 | //Assert
50 | expect(loggerDouble.lastCall.firstArg).toMatchObject({
51 | name: 'saving-failed',
52 | status: 500,
53 | stack: expect.any(String),
54 | message: expect.any(String),
55 | });
56 | });
57 |
58 | test('When exception is throw during request, Then a metric is fired', async () => {
59 | //Arrange
60 | const orderToAdd = {
61 | userId: 1,
62 | productId: 2,
63 | mode: 'approved',
64 | };
65 |
66 | const errorToThrow = new AppError(
67 | 'example-error',
68 | 'some example message',
69 | 500,
70 | );
71 | sinon.stub(OrderRepository.prototype, 'addOrder').throws(errorToThrow);
72 | const metricsExporterDouble = sinon.stub(metricsExporter, 'fireMetric');
73 |
74 | //Act
75 | await testSetup.getHTTPClient().post('/order', orderToAdd);
76 |
77 | //Assert
78 | expect(
79 | metricsExporterDouble.calledWith('error', {
80 | errorName: 'example-error',
81 | }),
82 | ).toBe(true);
83 | });
84 |
85 | test('When a non-trusted exception is throw, Then the process should exit', async () => {
86 | //Arrange
87 | const orderToAdd = {
88 | userId: 1,
89 | productId: 2,
90 | mode: 'approved',
91 | };
92 | processExitStub.restore();
93 | const processExitListener = sinon.stub(process, 'exit');
94 | const errorToThrow = new AppError(
95 | 'saving-failed',
96 | 'Order could not be saved',
97 | 500,
98 | false, // ❌ Non-trusted error!
99 | );
100 | sinon.stub(OrderRepository.prototype, 'addOrder').throws(errorToThrow);
101 |
102 | //Act
103 | await testSetup.getHTTPClient().post('/order', orderToAdd);
104 |
105 | //Assert
106 | expect(processExitListener.called).toBe(true);
107 | });
108 |
109 | test('When unknown exception is throw during request, Then the process stays alive', async () => {
110 | //Arrange
111 | const orderToAdd = {
112 | userId: 1,
113 | productId: 2,
114 | mode: 'approved',
115 | };
116 | processExitStub.restore();
117 | const processExitListener = sinon.stub(process, 'exit');
118 | // Arbitrarily choose an object that throws an error
119 | const errorToThrow = new Error('Something vague and unknown');
120 | sinon.stub(OrderRepository.prototype, 'addOrder').throws(errorToThrow);
121 |
122 | //Act
123 | await testSetup.getHTTPClient().post('/order', orderToAdd);
124 |
125 | //Assert
126 | expect(processExitListener.called).toBe(false);
127 | });
128 | });
129 | describe('Various Throwing Scenarios And Locations', () => {
130 | test('When unhandled exception is throw, Then the logger+process exit reports correctly', async () => {
131 | //Arrange
132 | const loggerDouble = sinon.stub(logger, 'error');
133 | const errorToThrow = new Error('An error that wont be caught 😳');
134 | processExitStub.restore();
135 | const processExitListener = sinon.stub(process, 'exit');
136 |
137 | //Act
138 | process.emit('uncaughtException', errorToThrow);
139 |
140 | // Assert
141 | expect(loggerDouble.lastCall.firstArg).toMatchObject(errorToThrow);
142 | expect(processExitListener.called).toBe(false);
143 | });
144 |
145 | test.todo(
146 | "When an error is thrown during web request, then it's handled correctly",
147 | );
148 | test.todo(
149 | "When an error is thrown during a queue message processing , then it's handled correctly",
150 | );
151 | test.todo(
152 | "When an error is thrown from a timer, then it's handled correctly",
153 | );
154 | test.todo(
155 | "When an error is thrown from a middleware, then it's handled correctly",
156 | );
157 | });
158 |
159 | describe('Various Error Types', () => {
160 | test.each`
161 | errorInstance | errorTypeDescription
162 | ${null} | ${'Null as error'}
163 | ${'This is a string'} | ${'String as error'}
164 | ${1} | ${'Number as error'}
165 | ${{}} | ${'Object as error'}
166 | ${new Error('JS basic error')} | ${'JS error'}
167 | ${new AppError('error-name', 'something bad', 500)} | ${'AppError'}
168 | ${'🐤'} | ${'Small cute duck 🐤 as error'}
169 | `(
170 | `When throwing $errorTypeDescription, Then it's handled correctly`,
171 | async ({ errorInstance }) => {
172 | //Arrange
173 | const orderToAdd = {
174 | userId: 1,
175 | productId: 2,
176 | mode: 'approved',
177 | };
178 | sinon.stub(OrderRepository.prototype, 'addOrder').throws(errorInstance);
179 | const metricsExporterDouble = sinon.stub(metricsExporter, 'fireMetric');
180 | const loggerDouble = sinon.stub(logger, 'error');
181 |
182 | //Act
183 | await testSetup.getHTTPClient().post('/order', orderToAdd);
184 |
185 | //Assert
186 | expect(metricsExporterDouble.called).toBe(true);
187 | expect(loggerDouble.called).toBe(true);
188 | },
189 | );
190 | });
191 | });
192 |
--------------------------------------------------------------------------------
/example-application/test/createOrder.responseCheck.test.ts:
--------------------------------------------------------------------------------
1 | import sinon from 'sinon';
2 | import { buildOrder } from './order-data-factory';
3 | import { testSetup } from './setup/test-file-setup';
4 |
5 | beforeAll(async () => {
6 | // ️️️✅ Best Practice: Place the backend under test within the same process
7 | // ️️️✅ Best Practice: Make it clear what is happening before and during the tests
8 | await testSetup.start({
9 | startAPI: true,
10 | disableNetConnect: true,
11 | includeTokenInHttpClient: true,
12 | mockGetUserCalls: true,
13 | mockMailerCalls: true,
14 | });
15 | });
16 |
17 | beforeEach(() => {
18 | testSetup.resetBeforeEach();
19 | });
20 |
21 | afterAll(async () => {
22 | // ️️️✅ Best Practice: Clean-up resources after each run
23 | testSetup.tearDownTestFile();
24 | });
25 |
26 | // 🎯 This file is about checking specific route responses
27 | // This is only aspect of component testing, see other files for even more importan sceanrios
28 |
29 | // ️️️✅ Best Practice: Structure tests by route name or by any other meaningful category
30 | describe('POST /orders', () => {
31 | // ️️️✅ Best Practice: Check the response, but not only this! See other files and examples for other effects
32 | // that should be tested
33 | test('When adding a new valid order, Then should get back successful approval response', async () => {
34 | //Arrange
35 | const orderToAdd = {
36 | userId: 1,
37 | productId: 2,
38 | mode: 'approved',
39 | };
40 |
41 | //Act
42 | const receivedAPIResponse = await testSetup
43 | .getHTTPClient()
44 | .post('/order', orderToAdd);
45 |
46 | //Assert
47 | expect(receivedAPIResponse).toMatchObject({
48 | status: 200,
49 | data: {
50 | // ️️️✅ Best Practice: Check the existence + data type of dynamic fields
51 | id: expect.any(Number),
52 | mode: 'approved',
53 | },
54 | });
55 | });
56 |
57 | // ️️️✅ Best Practice: Check invalid input
58 | test('When adding an order without specifying product, stop and return 400', async () => {
59 | //Arrange
60 | const orderToAdd = {
61 | userId: 1,
62 | mode: 'draft',
63 | };
64 |
65 | //Act
66 | const orderAddResult = await testSetup
67 | .getHTTPClient()
68 | .post('/order', orderToAdd);
69 |
70 | //Assert
71 | expect(orderAddResult.status).toBe(400);
72 | });
73 |
74 | // ️️️✅ Best Practice: Check invalid input
75 | test('When providing no token, then get unauthorized response', async () => {
76 | //Arrange
77 | const orderToAdd = {
78 | userId: 1,
79 | mode: 'draft',
80 | };
81 |
82 | //Act
83 | const orderAddResult = await testSetup
84 | .getHTTPClient()
85 | .post('/order', orderToAdd, { headers: { Authorization: '' } });
86 |
87 | //Assert
88 | expect(orderAddResult.status).toBe(401);
89 | });
90 |
91 |
92 | test('When ordered by a premium user, Then 10% discount is applied', async () => {
93 | //Arrange
94 | // ️️️✅ Best Practice: Use a dynamic factory to craft big payloads and still clarify which specific details
95 | // are the 'smoking gun' that is responsible for the test outcome
96 | const orderOfPremiumUser = buildOrder({
97 | isPremiumUser: true,
98 | totalPrice: 100,
99 | });
100 |
101 | //Act
102 | const addResponse = await testSetup
103 | .getHTTPClient()
104 | .post('/order', orderOfPremiumUser);
105 |
106 | //Assert
107 | const orderAfterSave = await testSetup
108 | .getHTTPClient()
109 | .get(`/order/${addResponse.data.id}`);
110 | expect(orderAfterSave.data.totalPrice).toBe(90);
111 | });
112 | });
113 |
--------------------------------------------------------------------------------
/example-application/test/createOrder.stateCheck.test.ts:
--------------------------------------------------------------------------------
1 | import { buildOrder } from './order-data-factory';
2 | import { testSetup } from './setup/test-file-setup';
3 |
4 | beforeAll(async () => {
5 | await testSetup.start({
6 | startAPI: true,
7 | disableNetConnect: true,
8 | includeTokenInHttpClient: true,
9 | mockGetUserCalls: true,
10 | mockMailerCalls: true,
11 | });
12 | });
13 |
14 | beforeEach(() => {
15 | testSetup.resetBeforeEach();
16 | });
17 |
18 | afterAll(async () => {
19 | // ️️️✅ Best Practice: Clean-up resources after each run
20 | testSetup.tearDownTestFile();
21 | });
22 |
23 | describe('POST /orders', () => {
24 | // ️️️✅ Best Practice: Check the new state
25 | // In a real-world project, this test can be combined with the previous test
26 | test('When adding a new valid order, Then should be able to retrieve it', async () => {
27 | //Arrange
28 | const orderToAdd = {
29 | userId: 1,
30 | productId: 2,
31 | mode: 'approved',
32 | };
33 |
34 | //Act
35 | const {
36 | data: { id: addedOrderId },
37 | } = await testSetup.getHTTPClient().post('/order', orderToAdd);
38 |
39 | //Assert
40 | const getOrderResponse = await testSetup
41 | .getHTTPClient()
42 | .get(`/order/${addedOrderId}`);
43 |
44 | expect(getOrderResponse.data).toMatchObject({
45 | ...orderToAdd,
46 | id: addedOrderId,
47 | });
48 | });
49 | });
50 |
51 | describe('DELETE /order', () => {
52 | test.only('When deleting an existing order, Then it should NOT be retrievable', async () => {
53 | // Arrange
54 | const deletedOrder = (
55 | await testSetup.getHTTPClienForArrange().post('/order', buildOrder())
56 | ).data.id;
57 | const notDeletedOrder = (
58 | await testSetup.getHTTPClienForArrange().post('/order', buildOrder())
59 | ).data.id;
60 |
61 | // Act
62 | await testSetup.getHTTPClient().delete(`/order/${deletedOrder}`);
63 |
64 | // Assert
65 | const getDeletedOrderStatus = await testSetup
66 | .getHTTPClient()
67 | .get(`/order/${deletedOrder}`);
68 | const getNotDeletedOrderStatus = await testSetup
69 | .getHTTPClient()
70 | .get(`/order/${notDeletedOrder}`);
71 | expect(getDeletedOrderStatus.status).toBe(404);
72 | expect(getNotDeletedOrderStatus.status).toBe(200);
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/example-application/test/order-data-factory.ts:
--------------------------------------------------------------------------------
1 | import { faker } from '@faker-js/faker';
2 |
3 | import { Order } from '../libraries/types';
4 |
5 | export function buildOrder(overrides?: Partial): Order {
6 | const defaultOrder: Order = {
7 | userId: 1,
8 | isPremiumUser: true,
9 | productId: 2,
10 | mode: 'approved',
11 | orderId: 12345,
12 | orderDate: '2024-12-23T10:00:00Z',
13 | status: 'processing',
14 | totalPrice: 59,
15 | externalIdentifier: faker.string.uuid(),
16 | currency: 'USD',
17 | quantity: 1,
18 | paymentMethod: 'credit_card',
19 | transactionId: 'txn_987654',
20 | deliveryAddress: {
21 | street: '123 Main St',
22 | city: 'New York',
23 | state: 'NY',
24 | postalCode: '10001',
25 | country: 'USA',
26 | },
27 | billingAddress: {
28 | street: '456 Elm St',
29 | city: 'Boston',
30 | state: 'MA',
31 | postalCode: '02118',
32 | country: 'USA',
33 | },
34 | shippingMethod: 'standard',
35 | estimatedDeliveryDate: '2024-12-28',
36 | trackingNumber: 'TRACK123456789',
37 | items: [
38 | {
39 | productId: 2,
40 | productName: 'Wireless Mouse',
41 | productPrice: 29.99,
42 | quantity: 1,
43 | },
44 | ],
45 | discountCode: 'HOLIDAY20',
46 | discountAmount: 10.0,
47 | taxAmount: 5.0,
48 | shippingCost: 5.0,
49 | giftWrap: false,
50 | customerNote: 'Please leave the package at the back door.',
51 | isGift: true,
52 | refundStatus: 'none',
53 | loyaltyPointsUsed: 200,
54 | loyaltyPointsEarned: 10,
55 | contactEmail: 'user@example.com',
56 | contactPhone: '+1234567890',
57 | };
58 |
59 | return { ...defaultOrder, ...overrides };
60 | }
61 |
--------------------------------------------------------------------------------
/example-application/test/pool.ts:
--------------------------------------------------------------------------------
1 | // ️️️✅ Best Practice: Check monitoring metrics
2 | test.todo(
3 | 'When a new valid order was added, then order-added metric was fired',
4 | );
5 |
6 | // ️️️✅ Best Practice: Simulate external failures
7 | test.todo(
8 | 'When the user service is down, then order is still added successfully',
9 | );
10 |
11 | test('When the user does not exist, return 404 response', async () => {
12 | //Arrange
13 | testSetup.removeUserNock();
14 | nock('http://localhost').get('/user/1').reply(404, undefined);
15 | const orderToAdd = {
16 | userId: 1,
17 | productId: 2,
18 | mode: 'draft',
19 | };
20 |
21 | //Act
22 | const orderAddResult = await testSetup
23 | .getHTTPClient()
24 | .post('/order', orderToAdd);
25 |
26 | //Assert
27 | expect(orderAddResult.status).toBe(404);
28 | });
29 |
30 | test('When order failed, send mail to admin', async () => {
31 | //Arrange
32 | process.env.SEND_MAILS = 'true';
33 | // ️️️✅ Best Practice: Intercept requests for 3rd party services to eliminate undesired side effects like emails or SMS
34 | // ️️️✅ Best Practice: Specify the body when you need to make sure you call the 3rd party service as expected
35 | testSetup.removeMailNock();
36 | let emailPayload;
37 | nock('http://mailer.com')
38 | .post('/send', (payload) => ((emailPayload = payload), true))
39 | .reply(202);
40 |
41 | sinon
42 | .stub(OrderRepository.prototype, 'addOrder')
43 | .throws(new Error('Unknown error'));
44 | const orderToAdd = {
45 | userId: 1,
46 | productId: 2,
47 | mode: 'approved',
48 | };
49 |
50 | //Act
51 | await testSetup.getHTTPClient().post('/order', orderToAdd);
52 |
53 | //Assert
54 | // ️️️✅ Best Practice: Assert that the app called the mailer service appropriately
55 | expect(emailPayload).toMatchObject({
56 | subject: expect.any(String),
57 | body: expect.any(String),
58 | recipientAddress: expect.stringMatching(/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/),
59 | });
60 | });
61 |
62 | // ️️️✅ Best Practice: Check external calls
63 | test('When adding a new valid order, Then an email should be send to admin', async () => {
64 | //Arrange
65 | process.env.SEND_MAILS = 'true';
66 |
67 | // ️️️✅ Best Practice: Intercept requests for 3rd party services to eliminate undesired side effects like emails or SMS
68 | // ️️️✅ Best Practice: Specify the body when you need to make sure you call the 3rd party service as expected
69 | let emailPayload;
70 | testSetup.removeMailNock();
71 | nock('http://mailer.com')
72 | .post('/send', (payload) => ((emailPayload = payload), true))
73 | .reply(202);
74 |
75 | const orderToAdd = {
76 | userId: 1,
77 | productId: 2,
78 | mode: 'approved',
79 | };
80 |
81 | //Act
82 | await testSetup.getHTTPClient().post('/order', orderToAdd);
83 |
84 | //Assert
85 | // ️️️✅ Best Practice: Assert that the app called the mailer service appropriately
86 | expect(emailPayload).toMatchObject({
87 | subject: expect.any(String),
88 | body: expect.any(String),
89 | recipientAddress: expect.stringMatching(/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/),
90 | });
91 | });
92 |
93 | // ️️️✅ Best Practice: Check error handling
94 | test.todo('When a new order failed, an invalid-order error was handled');
95 |
96 | // ️️️✅ Best Practice: Acknowledge that other unknown records might exist, find your expectations within
97 | // the result
98 | test.todo(
99 | 'When adding 2 orders, then these orders exist in result when querying for all',
100 | );
101 |
--------------------------------------------------------------------------------
/example-application/test/setup/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.6'
2 | services:
3 | database:
4 | image: postgres:11
5 | command: postgres -c fsync=off -c synchronous_commit=off -c full_page_writes=off -c random_page_cost=1.0
6 | environment:
7 | - POSTGRES_USER=myuser
8 | - POSTGRES_PASSWORD=myuserpassword
9 | - POSTGRES_DB=shop
10 | container_name: 'postgres-for-testing'
11 | ports:
12 | - '54310:5432'
13 | tmpfs: /var/lib/postgresql/data
14 | messageQueue:
15 | image: rabbitmq:3-management-alpine
16 | container_name: 'rabbitmq'
17 | ports:
18 | - 5672:5672
19 | - 15672:15672
20 | environment:
21 | - RABBITMQ_DEFAULT_PASS=guest
22 | - RABBITMQ_DEFAULT_USER=guest
23 | - RABBITMQ_PASSWORD=guest
24 | - RABBITMQ_USERNAME=guest
25 |
--------------------------------------------------------------------------------
/example-application/test/setup/global-setup.js:
--------------------------------------------------------------------------------
1 | const isPortReachable = require('is-port-reachable');
2 | const path = require('path');
3 | const dockerCompose = require('docker-compose');
4 | const { execSync } = require('child_process');
5 |
6 | module.exports = async () => {
7 | console.time('global-setup');
8 |
9 | // ️️️✅ Best Practice: Speed up during development, if already live then do nothing
10 | const isDBReachable = await isPortReachable(54310);
11 | if (!isDBReachable) {
12 | // ️️️✅ Best Practice: Start the infrastructure within a test hook - No failures occur because the DB is down
13 | await dockerCompose.upAll({
14 | cwd: path.join(__dirname),
15 | log: true,
16 | });
17 |
18 | await dockerCompose.exec(
19 | 'database',
20 | ['sh', '-c', 'until pg_isready ; do sleep 1; done'],
21 | {
22 | cwd: path.join(__dirname),
23 | }
24 | );
25 |
26 | // ️️️✅ Best Practice: Use npm script for data seeding and migrations
27 | execSync('npm run db:migrate');
28 | // ✅ Best Practice: Seed only metadata and not test record, read "Dealing with data" section for further information
29 | execSync('npm run db:seed');
30 | }
31 |
32 | // 👍🏼 We're ready
33 | console.timeEnd('global-setup');
34 | };
35 |
--------------------------------------------------------------------------------
/example-application/test/setup/global-teardown.js:
--------------------------------------------------------------------------------
1 | const isCI = require('is-ci');
2 | const dockerCompose = require('docker-compose');
3 | const OrderRepository = require('../../data-access/order-repository');
4 |
5 | module.exports = async () => {
6 | if (isCI) {
7 | // ️️️✅ Best Practice: Leave the DB up in dev environment
8 | dockerCompose.down();
9 | } else {
10 | // ✅ Best Practice: Clean the database occasionally
11 | if (Math.ceil(Math.random() * 10) === 10) {
12 | await new OrderRepository().cleanup();
13 | }
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/example-application/test/setup/test-file-setup.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
2 | import colors from 'colors/safe';
3 | import dateFns from 'date-fns';
4 | import { AddressInfo } from 'net';
5 | import nock from 'nock';
6 | import * as sinon from 'sinon';
7 |
8 | import { startWebServer, stopWebServer } from '../../entry-points/api';
9 | import { Roles, User } from '../../libraries/types';
10 | const jwt = require('jsonwebtoken');
11 |
12 | export type TestStartOptions = {
13 | startAPI: boolean;
14 | disableNetConnect: boolean;
15 | includeTokenInHttpClient: boolean;
16 | mockGetUserCalls: boolean;
17 | mockMailerCalls: boolean;
18 | };
19 |
20 | let apiAddress: null | AddressInfo; // holds the address of the API server to bake into HTTP clients
21 | let httpClientForArrange: AxiosInstance | undefined; // This is used for making API calls in the arrange phase, where we wish to fail fast if someting goes wrong happen
22 | let httpClient: AxiosInstance | undefined; // Http client for the Act phase, won't throw errors but rather return statuses
23 | let chosenOptions: TestStartOptions | undefined;
24 |
25 | export const testSetup = {
26 | start: async function (options: TestStartOptions) {
27 | chosenOptions = options;
28 | if (options.startAPI === true) {
29 | apiAddress = await startWebServer();
30 | }
31 | if (options.disableNetConnect === true) {
32 | disableNetworkConnect();
33 | }
34 | nock.emitter.on('no match', (request) => {
35 | if (
36 | request.host?.includes('localhost') ||
37 | request.host?.includes('127.0.0.1')
38 | ) {
39 | return;
40 | }
41 | console.log(
42 | colors.red(
43 | `An unmocked HTTP call was found. This is not recommended as it hurts performance, stability and also might get blocked: ${request.method} ${request.protocol}://${request.host}${request.path}`,
44 | ),
45 | );
46 | });
47 | },
48 |
49 | tearDownTestFile: async function () {
50 | if (apiAddress) {
51 | await stopWebServer();
52 | }
53 | apiAddress = null;
54 | nock.cleanAll();
55 | nock.enableNetConnect();
56 | sinon.restore();
57 | },
58 |
59 | resetBeforeEach: async function () {
60 | nock.cleanAll();
61 | sinon.restore();
62 | if (chosenOptions?.mockGetUserCalls === true) {
63 | nock('http://localhost')
64 | .get('/user/1')
65 | .reply(200, { id: '1', name: 'John' })
66 | .persist();
67 | }
68 | if (chosenOptions?.mockMailerCalls === true) {
69 | nock('http://mailer.com').post('/send').reply(202);
70 | }
71 | },
72 |
73 | getHTTPClienForArrange: function (): AxiosInstance {
74 | if (!httpClientForArrange) {
75 | httpClientForArrange = buildHttpClient(true);
76 | }
77 |
78 | return httpClientForArrange!;
79 | },
80 |
81 | getHTTPClient: function (): AxiosInstance {
82 | if (!httpClient) {
83 | httpClient = buildHttpClient(false);
84 | }
85 | return httpClient!;
86 | },
87 | removeUserNock: function () {
88 | nock.removeInterceptor({
89 | hostname: 'localhost',
90 | method: 'GET',
91 | path: '/user/1',
92 | });
93 | },
94 | removeMailNock: function () {
95 | nock.removeInterceptor({
96 | hostname: 'mailer.com',
97 | method: 'POST',
98 | path: '/send',
99 | });
100 | },
101 | };
102 |
103 | function buildHttpClient(throwsIfErrorStatus: boolean = false) {
104 | if (!apiAddress) {
105 | // This is probably a faulty state - someone instantiated the setup file without starting the API
106 | console.log(
107 | colors.red(
108 | `Test warning: The http client will be returned without a base address, is this what you meant?
109 | If you mean to test the API, ensure to pass {startAPI: true} to the setupTestFile function`,
110 | ),
111 | );
112 | }
113 |
114 | const axiosConfig: AxiosRequestConfig = {
115 | maxRedirects: 0,
116 | };
117 | axiosConfig.headers = new axios.AxiosHeaders();
118 | axiosConfig.headers.set('Content-Type', 'application/json');
119 | if (apiAddress) {
120 | axiosConfig.baseURL = `http://127.0.0.1:${apiAddress.port}`;
121 | }
122 | if (!throwsIfErrorStatus) {
123 | axiosConfig.validateStatus = () => true;
124 | }
125 | if (chosenOptions?.includeTokenInHttpClient) {
126 | axiosConfig.headers.set(
127 | 'Authorization',
128 | `${signToken(
129 | { id: '1', name: 'John' },
130 | Roles.user,
131 | dateFns.addDays(new Date(), 1).getTime(),
132 | )}`,
133 | );
134 | }
135 |
136 | const axiosInstance = axios.create(axiosConfig);
137 |
138 | return axiosInstance;
139 | }
140 |
141 | function getWebServerAddress() {
142 | if (!apiAddress) {
143 | throw new Error('The API server is not started, cannot get its address');
144 | }
145 | return { port: apiAddress.port, url: apiAddress.address };
146 | }
147 |
148 | function disableNetworkConnect() {
149 | nock.disableNetConnect();
150 | nock.enableNetConnect(
151 | (host) => host.includes('127.0.0.1') || host.includes('localhost'),
152 | );
153 | }
154 |
155 | function signToken(user: User, role: Roles, expirationInUnixTime: number) {
156 | const token = jwt.sign(
157 | {
158 | exp: expirationInUnixTime,
159 | data: {
160 | user,
161 | role,
162 | },
163 | },
164 | 'some secret', // In production system obviously read this from config...
165 | );
166 |
167 | return token;
168 | }
169 |
--------------------------------------------------------------------------------
/graphics/component-diagram.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/component-diagram.jpg
--------------------------------------------------------------------------------
/graphics/component-tests-explainer-video.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/component-tests-explainer-video.mp4
--------------------------------------------------------------------------------
/graphics/component-tests-header.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/component-tests-header.jpg
--------------------------------------------------------------------------------
/graphics/component-tests-v4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/component-tests-v4.jpg
--------------------------------------------------------------------------------
/graphics/contract-options.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/contract-options.png
--------------------------------------------------------------------------------
/graphics/db-clean-options.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/db-clean-options.png
--------------------------------------------------------------------------------
/graphics/exit-doors.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/exit-doors.png
--------------------------------------------------------------------------------
/graphics/low-value.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/low-value.png
--------------------------------------------------------------------------------
/graphics/main-header.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/main-header.jpg
--------------------------------------------------------------------------------
/graphics/main-header.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/main-header.png
--------------------------------------------------------------------------------
/graphics/mq-comparison.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/mq-comparison.png
--------------------------------------------------------------------------------
/graphics/operations.md:
--------------------------------------------------------------------------------
1 | //soon
2 |
--------------------------------------------------------------------------------
/graphics/team/daniel.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/team/daniel.jpg
--------------------------------------------------------------------------------
/graphics/team/github-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/team/github-light.png
--------------------------------------------------------------------------------
/graphics/team/github.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/team/github.png
--------------------------------------------------------------------------------
/graphics/team/linkedin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/team/linkedin.png
--------------------------------------------------------------------------------
/graphics/team/michael.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/team/michael.jpg
--------------------------------------------------------------------------------
/graphics/team/twitter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/team/twitter.png
--------------------------------------------------------------------------------
/graphics/team/website.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/team/website.png
--------------------------------------------------------------------------------
/graphics/team/yoni.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/team/yoni.jpg
--------------------------------------------------------------------------------
/graphics/test-data-types.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/test-data-types.png
--------------------------------------------------------------------------------
/graphics/test-report-by-route.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/test-report-by-route.png
--------------------------------------------------------------------------------
/graphics/testing-best-practices-banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/testing-best-practices-banner.png
--------------------------------------------------------------------------------
/graphics/unleash-the-power.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/graphics/unleash-the-power.png
--------------------------------------------------------------------------------
/improve.txt:
--------------------------------------------------------------------------------
1 | - Test setup
2 | - More fields, no mode
3 | - Data factory
4 | - httpClientForArrange
5 | - TypeScript
6 | - Tokens
7 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | verbose: false,
3 | testMatch: [
4 | '**/*test.{ts,js}',
5 | '!**/mocha/**',
6 | '!**/playground/**',
7 | '!**/*test-helper*',
8 | '!**/*anti-pattern*', // Uncomment this only when you want to inspect the consequences of anti-patterns
9 | '!**/*performance*', //Uncomment this only when you want to inspect the performance of tests
10 | ],
11 | collectCoverage: false,
12 | coverageReporters: ['text-summary', 'lcov'],
13 | collectCoverageFrom: ['**/*.js', '!**/node_modules/**', '!**/test/**'],
14 | forceExit: true,
15 | setupFilesAfterEnv: ['jest-extended/all'],
16 | testEnvironment: 'node',
17 | notify: true,
18 | globalSetup: './example-application/test/setup/global-setup.js',
19 | globalTeardown: './example-application/test/setup/global-teardown.js',
20 | notifyMode: 'change',
21 | transform: {
22 | '^.+\\.ts$': 'ts-jest',
23 | },
24 | watchPlugins: [
25 | 'jest-watch-typeahead/filename',
26 | 'jest-watch-typeahead/testname',
27 | [
28 | 'jest-watch-repeat',
29 | {
30 | key: 'r',
31 | prompt: 'repeat test runs.',
32 | },
33 | ],
34 | [
35 | 'jest-watch-suspend',
36 | {
37 | key: 's',
38 | prompt: 'suspend watch mode',
39 | },
40 | ],
41 | 'jest-watch-master',
42 | [
43 | 'jest-watch-toggle-config',
44 | {
45 | setting: 'verbose',
46 | },
47 | ],
48 | [
49 | 'jest-watch-toggle-config',
50 | {
51 | setting: 'collectCoverage',
52 | },
53 | ],
54 | ],
55 | };
56 |
--------------------------------------------------------------------------------
/mocking.md:
--------------------------------------------------------------------------------
1 | ## \*_Mocking_
2 |
3 |
4 |
5 | ### ⚪️ 1. Spot the Good Mocks from the Evil Ones
6 |
7 | 🏷 **Tags:** `#basic, #strategic`
8 |
9 | :white*check_mark: **Do:** Mocking is a necessary evil—sometimes it saves us, and sometimes it drags us straight into testing hell. While often treated as a single tool, mocks come in different flavors, and understanding them makes all the difference. Let’s break it down into \_three types* based on their purpose:
10 |
11 | **Isolation mocks - Keeping the Outside World Outside** – Preventing access to out-of-scope units (e.g., third-party services, email providers) and ensuring proper interaction with these external units. This is done by stubbing a function that lives at our code’s boundaries and makes external calls. Alternatively, for network calls, we might intercept the request instead of stubbing the function itself.
12 |
13 | This style of mocking serves two main purposes:
14 |
15 | 1. **Prevent unwanted side effects** – Avoid hitting external systems.
16 | 2. **Verify external interactions** – Ensure _our_ code is making the right calls in the right way.
17 |
18 | Note: Calls to an _external_ system are an _effect_, an outcome of our code. We should test them just as we test function outputs.
19 |
20 | **Simulation mocks** – Forcing a specific scenario that can't be triggered using external inputs. For example, simulating an internal error or advancing time in a test. Practically, this means stubbing an _internal_ function.
21 |
22 | While coupling tests to internal mechanisms isn’t ideal, sometimes it’s necessary. If a scenario could realistically happen in production but is impossible to trigger naturally in a test, then a simulation mock might be justified.
23 |
24 | **Implementation mocks** – Checking that the code _internally_ worked as expected. For example, asserting that an in-scope function was invoked or verifying a component's internal state.
25 |
26 | The fundamental difference between these three is that the first two check external effects—things visible to the outside world—while implementation mocks check _how_ the unit works rather than _what_ it produces.
27 |
28 | This last type is the one you want to avoid at all costs. Why? Two reasons:
29 |
30 | 1. **False Positives** – The test fails when refactoring, even if the behavior stays correct.
31 | 2. **False Negatives** – The test passes when it should fail because it only checks implementation details, not the real outcome.
32 |
33 | ### The Formula for a Bad Mock
34 |
35 | A mock is bad if it meets both of these conditions:
36 |
37 | - It applies to _internal_ code.
38 | - It appears in the test's _assert_ phase.
39 | -
40 |
41 | 👀 **Alternatives:** One may minimize mocks by separating pure code from code that collaborates with externous units. This is always a welome approach, but inhertly some code must have effects
42 |
43 |
44 |
45 | ### ⚪️ 2. Avoid Hidden, Surprising Mocks
46 |
47 | 🏷 **Tags:** #strategic
48 |
49 | :white_check_mark: **Do:** Mocks obviously change both the code and test behavior, and whoever reads a failing test at midnight must be aware of these effects. "I love hidden things that mysteriously modify my code," said no one ever.
50 |
51 | Consequently, mocks should always be defined inside the test file in one of two places:
52 |
53 | If a mock directly affects the test outcome, it should be defined as part of the test itself, in the setup phase (i.e., the arrange phase).
54 | If a mock isn't the direct cause of the test result, it still might implicitly affect debugging, so we don't want it hidden far away from the reader—place it in the beforeEach hook instead.
55 | This doesn’t mean stuffing massive JSONs inside each test file. The call to the factory that generates mock data should be included in the test file, but the data itself can be kept in a dedicated file. (See the bullet about mocking factories.)
56 |
57 |
58 | 👀 Alternatives: Some test runners, like Vitest and Jest, allow defining mocks in static files within dedicated folders. These mocks get picked up automatically, applied everywhere auto-magically, and leave the reader to figure out where these surprising effects are coming from ❌.
59 |
60 | Similarly, defining mocks in an external hook file that the test runner calls before running a suite? Also a bad idea—same reason. ❌
61 |
62 | ✏ Code Examples
63 |
64 | ```js
65 | beforeEach(() => {
66 | // The email sender isn't directly related to the test's subject, so we put it in the
67 | // closest test hook instead of inside each test.
68 | sinon.restore();
69 | sinon.stub(emailService, 'send').returns({ succeeded: true });
70 | });
71 |
72 | test('When ordered by a premium user, Then 10% discount is applied', async () => {
73 | // This response from an external service directly affects the test result,
74 | // so we define the mock inside the test itself.
75 | sinon.stub(usersService, 'getUser').returns({ id: 1, status: 'premium' });
76 | //...
77 | });
78 | ```
79 |
80 |
81 |
82 |
83 |
84 | ### ⚪️ 3. Be Mindful About Partial Mocks
85 |
86 | 🏷 **Tags:** #advanced
87 |
88 | :white_check_mark: **Do:** When mocking, you’re replacing functions. But if you have an object or class that needs to be mocked, should you replace all functions or just some?
89 |
90 | Partial mocks are risky: They leave a zombie object—part real, part mocked. Will it always behave as expected?
91 |
92 | As a rule of thumb, when mocking objects that interact with external systems (e.g., isolation mocks like a Mailer), it’s best to mock the entire object. This ensures no hidden calls slip through. Close the borders by giving all functions a safe default—either throwing an error or returning undefined. Once that’s locked down, you can specify valid responses for the functions you actually need.
93 |
94 | Some mocking libraries, like Sinon, allow auto-mocking all functions with a single line, while others require you to define a response for every function.
95 |
96 | There is, however, one valid case for partial mocks: Simulating a specific internal failure while letting the rest of the system run as usual. For example, testing what happens if a database connection fails. In this case, we need the entire production code to run normally, except for one function that we intentionally make fail. This is the only situation where a partial mock makes sense.
97 |
98 |
99 |
100 | 👀 **Alternatives:** Replace the object with a fully fake implementation. This avoids a messy mix of real and fake but requires more effort.
101 |
102 | ✏ Code Examples
103 |
104 | ```js
105 | import sinon from 'sinon';
106 |
107 | const myObject = {
108 | methodA: () => 'some value',
109 | methodB: () => 42,
110 | };
111 |
112 | // Stub all functions to return undefined
113 | const stubbedObject = sinon.stub(myObject);
114 |
115 | console.log(stubbedObject.methodA()); // undefined
116 | ```
117 |
118 |
119 |
120 |
121 |
122 | ### ⚪️ 4. Clean Up All Mocks Before Every Test
123 |
124 | 🏷 **Tags:** #strategic
125 |
126 | :white_check_mark: **Do:** Every test must start from a clean slate—mocks from previous tests should never affect the next ones. Always clean up all mocks in the beforeEach hook.
127 |
128 | What if you need the same common mocks across all tests? Define them inside the same beforeEach hook so they get reset and reapplied every time. This ensures that any modifications made in one test don’t leak into another.
129 |
130 | Also, consider adding a cleanup step in afterAll—it’s just one line—to make sure the next test file starts with no leftovers.
131 |
132 |
133 | 👀 **Alternatives:** Cleaning up in afterEach is also an option, but there’s a catch: If a test file fails to clean up properly, the first test in the next file could start in a dirty state.
134 |
135 | ✏ Code Examples
136 |
137 | ```js
138 | beforeEach(() => {
139 | sinon.restore();
140 | // Redefine all common mocks to reset them in case a test modified them
141 | sinon.stub(emailService, 'send').returns({ succeeded: true });
142 | });
143 |
144 | afterAll(() => {
145 | sinon.restore();
146 | });
147 | ```
148 |
149 |
150 |
151 |
152 |
153 | ### ⚪️ 5. Be Mindful About the Mocking Mechanism
154 |
155 | 🏷 **Tags:** `#advanced`
156 |
157 | ✅ **Do:** There are **significant differences** between the two main mocking techniques, each with its own trade-offs:
158 |
159 | 1. **Module-based mocks** (e.g., `vitest.mock`, `jest.mock`) work by **intercepting module imports** and replacing them with mocked alternatives. These mocks hijack the module loading process and inject a test-defined version.
160 | 2. **Cache-based mocks** rely on the fact that **`require`-d modules share the same cache**, allowing tests to modify an imported object, affecting its behavior globally. Libraries like [Sinon](https://sinonjs.org/) and `jest.spyOn`/`vitest.spyOn` use this approach.
161 |
162 | ### **Understanding the Trade-offs**
163 |
164 | - **Module-based mocking** works in all scenarios, including ESM, but it comes at a cost:
165 | - Mocks **must** be defined _before_ importing the module.
166 | - The test runner magically **hoists** the mock above imports, which involves tweaking the module system behind the scenes.
167 | - **Cache-based mocking** is simpler—no magic under the hood, just regular object reference manipulation. But it has **strict limitations**:
168 | - It **fails** if the module isn’t exported as a plain JS object.
169 | - **ESM default exports can’t be modified** (due to [live bindings](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import)).
170 | - **CJS single-function exports don’t have an object to modify**, making this technique ineffective.
171 |
172 | ### **Which One Should You Use?**
173 |
174 | - If your codebase uses **CJS (`require`)** or consistently exports **objects**, **cache-based mocking** is the simpler and preferred approach.
175 | - In **all other cases**, **module-based mocking** is unavoidable.
176 |
177 |
178 |
179 | 👀 **Alternatives:** A **dependency injection system** allows passing a mock instead of the real implementation. This sidesteps all these technical complexities but requires modifying the code structure.
180 |
181 |
182 |
183 | ✏ Code Examples
184 |
185 | ```js
186 | // service2.js
187 | export function service2Func() {
188 | console.log('the real service2');
189 | }
190 |
191 | // service1.js
192 | import { service2Func } from './service2.js';
193 |
194 | export function service1Func() {
195 | service2Func();
196 | }
197 |
198 | // test-with-object-caching.js
199 | import * as s1 from './service1.js';
200 | import * as s2 from './service2.js';
201 |
202 | test('Check mocking', () => {
203 | sinon.stub(s2, 'service2Func').callsFake(() => {
204 | console.log('Service 2 mocked');
205 | });
206 | s1.service1Func();
207 | });
208 | // Prints "The real service2". Mocking failed ❌
209 |
210 | // test-with-module-loading.js
211 | import { test, vi } from 'vitest';
212 | import * as s1 from './service1.js';
213 | import * as s2 from './service2.js';
214 |
215 | test('Check mocking', () => {
216 | vi.spyOn(s2, 'service2Func').mockImplementation(() => {
217 | console.log('Service 2 mocked');
218 | });
219 | s1.service1Func();
220 | });
221 | // Prints "Service 2 mocked". Mocking worked ✅
222 | ```
223 |
224 |
225 |
226 |
227 | ### ⚪️ 6. Type your mocks
228 |
229 | 🏷 **Tags:** `#advanced`
230 |
231 | :white_check_mark: **Do:** Type safety is always a precious asset, all the more with mocking: Once we create a 2nd instance of some code, a mock, we put ourselves at a risk of having a different signature, mostly when the code changes. When this happen, we have two versions of the truth: the reality and our false personal belief. When this happens, tests are likely to pass while production is troubled. A reputable mocking library provides type-safety support: should the defined mock isn't aligned with the code it mocks - a type error will be shown. With that, some of key mocking functions of the popular test runners are not type safe (e.g., Jest.mock, Vitest.mock) - ensure to use the ones that do support types
232 |
233 |
234 |
235 | 👀 **Alternatives:** It's possible to occassionaly turn-off mocks and run the same tests against the real collaborators - type mismatch is will be discovered only for happy paths and too late ❌; One may explictly put a type definition for the defined mocks (e.g., use the TypeScript 'satisfy' keyword) - a viable option ✅
236 |
237 |
238 |
239 | ✏ Code Examples
240 |
241 | ```js
242 | // calculate-price.ts
243 | export function calculatePrice(): number {
244 | return 100;
245 | }
246 |
247 | // calculate-price.test.ts
248 | vi.mocked(calculatePrice).mockImplementation(() => {
249 | // Vitest example. Works the same with Jest
250 | return { price: 500 }; // ❌ Type '{ price: number; }' is not assignable to type 'number'
251 | });
252 | ```
253 |
254 | ➡️ [Full code here](https://github.com/testjavascript/integration-tests-a-z/blob/4c76cb2e2202e6c1184d1659bf1a2843db3044e4/example-application/entry-points/api-under-test.js#L10-L34)
255 |
256 |
257 | ````
258 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "integration-test-a-z",
3 | "version": "1.0.0",
4 | "description": "",
5 | "scripts": {
6 | "test": "jest",
7 | "test:dev": "jest --watch --silent --maxWorkers=2 --verbose=false",
8 | "test:dev:debug": "node --inspect=9229 node_modules/.bin/jest --watch --runInBand",
9 | "test:nestjs": "jest --config='./recipes/nestjs/ts-jest.config.js' basic-nest-tests.test.ts",
10 | "test:dev:verbose": "jest --watch --verbose",
11 | "lint": "eslint .",
12 | "db:migrate": "cd example-application/data-access && sequelize-cli db:migrate",
13 | "db:seed": "cd example-application/data-access && sequelize-cli db:seed:all"
14 | },
15 | "author": "",
16 | "license": "ISC",
17 | "dependencies": {
18 | "@aws-sdk/client-sqs": "^3.712.0",
19 | "@nestjs/common": "^10.4.15",
20 | "@nestjs/core": "^10.4.15",
21 | "@nestjs/platform-express": "^10.4.15",
22 | "@pulumi/pulumi": "^3.143.0",
23 | "@pulumi/rabbitmq": "^3.3.8",
24 | "@types/amqplib": "^0.10.6",
25 | "@types/aws-sdk": "^2.7.0",
26 | "@types/jsonwebtoken": "^9.0.7",
27 | "amqplib": "^0.10.5",
28 | "axios": "^1.7.9",
29 | "axios-retry": "^4.5.0",
30 | "body-parser": "^1.20.3",
31 | "express": "^4.21.2",
32 | "jsonwebtoken": "^9.0.2",
33 | "pg": "^8.13.1",
34 | "reflect-metadata": "^0.2.2",
35 | "rxjs": "^7.8.1",
36 | "sequelize": "^6.37.5"
37 | },
38 | "devDependencies": {
39 | "@faker-js/faker": "^9.3.0",
40 | "@types/chai": "^5.0.1",
41 | "@types/chai-subset": "^1.3.5",
42 | "@types/colors": "^1.2.4",
43 | "@types/date-fns": "^2.6.3",
44 | "@types/express": "^5.0.0",
45 | "@types/jest": "^29.5.14",
46 | "@types/mocha": "^10.0.10",
47 | "aws-sdk-client-mock": "^4.1.0",
48 | "chai": "^5.1.2",
49 | "chai-subset": "^1.6.0",
50 | "colors": "^1.4.0",
51 | "date-fns": "^4.1.0",
52 | "docker-compose": "^1.1.0",
53 | "eslint": "^9.17.0",
54 | "is-ci": "^4.1.0",
55 | "is-port-reachable": "^3.1.0",
56 | "jest": "^29.7.0",
57 | "jest-extended": "^4.0.2",
58 | "jest-openapi": "^0.14.2",
59 | "jest-silent-reporter": "^0.6.0",
60 | "jest-watch-master": "^1.0.0",
61 | "jest-watch-repeat": "^3.0.1",
62 | "jest-watch-suspend": "^1.1.2",
63 | "jest-watch-toggle-config": "^3.0.0",
64 | "jest-watch-typeahead": "^2.2.2",
65 | "mocha": "^11.0.1",
66 | "nock": "^13.5.6",
67 | "node-notifier": "^10.0.1",
68 | "npm-check-updates": "^17.1.11",
69 | "prettier": "3.4.2",
70 | "sequelize-cli": "^6.6.2",
71 | "sinon": "^19.0.2",
72 | "ts-jest": "^29.2.5",
73 | "typescript": "^5.7.2",
74 | "umzug": "^3.8.2"
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/recipes/consumer-driven-contract-test/readme.md:
--------------------------------------------------------------------------------
1 | TBD
--------------------------------------------------------------------------------
/recipes/db-optimization/docker-compose-mongo.yml:
--------------------------------------------------------------------------------
1 | version: '3.6'
2 | services:
3 | database:
4 | image: mongo:4
5 | environment:
6 | - MONGO_INITDB_ROOT_USERNAME=myuser
7 | - MONGO_INITDB_ROOT_PASSWORD=myuserpassword
8 | # You still need to create the db with initdb file (https://hub.docker.com/_/mongo)
9 | - MONGO_INITDB_DATABASE=shop
10 | container_name: 'mongo-for-testing'
11 | ports:
12 | - '27017:27017'
13 | tmpfs: /data/db
14 |
--------------------------------------------------------------------------------
/recipes/db-optimization/docker-compose-mysql.yml:
--------------------------------------------------------------------------------
1 | version: '3.6'
2 | services:
3 | database:
4 | image: mysql:8
5 | command: --default-authentication-plugin=mysql_native_password --innodb_flush_log_at_trx_commit=0 --sync-binlog=0
6 | environment:
7 | - MYSQL_ROOT_PASSWORD=example
8 | - MYSQL_USER=myuser
9 | - MYSQL_PASSWORD=myuserpassword
10 | - MYSQL_DATABASE=shop
11 | container_name: 'mysql-for-testing'
12 | ports:
13 | - '33060:3306'
14 | # Do not use Memory engine, use RAM folder (tmpfs) instead (https://github.com/testjavascript/nodejs-integration-tests-best-practices/issues/9#issuecomment-710674437)
15 | tmpfs: /var/lib/mysql
16 | volumes:
17 | - ./mysql-init-scripts:/docker-entrypoint-initdb.d
18 |
19 |
--------------------------------------------------------------------------------
/recipes/db-optimization/docker-compose-postgres.yml:
--------------------------------------------------------------------------------
1 | version: '3.6'
2 | services:
3 | database:
4 | image: postgres:11
5 | command: postgres -c fsync=off -c synchronous_commit=off -c full_page_writes=off -c random_page_cost=1.0
6 | environment:
7 | - POSTGRES_USER=myuser
8 | - POSTGRES_PASSWORD=myuserpassword
9 | - POSTGRES_DB=shop
10 | container_name: 'postgres-for-testing'
11 | ports:
12 | - '54320:5432'
13 | tmpfs: /var/lib/postgresql/data
14 |
--------------------------------------------------------------------------------
/recipes/db-optimization/mysql-init-scripts/init.sql:
--------------------------------------------------------------------------------
1 | ALTER INSTANCE DISABLE INNODB REDO_LOG;
--------------------------------------------------------------------------------
/recipes/doc-driven-contract-test/readme.md:
--------------------------------------------------------------------------------
1 | TBD
--------------------------------------------------------------------------------
/recipes/doc-driven-contract-test/test/contract-example-with-openapi.test.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const nock = require('nock');
3 | const jestOpenAPI = require('jest-openapi').default;
4 | const {
5 | testSetup,
6 | } = require('../../../example-application/test/setup/test-file-setup');
7 |
8 | jestOpenAPI(path.join(__dirname, '../../../example-application/openapi.json'));
9 | beforeAll(async () => {
10 | await testSetup.start({
11 | startAPI: true,
12 | disableNetConnect: true,
13 | includeTokenInHttpClient: true,
14 | mockGetUserCalls: true,
15 | mockMailerCalls: true,
16 | });
17 | });
18 |
19 | beforeEach(() => {
20 | testSetup.resetBeforeEach();
21 | });
22 |
23 | afterAll(async () => {
24 | // ️️️✅ Best Practice: Clean-up resources after each run
25 | testSetup.tearDownTestFile();
26 | });
27 |
28 | describe('Verify openApi (Swagger) spec', () => {
29 | // ️️️✅ Best Practice: When testing the API contract/doc, write a test against each route and potential HTTP status
30 | describe('POST /orders', () => {
31 | test('When added a valid order and 200 was expected', async () => {
32 | nock('http://localhost/user/').get(`/1`).reply(200, {
33 | id: 1,
34 | name: 'John',
35 | });
36 | const orderToAdd = {
37 | userId: 1,
38 | productId: 2,
39 | mode: 'approved',
40 | };
41 |
42 | const receivedResponse = await testSetup
43 | .getHTTPClient()
44 | .post('/order', orderToAdd);
45 |
46 | // ️️️✅ Best Practice: When testing the API contract/doc
47 | expect(receivedResponse).toSatisfyApiSpec();
48 | });
49 |
50 | test('When an invalid order was send, then error 400 is expected', async () => {
51 | // Arrange
52 | nock('http://localhost/user/').get(`/1`).reply(200, {
53 | id: 1,
54 | name: 'John',
55 | });
56 | const orderToAdd = {
57 | userId: 1,
58 | productId: undefined, //❌
59 | mode: 'approved',
60 | };
61 |
62 | // Act
63 | const receivedResponse = await testSetup
64 | .getHTTPClient()
65 | .post('/order', orderToAdd);
66 |
67 | // Assert
68 | expect(receivedResponse).toSatisfyApiSpec();
69 | expect(receivedResponse.status).toBe(400);
70 | });
71 |
72 | test('When a call to the users microservice fails, then get back 404 error', async () => {
73 | nock('http://localhost/user/').get(`/1`).reply(404);
74 | const orderToAdd = {
75 | userId: 1,
76 | productId: 2,
77 | mode: 'approved',
78 | };
79 |
80 | const res = await testSetup.getHTTPClient().post('/order', orderToAdd);
81 |
82 | expect(res).toSatisfyApiSpec();
83 | });
84 | });
85 | });
86 |
--------------------------------------------------------------------------------
/recipes/friendly-structure-for-reporting/The Shop Requirements.docx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/recipes/friendly-structure-for-reporting/The Shop Requirements.docx
--------------------------------------------------------------------------------
/recipes/friendly-structure-for-reporting/anti-pattern-flat-report.test.js:
--------------------------------------------------------------------------------
1 | // ❌ Anti-Pattern file: This exemplifies a too verbose test structure that is flat and shows no categorization to ease the visitor understanding
2 |
3 | test('When product is on sale, then apply discount', () => {});
4 |
5 | test('When premium customer and on sale, then the price is 20% off', () => {});
6 | test('When no price specified, then exception is thrown', () => {});
7 | test('When no onSale parameter specified, then exception is thrown', (done) => {
8 | setTimeout(() => {
9 | expect({ approved: true }).toMatchObject({ approved: false });
10 | done();
11 | }, 15000);
12 | }, 16000);
13 | test('When has 5% coupon and on sale, then apply 5% discount and not more', () => {});
14 | test('When no isPremium specified, then exception is thrown', () => {});
15 | test('When has 5% coupon, then apply 5% discount', () => {});
16 | test('When all input is null, then exception is thrown', () => {});
17 | test('When 2 input params are null, then throw exception', () => {});
18 | test('When not on sale or premium, then apply 0% discount', () => {});
19 | test('When product is on sale, then apply discount', () => {});
20 | test('When no price specified, then exception is thrown', () => {});
21 | test('When product is on sale, then apply discount', () => {});
22 | test('When no onSale parameter specified, then exception is thrown', () => {});
23 | test('When product is on sale, then apply discount', () => {});
24 | test('When coupon has expired, then apply 0% discount', () => {});
25 | test('When no isPremium specified, then exception is thrown', () => {});
26 | test('When product is on sale, then apply discount', () => {});
27 | test('When no onSale parameter specified, then exception is thrown', () => {});
28 | test('When product is on sale, then apply discount', () => {});
29 | test('When coupon has expired, then apply 0% discount', () => {});
30 | test('When no isPremium specified, then exception is thrown', () => {});
31 | test('When premium customer and on sale, then the price is 20% off', () => {});
32 | test('When no price specified, then exception is thrown', () => {});
33 | test('When product is on sale, then apply discount', () => {});
34 | test('When no onSale parameter specified, then exception is thrown', () => {});
35 | test('When product is on sale, then apply discount', () => {});
36 | test('When coupon has expired, then apply 0% discount', () => {});
37 | test('When no isPremium specified, then exception is thrown', () => {});
38 | test('When product is on sale, then apply discount', () => {});
39 |
--------------------------------------------------------------------------------
/recipes/friendly-structure-for-reporting/anti-pattern-installation-instructions.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/recipes/friendly-structure-for-reporting/anti-pattern-installation-instructions.png
--------------------------------------------------------------------------------
/recipes/friendly-structure-for-reporting/hierarchy-report.orders-api.test.js:
--------------------------------------------------------------------------------
1 | // ️️️✅ Best Practice: Create hierarchical test reports so the reader can understand the various categories and scenarios
2 | // before delving into all the details
3 |
4 | describe('/API', () => {
5 | describe('/orders', () => {
6 | describe('POST', () => {
7 | describe('When adding a new order', () => {
8 | describe('And its valid', () => {
9 | test('Then return approval confirmation and status 200', () => {});
10 | test('Then send email to store manager', () => {});
11 | test('Then update customer credit', () => {});
12 | });
13 | describe('And the user has no credit', () => {
14 | test('Then return declined response with status 409', () => {});
15 | test('Then notify to bank', () => {});
16 | });
17 | });
18 | });
19 | describe('GET', () => {
20 | describe('When querying for order', () => {
21 | describe('And it is approved', () => {
22 | test('Then return to the caller', () => {});
23 | });
24 | describe('And it is declined', () => {
25 | test('Then return only to admin', () => {});
26 | });
27 | });
28 | });
29 | describe('DELETE', () => {
30 | describe('When deleting an order', () => {
31 | test('Then it returns confirmation with status 200', () => {});
32 | test('Then it no longer retrievable', () => {});
33 | });
34 | });
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/recipes/friendly-structure-for-reporting/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | verbose: true,
3 | //testMatch: ['**/anti-pattern-flat-report.test.js'],
4 | testMatch: ['**/anti-pattern-flat-report.test.js'],
5 | };
6 |
--------------------------------------------------------------------------------
/recipes/friendly-structure-for-reporting/shop-requirements.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goldbergyoni/nodejs-testing-best-practices/39685435c723c2e69340ca930da4b2bcf3dd5209/recipes/friendly-structure-for-reporting/shop-requirements.png
--------------------------------------------------------------------------------
/recipes/mocha/basic-mocha-tests.test.js:
--------------------------------------------------------------------------------
1 | const axios = require('axios');
2 | const chai = require('chai');
3 | const chaiSubset = require('chai-subset');
4 | const { after, afterEach, before, beforeEach, describe, it } = require('mocha');
5 | const sinon = require('sinon');
6 | const nock = require('nock');
7 | const {
8 | initializeWebServer,
9 | stopWebServer,
10 | } = require('../../example-application/entry-points/api');
11 | const OrderRepository = require('../../example-application/data-access/order-repository');
12 |
13 | // So we can use containSubset
14 | chai.use(chaiSubset);
15 | const expect = chai.expect;
16 |
17 | // Configuring file-level HTTP client with base URL will allow
18 | // all the tests to approach with a shortened syntax
19 | let axiosAPIClient;
20 |
21 | const mailRegex = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/;
22 |
23 | before(async () => {
24 | // ️️️✅ Best Practice: Place the backend under test within the same process
25 | const apiConnection = await initializeWebServer();
26 | const axiosConfig = {
27 | baseURL: `http://127.0.0.1:${apiConnection.port}`,
28 | validateStatus: () => true, // Don't throw HTTP exceptions. Delegate to the tests to decide which error is acceptable
29 | };
30 | axiosAPIClient = axios.create(axiosConfig);
31 |
32 | // ️️️✅ Best Practice: Ensure that this component is isolated by preventing unknown calls
33 | nock.disableNetConnect();
34 | nock.enableNetConnect('127.0.0.1');
35 | });
36 |
37 | beforeEach(() => {
38 | nock('http://localhost/user/').get(`/1`).reply(200, {
39 | id: 1,
40 | name: 'John',
41 | });
42 | });
43 |
44 | afterEach(() => {
45 | nock.cleanAll();
46 | sinon.restore();
47 | });
48 |
49 | after(async () => {
50 | // ️️️✅ Best Practice: Clean-up resources after each run
51 | await stopWebServer();
52 | nock.enableNetConnect();
53 | });
54 |
55 | // ️️️✅ Best Practice: Structure tests
56 | describe('/api', () => {
57 | describe('GET /order', () => {
58 | it('When asked for an existing order, Then should retrieve it and receive 200 response', async () => {
59 | //Arrange
60 | const orderToAdd = {
61 | userId: 1,
62 | productId: 2,
63 | mode: 'approved',
64 | };
65 | const {
66 | data: { id: addedOrderId },
67 | } = await axiosAPIClient.post(`/order`, orderToAdd);
68 |
69 | //Act
70 | // ️️️✅ Best Practice: Use generic and reputable HTTP client like Axios or Fetch. Avoid libraries that are coupled to
71 | // the web framework or include custom assertion syntax (e.g. Supertest)
72 | const getResponse = await axiosAPIClient.get(`/order/${addedOrderId}`);
73 |
74 | //Assert
75 | expect(getResponse).to.containSubset({
76 | status: 200,
77 | data: {
78 | userId: 1,
79 | productId: 2,
80 | mode: 'approved',
81 | },
82 | });
83 | });
84 |
85 | it('When asked for an non-existing order, Then should receive 404 response', async () => {
86 | //Arrange
87 | const nonExistingOrderId = -1;
88 |
89 | //Act
90 | const getResponse = await axiosAPIClient.get(
91 | `/order/${nonExistingOrderId}`
92 | );
93 |
94 | //Assert
95 | expect(getResponse.status).to.equal(404);
96 | });
97 | });
98 |
99 | describe('POST /orders', () => {
100 | // ️️️✅ Best Practice: Check the response
101 | it('When adding a new valid order, Then should get back approval with 200 response', async () => {
102 | //Arrange
103 | const orderToAdd = {
104 | userId: 1,
105 | productId: 2,
106 | mode: 'approved',
107 | };
108 |
109 | //Act
110 | const receivedAPIResponse = await axiosAPIClient.post(
111 | '/order',
112 | orderToAdd
113 | );
114 |
115 | //Assert
116 | expect(receivedAPIResponse).to.containSubset({
117 | status: 200,
118 | data: {
119 | mode: 'approved',
120 | },
121 | });
122 | });
123 |
124 | // ️️️✅ Best Practice: Check the new state
125 | it('When adding a new valid order, Then should be able to retrieve it', async () => {
126 | //Arrange
127 | const orderToAdd = {
128 | userId: 1,
129 | productId: 2,
130 | mode: 'approved',
131 | };
132 |
133 | //Act
134 | const {
135 | data: { id: addedOrderId },
136 | } = await axiosAPIClient.post('/order', orderToAdd);
137 |
138 | //Assert
139 | const { data, status } = await axiosAPIClient.get(
140 | `/order/${addedOrderId}`
141 | );
142 |
143 | expect({
144 | data,
145 | status,
146 | }).to.containSubset({
147 | status: 200,
148 | data: {
149 | id: addedOrderId,
150 | userId: 1,
151 | productId: 2,
152 | },
153 | });
154 | });
155 |
156 | // ️️️✅ Best Practice: Check external calls
157 | it('When adding a new valid order, Then an email should be send to admin', async () => {
158 | //Arrange
159 | process.env.SEND_MAILS = 'true';
160 |
161 | // ️️️✅ Best Practice: Intercept requests for 3rd party services to eliminate undesired side effects like emails or SMS
162 | // ️️️✅ Best Practice: Specify the body when you need to make sure you call the 3rd party service as expected
163 | let emailPayload;
164 | nock('http://mailer.com')
165 | .post('/send', (payload) => ((emailPayload = payload), true))
166 | .reply(202);
167 |
168 | const orderToAdd = {
169 | userId: 1,
170 | productId: 2,
171 | mode: 'approved',
172 | };
173 |
174 | //Act
175 | await axiosAPIClient.post('/order', orderToAdd);
176 |
177 | //Assert
178 | // ️️️✅ Best Practice: Assert that the app called the mailer service appropriately
179 | const { subject, body, recipientAddress } = emailPayload;
180 | expect(subject).to.be.a('string');
181 | expect(body).to.be.a('string');
182 | expect(mailRegex.test(recipientAddress)).to.equal(true);
183 | });
184 |
185 | // ️️️✅ Best Practice: Check invalid input
186 | it('When adding an order without specifying product, stop and return 400', async () => {
187 | //Arrange
188 | const orderToAdd = {
189 | userId: 1,
190 | mode: 'draft',
191 | };
192 |
193 | //Act
194 | const orderAddResult = await axiosAPIClient.post('/order', orderToAdd);
195 |
196 | //Assert
197 | expect(orderAddResult.status).to.equal(400);
198 | });
199 |
200 | // ️️️✅ Best Practice: Check error handling
201 | it.skip('When a new order failed, an invalid-order error was handled');
202 |
203 | // ️️️✅ Best Practice: Check monitoring metrics
204 | it.skip(
205 | 'When a new valid order was added, then order-added metric was fired'
206 | );
207 |
208 | // ️️️✅ Best Practice: Simulate external failures
209 | it.skip(
210 | 'When the user service is down, then order is still added successfully'
211 | );
212 |
213 | it('When the user does not exist, return 404 response', async () => {
214 | //Arrange
215 | nock('http://localhost/user/').get(`/7`).reply(404, null);
216 | const orderToAdd = {
217 | userId: 7,
218 | productId: 2,
219 | mode: 'draft',
220 | };
221 |
222 | //Act
223 | const orderAddResult = await axiosAPIClient.post('/order', orderToAdd);
224 |
225 | //Assert
226 | expect(orderAddResult.status).to.equal(404);
227 | });
228 |
229 | it('When order failed, send mail to admin', async () => {
230 | //Arrange
231 | process.env.SEND_MAILS = 'true';
232 | // ️️️✅ Best Practice: Intercept requests for 3rd party services to eliminate undesired side effects like emails or SMS
233 | // ️️️✅ Best Practice: Specify the body when you need to make sure you call the 3rd party service as expected
234 | let emailPayload;
235 | nock('http://mailer.com')
236 | .post('/send', (payload) => ((emailPayload = payload), true))
237 | .reply(202);
238 |
239 | sinon
240 | .stub(OrderRepository.prototype, 'addOrder')
241 | .throws(new Error('Unknown error'));
242 | const orderToAdd = {
243 | userId: 1,
244 | productId: 2,
245 | mode: 'approved',
246 | };
247 |
248 | //Act
249 | await axiosAPIClient.post('/order', orderToAdd);
250 |
251 | //Assert
252 | // ️️️✅ Best Practice: Assert that the app called the mailer service appropriately
253 | const { subject, body, recipientAddress } = emailPayload;
254 | expect(subject).to.be.a('string');
255 | expect(body).to.be.a('string');
256 | expect(mailRegex.test(recipientAddress)).to.equal(true);
257 | });
258 | });
259 | });
260 |
--------------------------------------------------------------------------------
/recipes/mocha/hooks.js:
--------------------------------------------------------------------------------
1 | const setup = require('../../example-application/test/setup/global-setup.js');
2 | const teardown = require('../../example-application/test/setup/global-teardown.js');
3 |
4 | exports.mochaGlobalSetup = async () => {
5 | await setup();
6 | };
7 |
8 | exports.mochaGlobalTeardown = async () => {
9 | await teardown();
10 | };
11 |
--------------------------------------------------------------------------------
/recipes/nestjs/app/app.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get } from '@nestjs/common';
2 |
3 | @Controller()
4 | export class AppController {
5 | constructor() {}
6 |
7 | @Get('/hello')
8 | getHello() {
9 | // ️️️Here we just exemplifies a simple route and the setup of Nest.js.
10 | // To learn about testing patterns of real-world app, look at the main example under "example-application" folder
11 | return {
12 | greeting: 'Testing is fun!'
13 | };
14 | }
15 | }
--------------------------------------------------------------------------------
/recipes/nestjs/app/app.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { AppController } from './app.controller';
3 |
4 | @Module({
5 | imports: [],
6 | controllers: [AppController],
7 | })
8 | export class AppModule {};
9 |
--------------------------------------------------------------------------------
/recipes/nestjs/main.ts:
--------------------------------------------------------------------------------
1 | import { INestApplication, ValidationPipe } from "@nestjs/common";
2 | import { NestFactory } from "@nestjs/core";
3 | import { AppModule } from "./app/app.module";
4 |
5 | let app: INestApplication;
6 |
7 | export const initializeWebServer = async () => {
8 | app = await NestFactory.create(AppModule);
9 |
10 | // ➿ Port is required in Nest.js, we can send 0 to get a available dynamic port
11 | await app.listen(0);
12 |
13 | return app.getHttpServer().address();
14 | };
15 |
16 | export const stopWebServer = async () => {
17 | return await app.close();
18 | };
--------------------------------------------------------------------------------
/recipes/nestjs/test/basic-nest-tests.test.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosInstance } from 'axios';
2 | import * as nock from 'nock';
3 | import { initializeWebServer, stopWebServer } from '../main';
4 |
5 | // Configuring file-level HTTP client with base URL will allow
6 | // all the tests to approach with a shortened syntax
7 | let axiosAPIClient: AxiosInstance;
8 |
9 | beforeAll(async () => {
10 | // ️️️✅ Best Practice: Place the backend under test within the same process
11 | const apiConnection = await initializeWebServer();
12 | // ️️️✅ Best Practice: Ensure that this component is isolated by preventing unknown calls
13 | nock.disableNetConnect();
14 | nock.enableNetConnect('127.0.0.1');
15 |
16 | const axiosConfig = {
17 | baseURL: `http://127.0.0.1:${apiConnection.port}`,
18 | validateStatus: () => true, //Don't throw HTTP exceptions. Delegate to the tests to decide which error is acceptable
19 | };
20 | axiosAPIClient = axios.create(axiosConfig);
21 | });
22 |
23 | afterEach(() => {
24 | nock.cleanAll();
25 | });
26 |
27 | afterAll(async () => {
28 | // ️️️✅ Best Practice: Clean-up resources after each run
29 | await stopWebServer();
30 | nock.enableNetConnect();
31 | });
32 |
33 | // ️️️Here we just exemplifies a simple route and the setup of Nest.js.
34 | // To learn about testing patterns of real-world app, look at the main example under "example-application/test" folder
35 | describe('/api', () => {
36 | describe('GET /hello', () => {
37 | test('When request, Then should return hello', async () => {
38 | //Act
39 | const getResponse = await axiosAPIClient.get('/hello');
40 |
41 | //Assert
42 | expect(getResponse).toMatchObject({
43 | status: 200,
44 | data: {
45 | greeting: 'Testing is fun!',
46 | },
47 | });
48 | });
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/recipes/nestjs/ts-jest.config.js:
--------------------------------------------------------------------------------
1 | const config = require('../../jest.config');
2 |
3 | module.exports = {
4 | ...config,
5 | testMatch: ['**/*.test.ts'],
6 | preset: 'ts-jest',
7 | rootDir: '../../',
8 | testEnvironment: 'node',
9 | globals: {
10 | 'ts-jest': {
11 | tsconfig: 'recipes/nestjs/tsconfig.json',
12 | },
13 | },
14 | types: ['jest', 'jest-extended'],
15 | };
16 |
--------------------------------------------------------------------------------
/recipes/nestjs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2019",
4 | "module": "commonjs",
5 | "experimentalDecorators": true,
6 | "outDir": "dist",
7 | "allowJs": true,
8 | "types": [
9 | "node",
10 | "jest"
11 | ],
12 | }
13 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig to read more about this file */
4 |
5 | /* Projects */
6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12 |
13 | /* Language and Environment */
14 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
16 | // "jsx": "preserve", /* Specify what JSX code is generated. */
17 | "experimentalDecorators": true /* Enable experimental support for TC39 stage 2 draft decorators. */,
18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
26 |
27 | /* Modules */
28 | "module": "commonjs" /* Specify what module code is generated. */,
29 | // "rootDir": "./", /* Specify the root folder within your source files. */
30 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */
36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
38 | // "resolveJsonModule": true, /* Enable importing .json files. */
39 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
40 |
41 | /* JavaScript Support */
42 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */,
43 | "checkJs": true /* Enable error reporting in type-checked JavaScript files. */,
44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
45 |
46 | /* Emit */
47 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */
49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
52 | // "outDir": "./", /* Specify an output folder for all emitted files. */
53 | // "removeComments": true, /* Disable emitting comments. */
54 | // "noEmit": true, /* Disable emitting files from a compilation. */
55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
63 | // "newLine": "crlf", /* Set the newline character for emitting files. */
64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
70 |
71 | /* Interop Constraints */
72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
74 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
76 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
77 |
78 | /* Type Checking */
79 | "strict": true /* Enable all strict type-checking options. */,
80 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
81 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
82 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
83 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
85 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
86 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
88 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
93 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
98 |
99 | /* Completeness */
100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */
102 | }
103 | }
104 |
--------------------------------------------------------------------------------