├── .gitignore ├── src ├── services │ ├── index.ts │ └── postService.ts ├── controllers │ ├── index.ts │ └── postController.ts ├── entities │ ├── index.ts │ ├── Image.ts │ └── Post.ts ├── index.ts └── startServer.ts ├── docker-compose.yml ├── jest.unit.config.js ├── jest.e2e.config.js ├── test ├── e2e │ ├── callSetup.ts │ ├── seed.ts │ └── test.postController.ts ├── utils.ts └── unit │ ├── jest │ ├── test.getAll.ts │ ├── test.getById.ts │ ├── test.create.ts │ └── test.update.ts │ └── mocha │ ├── test.getById.ts │ ├── test.create.ts │ ├── test.getAll.ts │ └── test.update.ts ├── .travis.yml ├── tslint.json ├── LICENSE ├── tsconfig.json ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './postService' 2 | -------------------------------------------------------------------------------- /src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './postController' 2 | -------------------------------------------------------------------------------- /src/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Image' 2 | export * from './Post' 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import startServer from './startServer' 2 | 3 | startServer() 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | postgres: 4 | image: 'postgres' 5 | ports: 6 | - '5432:5432' 7 | environment: 8 | POSTGRES_USER: 'test' 9 | POSTGRES_PASSWORD: 'test' 10 | POSTGRES_DB: 'test' 11 | -------------------------------------------------------------------------------- /jest.unit.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | rootDir: '.', 4 | preset: 'ts-jest', 5 | testMatch: ['././build/test/unit/jest/test.*.js'], 6 | moduleFileExtensions: ['ts', 'js', 'json'], 7 | testEnvironment: 'node', 8 | clearMocks: true 9 | } 10 | -------------------------------------------------------------------------------- /jest.e2e.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: false, 3 | rootDir: '.', 4 | preset: 'ts-jest', 5 | globalSetup: './build/test/e2e/callSetup.js', 6 | testMatch: ['/test/e2e/test.*.ts'], 7 | moduleFileExtensions: ['ts', 'js', 'json'], 8 | testEnvironment: 'node', 9 | clearMocks: true 10 | } 11 | -------------------------------------------------------------------------------- /test/e2e/callSetup.ts: -------------------------------------------------------------------------------- 1 | import startServer from '../../src/startServer' 2 | import { AddressInfo } from 'net' 3 | 4 | module.exports = async (): Promise => { 5 | const app = await startServer(true) 6 | const { port } = app.address() as AddressInfo 7 | 8 | process.env.TEST_HOST = `http://localhost:${port}/api` 9 | } 10 | -------------------------------------------------------------------------------- /src/entities/Image.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm' 2 | import { Post } from './index' 3 | 4 | @Entity() 5 | export class Image { 6 | @PrimaryGeneratedColumn() 7 | id: number 8 | 9 | @Column() 10 | url: string 11 | 12 | @Column({ default: false }) 13 | archived: boolean 14 | 15 | @ManyToOne(_ => Post, post => post.images) 16 | post: Post 17 | } 18 | -------------------------------------------------------------------------------- /src/entities/Post.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm' 2 | import { Image } from './index' 3 | 4 | @Entity() 5 | export class Post { 6 | @PrimaryGeneratedColumn() 7 | id: number 8 | 9 | @Column() 10 | title: string 11 | 12 | @Column({ default: false }) 13 | archived: boolean 14 | 15 | @OneToMany(_ => Image, image => image.post, { nullable: true }) 16 | images: Image[] 17 | } 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | # - stable 4 | - 12 5 | - 10 6 | - 8 7 | 8 | services: 9 | - docker 10 | 11 | install: 12 | - npm install ci 13 | 14 | before_script: 15 | - sudo service postgresql stop 16 | - docker-compose up -d 17 | 18 | script: 19 | - npm run lint 20 | - npm run build 21 | - npm run test:jest 22 | - npm run test:mocha 23 | # - npm run test:e2e 24 | 25 | notifications: 26 | email: false 27 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:latest"], 3 | "rules": { 4 | "no-return-await": false, 5 | "semicolon": false, 6 | "no-shadowed-variable": false, 7 | "prefer-object-spread": false, 8 | "quotemark": [true, "single"], 9 | "trailing-comma": false, 10 | "no-implicit-dependencies": [true, "dev"], 11 | "ordered-imports": false, 12 | "object-literal-sort-keys": false, 13 | "no-console": false, 14 | "member-access": false, 15 | "arrow-parens": false 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | export const images = [ 2 | { 3 | id: 1, 4 | url: 'http://1', 5 | archived: false 6 | }, 7 | { 8 | id: 2, 9 | url: 'http://2', 10 | archived: false 11 | } 12 | ] 13 | 14 | export const post = { 15 | id: 1, 16 | title: 'just a title', 17 | archived: false 18 | } 19 | 20 | // @todo tslint check 21 | export const helpers = { 22 | omit: (targetObj: object, arr: string[]) => Object.entries(targetObj) 23 | .filter(([key]) => !arr.includes(key)) 24 | .reduce((targetObj, [key, val]) => Object.assign(targetObj, { [key]: val }), {}) 25 | } 26 | -------------------------------------------------------------------------------- /test/e2e/seed.ts: -------------------------------------------------------------------------------- 1 | import { images, post } from '../utils' 2 | import { Post, Image } from '../../src/entities' 3 | import { getRepository } from 'typeorm' 4 | 5 | export default async (): Promise => { 6 | try { 7 | const createdPost = await getRepository(Post).save(post) 8 | 9 | const newImage1 = new Image() 10 | Object.assign(newImage1, { ...images[0], post: createdPost }) 11 | await getRepository(Image).save(newImage1) 12 | 13 | const newImage2 = new Image() 14 | Object.assign(newImage2, { ...images[1], post: createdPost }) 15 | await getRepository(Image).save(newImage2) 16 | } catch { 17 | throw new Error('seeding is failed') 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Yegor <3 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/unit/jest/test.getAll.ts: -------------------------------------------------------------------------------- 1 | import { postService } from '../../../src/services' 2 | import { Post } from '../../../src/entities' 3 | 4 | import typeorm = require('typeorm') 5 | 6 | describe('postService => getAll', () => { 7 | it('getAll method passed', async () => { 8 | const fakeQueryBuilder = jest.fn().mockReturnValue({ 9 | leftJoinAndSelect: jest.fn().mockReturnThis(), 10 | orderBy: jest.fn().mockReturnThis(), 11 | getMany: jest.fn().mockResolvedValue('0x0') 12 | }) 13 | 14 | typeorm.getConnection = jest.fn().mockReturnValue({ 15 | getRepository: jest.fn().mockReturnValue({ createQueryBuilder: fakeQueryBuilder }) 16 | }) 17 | const result = await postService.getAll() 18 | 19 | expect(result).toEqual('0x0') 20 | 21 | const queryBuilder = typeorm.getConnection().getRepository(Post).createQueryBuilder 22 | expect(queryBuilder).toHaveBeenNthCalledWith(1, 'post') 23 | expect(queryBuilder().leftJoinAndSelect).toHaveBeenNthCalledWith(1, 'post.images', 'image') 24 | expect(queryBuilder().orderBy).toHaveBeenNthCalledWith(1, { post: 'ASC', image: 'ASC' }) 25 | expect(queryBuilder().getMany).toHaveBeenNthCalledWith(1) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "moduleResolution": "node", 6 | "outDir": "build", 7 | "sourceMap": true, 8 | "experimentalDecorators": true, 9 | "emitDecoratorMetadata": true, 10 | "esModuleInterop": true, 11 | "noUnusedLocals": true /* Report errors on unused locals. */, 12 | "noUnusedParameters": true /* Report errors on unused parameters. */, 13 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 14 | "strict": true, /* Enable all strict type-checking options. */ 15 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 16 | "strictNullChecks": false, 17 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 18 | "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 19 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 20 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */ 21 | }, 22 | "exclude": ["build", "node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /test/unit/jest/test.getById.ts: -------------------------------------------------------------------------------- 1 | import { postService } from '../../../src/services' 2 | import { post } from '../../utils' 3 | import { Post } from '../../../src/entities' 4 | 5 | import typeorm = require('typeorm') 6 | 7 | describe('postService => getById', () => { 8 | it('getById method passed', async () => { 9 | typeorm.createQueryBuilder = jest.fn().mockReturnValue({ 10 | select: jest.fn().mockReturnThis(), 11 | from: jest.fn().mockReturnThis(), 12 | leftJoinAndSelect: jest.fn().mockReturnThis(), 13 | where: jest.fn().mockReturnThis(), 14 | orderBy: jest.fn().mockReturnThis(), 15 | getOne: jest.fn().mockResolvedValue('0x0') 16 | }) 17 | const result = await postService.getById(post.id) 18 | 19 | expect(result).toEqual('0x0') 20 | 21 | const qBuilder = typeorm.createQueryBuilder() 22 | expect(qBuilder.select).toHaveBeenNthCalledWith(1, ['post']) 23 | expect(qBuilder.from).toHaveBeenNthCalledWith(1, Post, 'post') 24 | expect(qBuilder.leftJoinAndSelect).toHaveBeenNthCalledWith(1, 'post.images', 'image') 25 | expect(qBuilder.where).toHaveBeenNthCalledWith(1, 'post.id = :id', { id: post.id }) 26 | expect(qBuilder.orderBy).toHaveBeenNthCalledWith(1, { image: 'ASC' }) 27 | expect(qBuilder.getOne).toHaveBeenNthCalledWith(1) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /test/unit/mocha/test.getById.ts: -------------------------------------------------------------------------------- 1 | import { createStubInstance, createSandbox, SinonSandbox } from 'sinon' 2 | import * as typeorm from 'typeorm' 3 | import assert from 'assert' 4 | 5 | import { postService } from '../../../src/services' 6 | import { post } from '../../utils' 7 | import { Post } from '../../../src/entities' 8 | 9 | describe('postService => getById', () => { 10 | let sandbox: SinonSandbox 11 | 12 | beforeEach(() => { 13 | sandbox = createSandbox() 14 | }) 15 | 16 | afterEach(() => { 17 | sandbox.restore() 18 | }) 19 | 20 | it('getById method passed', async () => { 21 | const fakeQueryBuilder = createStubInstance(typeorm.SelectQueryBuilder) 22 | 23 | fakeQueryBuilder.select.withArgs(['post']).returnsThis() 24 | fakeQueryBuilder.from.withArgs(Post, 'post').returnsThis() 25 | fakeQueryBuilder.leftJoinAndSelect.withArgs('post.images', 'image').returnsThis() 26 | fakeQueryBuilder.where.withArgs('post.id = :id', { id: post.id }).returnsThis() 27 | fakeQueryBuilder.orderBy.withArgs({ image: 'ASC' }).returnsThis() 28 | fakeQueryBuilder.getOne.resolves('0x0') 29 | 30 | sandbox.stub(typeorm, 'createQueryBuilder').returns(fakeQueryBuilder as any) 31 | 32 | const result = await postService.getById(post.id) 33 | assert.equal(result, '0x0') 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /src/startServer.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import express from 'express' 3 | import { createConnection, ConnectionOptions } from 'typeorm' 4 | 5 | import { Post, Image } from './entities' 6 | import startSeeding from '../test/e2e/seed' 7 | import { postController } from './controllers' 8 | import { Server } from 'http' 9 | 10 | const connectionOptions: ConnectionOptions = { 11 | type: 'postgres', 12 | host: 'localhost', 13 | port: 5432, 14 | username: 'test', 15 | password: 'test', 16 | database: 'test', 17 | entities: [Post, Image], 18 | logging: false, 19 | synchronize: true, 20 | dropSchema: false 21 | } 22 | 23 | const router = express.Router() 24 | router.use('/posts', postController) 25 | 26 | export default async (E2E_TEST: boolean = false): Promise => { 27 | const app = express() 28 | app.use(express.json(), express.urlencoded({ extended: true })) 29 | app.use('/api', router) 30 | 31 | try { 32 | await createConnection({ ...connectionOptions, dropSchema: E2E_TEST }) 33 | if (E2E_TEST) { 34 | await startSeeding() 35 | } 36 | 37 | const PORT = 3000 38 | return app.listen(PORT, () => console.log(`Example app listening on port ${PORT}!`)) 39 | } catch { 40 | throw new Error('Launch postgres via this command: "docker-compose up -d"') 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/unit/mocha/test.create.ts: -------------------------------------------------------------------------------- 1 | import { createSandbox, SinonSandbox, spy } from 'sinon' 2 | import * as typeorm from 'typeorm' 3 | import assert from 'assert' 4 | 5 | import { postService, PostServiceDataToCreate } from '../../../src/services' 6 | import { post, images } from '../../utils' 7 | 8 | describe('postService => create', () => { 9 | let sandbox: SinonSandbox 10 | 11 | beforeEach(() => { 12 | sandbox = createSandbox() 13 | }) 14 | 15 | afterEach(() => { 16 | sandbox.restore() 17 | }) 18 | 19 | it('create post with images passed', async () => { 20 | const spyOnSave = spy(() => Promise.resolve(post)) 21 | sandbox.stub(typeorm, 'getRepository').returns({ save: spyOnSave } as any) 22 | 23 | // @ts-ignore @docs Yeah, it's disaster, but I have already done tests for _findPostById() method (getById()) 24 | sandbox.stub(postService, '_findPostById').resolves('0x0') 25 | const dataToCreate: PostServiceDataToCreate = { ...post, images } 26 | const result = await postService.create(dataToCreate) 27 | 28 | assert.equal(result, '0x0') 29 | assert.deepEqual(spyOnSave.callCount, 3) 30 | assert.deepEqual(spyOnSave.getCall(0).args, [post]) 31 | assert.deepEqual(spyOnSave.getCall(1).args, [{ ...images[0], post }]) 32 | assert.deepEqual(spyOnSave.getCall(2).args, [{ ...images[1], post }]) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/unit/jest/test.create.ts: -------------------------------------------------------------------------------- 1 | import { postService, PostServiceDataToCreate } from '../../../src/services' 2 | import { post, images } from '../../utils' 3 | 4 | import typeorm = require('typeorm') 5 | import { Post, Image } from '../../../src/entities' 6 | 7 | describe('postService => create', () => { 8 | it('create post with images passed', async () => { 9 | // @ts-ignore @docs Yeah, it's disaster, but I have already done tests for _findPostById() method (getById()) 10 | postService._findPostById = jest.fn().mockResolvedValue('0x0') 11 | 12 | typeorm.getRepository = jest.fn().mockReturnValue({ 13 | save: jest.fn().mockResolvedValue(post) 14 | }) 15 | 16 | const dataToCreate: PostServiceDataToCreate = { ...post, images } 17 | const result = await postService.create(dataToCreate) 18 | 19 | expect(result).toEqual('0x0') 20 | 21 | expect(typeorm.getRepository).toHaveBeenNthCalledWith(1, Post) 22 | expect(typeorm.getRepository(Post).save).toHaveBeenNthCalledWith(1, post) 23 | 24 | expect(typeorm.getRepository).toHaveBeenNthCalledWith(2, Image) 25 | expect(typeorm.getRepository(Image).save).toHaveBeenNthCalledWith(2, { ...images[0], post }) 26 | 27 | expect(typeorm.getRepository).toHaveBeenNthCalledWith(3, Image) 28 | expect(typeorm.getRepository(Image).save).toHaveBeenNthCalledWith(3, { ...images[1], post }) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /test/unit/mocha/test.getAll.ts: -------------------------------------------------------------------------------- 1 | import { createStubInstance, createSandbox, SinonSandbox } from 'sinon' 2 | import * as typeorm from 'typeorm' 3 | import assert from 'assert' 4 | 5 | import { postService } from '../../../src/services/postService' 6 | import { Post } from '../../../src/entities' 7 | 8 | describe('postService => getAll', () => { 9 | let sandbox: SinonSandbox 10 | 11 | beforeEach(() => { 12 | sandbox = createSandbox() 13 | }) 14 | 15 | afterEach(() => { 16 | sandbox.restore() 17 | }) 18 | 19 | it('getAll method passed', async () => { 20 | const fakeRepository = createStubInstance(typeorm.Repository) 21 | const fakeQueryBuilder = createStubInstance(typeorm.SelectQueryBuilder) 22 | const fakeConnection = createStubInstance(typeorm.Connection) 23 | 24 | fakeQueryBuilder.leftJoinAndSelect.withArgs('post.images', 'image').returnsThis() 25 | fakeQueryBuilder.orderBy.withArgs({ post: 'ASC', image: 'ASC' }).returnsThis() 26 | fakeQueryBuilder.getMany.resolves(['0x0']) 27 | 28 | fakeRepository.createQueryBuilder.withArgs('post').returns(fakeQueryBuilder as any) 29 | fakeConnection.getRepository.withArgs(Post).returns(fakeRepository as any) 30 | 31 | sandbox.stub(typeorm, 'getConnection').returns(fakeConnection as any) 32 | 33 | const result = await postService.getAll() 34 | assert.deepEqual(result, ['0x0']) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /src/controllers/postController.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response, Router } from 'express' 2 | import { postService } from '../services' 3 | 4 | const APIError = { 5 | notFound: (id: string) => ({ response: { error: true, message: `The post was not found by id: ${id}` } }) 6 | } 7 | 8 | export const postController = Router() 9 | 10 | postController.route('/').get(async (_, res: Response, next: NextFunction) => { 11 | try { 12 | res.status(200).json({ response: await postService.getAll() }) 13 | } catch (error) { 14 | next(error) 15 | } 16 | }) 17 | 18 | postController.route('/:id').get(async (req: Request, res: Response, next: NextFunction) => { 19 | try { 20 | const response = await postService.getById(Number(req.params.id)) 21 | response ? res.status(200).json({ response }) : res.status(404).json(APIError.notFound(req.params.id)) 22 | } catch (error) { 23 | next(error) 24 | } 25 | }) 26 | 27 | postController.route('/').post(async (req: Request, res: Response, next: NextFunction) => { 28 | try { 29 | res.status(200).json({ response: await postService.create(req.body) }) 30 | } catch (error) { 31 | next(error) 32 | } 33 | }) 34 | 35 | postController.route('/:id').put(async (req: Request, res: Response, next: NextFunction) => { 36 | try { 37 | const response = await postService.update({ id: Number(req.params.id), ...req.body }) 38 | 39 | response ? res.status(200).json({ response: 'OK' }) : res.status(404).json(APIError.notFound(req.params.id)) 40 | } catch (error) { 41 | next(error) 42 | } 43 | }) 44 | -------------------------------------------------------------------------------- /test/unit/jest/test.update.ts: -------------------------------------------------------------------------------- 1 | import { postService, IPostServiceDataToUpdate } from '../../../src/services' 2 | import { post, images, helpers } from '../../utils' 3 | 4 | import typeorm = require('typeorm') 5 | import { Post, Image } from '../../../src/entities' 6 | 7 | describe('postService => update', () => { 8 | it('update post with new images and existed images passed', async () => { 9 | // @ts-ignore @docs Yeah, it's disaster, but I have already done tests for _findPostById() method (getById()) 10 | postService._findPostById = jest.fn().mockResolvedValue({...post, images}) 11 | 12 | typeorm.getRepository = jest.fn().mockReturnValue({ 13 | save: jest.fn().mockResolvedValue('0x0') 14 | }) 15 | 16 | const data: IPostServiceDataToUpdate = { 17 | id: 1, 18 | title: 'updated title', 19 | archived: true, 20 | images: [ 21 | { 22 | id: 0, 23 | url: 'https://new-url', 24 | archived: true 25 | }, 26 | { 27 | id: images[0].id, 28 | url: 'updated url', 29 | archived: true 30 | } 31 | ] 32 | } 33 | 34 | const result = await postService.update(data) 35 | 36 | expect(result).toEqual(true) 37 | 38 | expect(typeorm.getRepository).toHaveBeenNthCalledWith(1, Image) 39 | // @todo 1st call, re-write data? 40 | 41 | expect(typeorm.getRepository).toHaveBeenNthCalledWith(2, Image) 42 | expect(typeorm.getRepository(Image).save).toHaveBeenNthCalledWith(2, data.images[1]) 43 | 44 | expect(typeorm.getRepository).toHaveBeenNthCalledWith(3, Post) 45 | const justUpdatedPost = helpers.omit(data, ['images']) 46 | expect(typeorm.getRepository(Post).save) 47 | .toHaveBeenNthCalledWith(3, { ...justUpdatedPost, images: [...images, '0x0'] }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /test/unit/mocha/test.update.ts: -------------------------------------------------------------------------------- 1 | import { createSandbox, SinonSandbox } from 'sinon' 2 | import * as typeorm from 'typeorm' 3 | import assert from 'assert' 4 | 5 | import { postService, IPostServiceDataToUpdate } from '../../../src/services' 6 | import { images, post, helpers } from '../../utils' 7 | 8 | describe('postService => update', () => { 9 | let sandbox: SinonSandbox 10 | 11 | beforeEach(() => { 12 | sandbox = createSandbox() 13 | }) 14 | 15 | afterEach(() => { 16 | sandbox.restore() 17 | }) 18 | 19 | it('update post with new images and existed images passed', async () => { 20 | const spyOnSave = sandbox.spy(() => Promise.resolve('0x0')) 21 | sandbox.stub(typeorm, 'getRepository').returns({ save: spyOnSave } as any) 22 | 23 | // @ts-ignore @docs Yeah, it's disaster, but I have already done tests for _findPostById() method (getById()) 24 | sandbox.stub(postService, '_findPostById').resolves({...post, images}) 25 | 26 | const data: IPostServiceDataToUpdate = { 27 | id: 1, 28 | title: 'updated title', 29 | archived: true, 30 | images: [ 31 | { 32 | id: 0, 33 | url: 'https://new-url', 34 | archived: true 35 | }, 36 | { 37 | id: images[0].id, 38 | url: 'updated url', 39 | archived: true 40 | } 41 | ] 42 | } 43 | 44 | assert(await postService.update(data)) 45 | 46 | assert.equal(spyOnSave.callCount, 3) 47 | 48 | const firstCall = spyOnSave.getCall(0).args as any[] 49 | const justUpdatedImage = helpers.omit(firstCall[0], ['post']) // @todo re-write data? 50 | assert.deepEqual(justUpdatedImage, data.images[0]) // save new image 51 | 52 | assert.deepEqual(spyOnSave.getCall(1).args, [data.images[1]]) // update existed image 53 | 54 | const justUpdatedPost = helpers.omit(data, ['images']) 55 | assert.deepEqual(spyOnSave.getCall(2).args, [{ ...justUpdatedPost, images: [...images, '0x0'] }]) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typeorm-mock-unit-testing-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.ts", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "start": "ts-node src/index.ts", 11 | "build": "rimraf build && tsc", 12 | "test": "npm run test:mocha && npm run test:jest", 13 | "lint": "npm run lint:code && npm run lint:commit", 14 | "test:jest": "rimraf build && tsc && jest -c jest.unit.config.js", 15 | "test:mocha": "rimraf build && tsc && mocha build/test/unit/mocha/test.*.js", 16 | "test:e2e": "rimraf build && tsc && jest -c jest.e2e.config.js --forceExit --runInBand --detectOpenHandles --coverage --coverageReporters=text-lcov", 17 | "lint:code": "tslint -c tslint.json -p tsconfig.json", 18 | "lint:commit": "echo \"module.exports = {extends: ['@commitlint/config-conventional']}\" > commitlint.config.js && commitlint . && rimraf commitlint.config.js" 19 | }, 20 | "engines": { 21 | "node": ">=8.0.0" 22 | }, 23 | "keywords": [ 24 | "NodeJS", 25 | "TypeScript", 26 | "Express", 27 | "TypeORM", 28 | "Jest", 29 | "Mocha", 30 | "Sinon", 31 | "unit", 32 | "tests", 33 | "unit testing" 34 | ], 35 | "author": "YegorZaremba ", 36 | "dependencies": { 37 | "express": "4.17.1", 38 | "pg": "7.11.0", 39 | "reflect-metadata": "0.1.13", 40 | "typeorm": "0.2.19" 41 | }, 42 | "devDependencies": { 43 | "@commitlint/cli": "^8.1.0", 44 | "@commitlint/config-conventional": "^8.1.0", 45 | "@types/express": "4.17.0", 46 | "@types/jest": "24.0.15", 47 | "@types/node": "12.0.10", 48 | "@types/request-promise": "4.1.44", 49 | "@types/sinon": "7.0.13", 50 | "coveralls": "3.0.4", 51 | "jest": "24.8.0", 52 | "mocha": "6.1.4", 53 | "request": "2.88.0", 54 | "request-promise": "4.2.4", 55 | "rimraf": "2.6.3", 56 | "sinon": "7.3.2", 57 | "ts-jest": "24.0.2", 58 | "ts-node": "8.3.0", 59 | "tslint": "5.18.0", 60 | "typescript": "3.6.3" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/services/postService.ts: -------------------------------------------------------------------------------- 1 | import { Post, Image } from '../entities' 2 | import { getConnection, getRepository, createQueryBuilder } from 'typeorm' 3 | 4 | type PostServiceImageData = Omit 5 | 6 | export interface IPostServiceDataToUpdate { 7 | id: number 8 | title: string 9 | archived: boolean 10 | images?: PostServiceImageData[] 11 | } 12 | 13 | export type PostServiceDataToCreate = Omit 14 | 15 | class PostService { 16 | public getAll(): Promise { 17 | return getConnection() 18 | .getRepository(Post) 19 | .createQueryBuilder('post') 20 | .leftJoinAndSelect('post.images', 'image') 21 | .orderBy({ post: 'ASC', image: 'ASC' }) 22 | .getMany() 23 | } 24 | 25 | public getById(id: number): Promise { 26 | return this._findPostById(id) 27 | } 28 | 29 | public async create({ images, ...data }: PostServiceDataToCreate): Promise { 30 | const newPost = new Post() 31 | Object.assign(newPost, data) 32 | const post = await getRepository(Post).save(newPost) 33 | 34 | if (images && images.length) { 35 | await Promise.all(images.map(imageData => this._saveImage(imageData, post))) 36 | } 37 | 38 | return await this._findPostById(post.id) 39 | } 40 | 41 | public async update({ id, images, ...data }: IPostServiceDataToUpdate): Promise { 42 | const postToUpdate = await this._findPostById(id) 43 | if (!postToUpdate) { 44 | return false 45 | } 46 | 47 | // @todo start transaction and commit during whole `update` request 48 | 49 | // @docs If ID equals 0, we'll create new Image entity 50 | const arrayOfNewImages = await Promise.all( 51 | images.map(receivedImage => { 52 | if (receivedImage.id === 0) { 53 | return this._saveImage(receivedImage, postToUpdate) 54 | } 55 | }).filter(notUndefined => notUndefined) 56 | ) 57 | 58 | // @docs If db's ID equals received ID, we'll update Image entity 59 | const arrayOfUpdatedImages: Image[] = await Promise.all( 60 | postToUpdate.images.map(image => { 61 | images.forEach(receivedImage => { 62 | if (receivedImage.id === image.id) { 63 | getRepository(Image).save(receivedImage) 64 | } 65 | }) 66 | return image 67 | }) 68 | ) 69 | 70 | postToUpdate.images = [...arrayOfUpdatedImages, ...arrayOfNewImages] 71 | Object.assign(postToUpdate, data) 72 | await getRepository(Post).save(postToUpdate) 73 | 74 | return true 75 | } 76 | 77 | private _findPostById(id: number): Promise { 78 | return createQueryBuilder() 79 | .select(['post']) 80 | .from(Post, 'post') 81 | .leftJoinAndSelect('post.images', 'image') 82 | .where('post.id = :id', { id }) 83 | .orderBy({ image: 'ASC' }) 84 | .getOne() 85 | } 86 | 87 | private _saveImage(data: PostServiceImageData, post: Post): Promise { 88 | const newImage = new Image() 89 | Object.assign(newImage, { ...data, post }) 90 | 91 | return getRepository(Image).save(newImage) 92 | } 93 | } 94 | 95 | export const postService = new PostService() 96 | -------------------------------------------------------------------------------- /test/e2e/test.postController.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'request-promise' 2 | import assert from 'assert' 3 | import { post, images } from '../utils' 4 | import { Post } from '../../src/entities' 5 | 6 | interface IResponsePostType { response: Post } 7 | 8 | describe('post router', () => { 9 | it('getAll post passed', async () => { 10 | const options = { 11 | uri: `${process.env.TEST_HOST}/posts`, 12 | json: true 13 | } 14 | 15 | const { response } = await request.get(options) 16 | assert.deepEqual(response, [{...post, images}]) 17 | }) 18 | 19 | it('getById post passed', async () => { 20 | const options = { 21 | uri: `${process.env.TEST_HOST}/posts/1`, 22 | json: true 23 | } 24 | 25 | const { response } = await request.get(options) 26 | assert.deepEqual(response, {...post, images}) 27 | }) 28 | 29 | it('create post passed', async () => { 30 | const title = 'new post2' 31 | 32 | const options = { 33 | uri: `${process.env.TEST_HOST}/posts`, 34 | body: { title }, 35 | json: true 36 | } 37 | 38 | const { response } = await request.post(options) 39 | const expected: Post = { 40 | id: 2, 41 | title, 42 | images: [], 43 | archived: false 44 | } 45 | 46 | assert.deepEqual(response, expected) 47 | }) 48 | 49 | it('create post with images passed', async () => { 50 | const title = 'new post3' 51 | 52 | const options = { 53 | uri: `${process.env.TEST_HOST}/posts`, 54 | body: { title, images }, 55 | json: true 56 | } 57 | 58 | const { response: { images: imageResult, ...postResult } } = await request.post(options) as IResponsePostType 59 | 60 | const expectedPost = { 61 | id: 3, 62 | title, 63 | archived: false 64 | } 65 | 66 | assert.deepEqual(postResult, expectedPost) 67 | assert(imageResult.map(({url}) => url).sort().join(' ') === images.map(({url}) => url).sort().join(' ')) 68 | }) 69 | 70 | it('update post passed', async () => { 71 | const id = 3 72 | const title = 'updated post' 73 | const url = 'updated url' 74 | 75 | const optionsGET1 = { 76 | uri: `${process.env.TEST_HOST}/posts/${id}`, 77 | json: true 78 | } 79 | 80 | const { response: response1 } = await request.get(optionsGET1) as IResponsePostType 81 | assert.notEqual(response1.title, title) 82 | assert.notEqual(response1.images[0].url, url) 83 | assert(response1.images.map(({ url }) => url).sort().join(' ') === images.map(({ url }) => url).sort().join(' ')) 84 | 85 | const bodyToUpdate = { 86 | title, 87 | images: [ 88 | { 89 | id: 0, 90 | url: 'https://new-url' 91 | }, 92 | { 93 | id: response1.images[0].id, 94 | url, 95 | } 96 | ] 97 | } 98 | 99 | const optionsPUT = { 100 | uri: `${process.env.TEST_HOST}/posts/${id}`, 101 | body: bodyToUpdate, 102 | json: true 103 | } 104 | const {response: response2} = await request.put(optionsPUT) 105 | assert.equal(response2, 'OK') 106 | 107 | const optionsGET2 = { 108 | uri: `${process.env.TEST_HOST}/posts/${id}`, 109 | json: true 110 | } 111 | 112 | const { response: response3 } = await request.get(optionsGET2) as IResponsePostType 113 | assert.equal(response3.title, title) 114 | assert.equal(response3.images[0].url, url) 115 | 116 | const expectedImages = [{ url }, { url: images[1].url }, { url: bodyToUpdate.images[0].url }] 117 | assert( 118 | response3.images.map(({url}) => url).sort().join(' ') === expectedImages.map(({url}) => url).sort().join(' ') 119 | ) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## TypeORM mock unit testing examples with Jest and Mocha 2 | 3 | Example how to mock TypeORM for your blazing unit tests with Mocha and Jest. 4 | It's a simple `express` server 5 | 6 | [![Build Status][travis-image]][travis-url] 7 | [![Coverage Status][coveralls-image]][coveralls-url] 8 | [![StackOverflow Question][so-image]][so-url] 9 | [![Contributions welcome][pr-image]][pr-url] 10 | [![License: MIT][license-image]][license-url] 11 | 12 | ## Usage 13 | 14 | ### Testing 15 | 16 | Run Mocha unit-tests 17 | 18 | ```sh 19 | npm ci 20 | npm run test:mocha 21 | ``` 22 | 23 | Run Jest unit-tests 24 | 25 | ```sh 26 | npm run test:jest 27 | ``` 28 | 29 | Run e2e tests 30 | 31 | ```sh 32 | docker-compose up -d 33 | npm run test:e2e 34 | ``` 35 | 36 | ### Development 37 | 38 | Run express server after changes 39 | 40 | ```sh 41 | npm start 42 | ``` 43 | 44 | Build express server 45 | 46 | ```sh 47 | npm run build 48 | ``` 49 | 50 | ## Example 51 | 52 | #### Source code 53 | ```js 54 | class PostService { 55 | public getById(id: number): Promise { 56 | return this._findPostById(id) 57 | } 58 | 59 | private _findPostById(id: number): Promise { 60 | return createQueryBuilder() 61 | .select(['post']) 62 | .from(Post, 'post') 63 | .leftJoinAndSelect('post.images', 'image') 64 | .where('post.id = :id', { id }) 65 | .orderBy({ image: 'ASC' }) 66 | .getOne() 67 | } 68 | } 69 | ``` 70 | 71 | #### Jest 72 | ```js 73 | describe('postService => getById', () => { 74 | it('getById method passed', async () => { 75 | typeorm.createQueryBuilder = jest.fn().mockReturnValue({ 76 | select: jest.fn().mockReturnThis(), 77 | from: jest.fn().mockReturnThis(), 78 | leftJoinAndSelect: jest.fn().mockReturnThis(), 79 | where: jest.fn().mockReturnThis(), 80 | orderBy: jest.fn().mockReturnThis(), 81 | getOne: jest.fn().mockResolvedValue('0x0') 82 | }) 83 | const result = await postService.getById(post.id) 84 | 85 | expect(result).toEqual('0x0') 86 | 87 | const qBuilder = typeorm.createQueryBuilder() 88 | expect(qBuilder.select).toHaveBeenNthCalledWith(1, ['post']) 89 | expect(qBuilder.from).toHaveBeenNthCalledWith(1, Post, 'post') 90 | expect(qBuilder.leftJoinAndSelect).toHaveBeenNthCalledWith(1, 'post.images', 'image') 91 | expect(qBuilder.where).toHaveBeenNthCalledWith(1, 'post.id = :id', { id: post.id }) 92 | expect(qBuilder.orderBy).toHaveBeenNthCalledWith(1, { image: 'ASC' }) 93 | expect(qBuilder.getOne).toHaveBeenNthCalledWith(1) 94 | }) 95 | }) 96 | ``` 97 | 98 | #### Sinon 99 | ```js 100 | describe('postService => getById', () => { 101 | let sandbox: SinonSandbox 102 | 103 | beforeEach(() => { 104 | sandbox = createSandbox() 105 | }) 106 | 107 | afterEach(() => { 108 | sandbox.restore() 109 | }) 110 | 111 | it('getById method passed', async () => { 112 | const fakeQueryBuilder = createStubInstance(typeorm.SelectQueryBuilder) 113 | 114 | fakeQueryBuilder.select.withArgs(['post']).returnsThis() 115 | fakeQueryBuilder.from.withArgs(Post, 'post').returnsThis() 116 | fakeQueryBuilder.leftJoinAndSelect.withArgs('post.images', 'image').returnsThis() 117 | fakeQueryBuilder.where.withArgs('post.id = :id', { id: post.id }).returnsThis() 118 | fakeQueryBuilder.orderBy.withArgs({ image: 'ASC' }).returnsThis() 119 | fakeQueryBuilder.getOne.resolves('0x0') 120 | 121 | sandbox.stub(typeorm, 'createQueryBuilder').returns(fakeQueryBuilder as any) 122 | 123 | const result = await postService.getById(post.id) 124 | assert.equal(result, '0x0') 125 | }) 126 | }) 127 | ``` 128 | 129 | [travis-image]: https://travis-ci.org/yegorzaremba/typeorm-mock-unit-testing-example.svg?branch=master 130 | [travis-url]: https://travis-ci.org/yegorzaremba/typeorm-mock-unit-testing-example 131 | [coveralls-image]: https://coveralls.io/repos/github/YegorZaremba/typeorm-mock-unit-testing-example/badge.svg?branch=master 132 | [coveralls-url]: https://coveralls.io/github/YegorZaremba/typeorm-mock-unit-testing-example?branch=master 133 | [so-image]: https://img.shields.io/badge/StackOverflow-Question-green.svg 134 | [so-url]: https://stackoverflow.com/q/51482701/10432429 135 | [pr-image]: https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat 136 | [pr-url]: https://github.com/yegorzaremba/typeorm-mock-unit-testing-example/issues 137 | [license-image]: https://img.shields.io/badge/License-MIT-yellow.svg 138 | [license-url]: https://opensource.org/licenses/MIT 139 | --------------------------------------------------------------------------------