├── .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 | --------------------------------------------------------------------------------