上一个文章我们介绍了如何搭建一个开发环境和 Pipeline, 这篇文章开始我们将正式的用 TDD 的模式实现一个后端项目.
我们选用了 Postgres 作为我们的数据库, 操作数据库的 ORM 我们选用 TypeORM, 这是一个 TypeScript 友好的 ORM, 并且 nest 也提供了非常便利的集成方法.
1 yarn add @nestjs/typeorm typeorm pg
编辑 app.module.ts
app.module.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import { Module } from '@nestjs/common' import { AppController } from './app.controller' import { AppService } from './app.service' import { TypeOrmModule } from '@nestjs/typeorm' @Module ({ imports : [ TypeOrmModule .forRoot ({ type : 'postgres' , host : 'localhost' , port : 5432 , username : 'realworld' , password : '123456' , database : 'nestjs' , entities : ['dist/**/*.entity{.ts,.js}' ], synchronize : true , }), ], controllers : [AppController ], providers : [AppService ], }) export class AppModule {}
我们启动我们的 Postgres 服务, 然后创建一个用户 realworld
和数据库 nestjs
1 2 3 4 5 psql postgres CREATE ROLE realworld WITH LOGIN PASSWORD '123456' ; CREATE DATABASE nestjs OWNER realworld; GRANT ALL PRIVILEGES ON DATABASE nestjs TO realworld;
我们接下来定义一个用户表, TypeORM 支持仓库设计模式(Repository design pattern), 每个实体都有自己的的仓库.
首先执行下面的命令来创建用户 module 和 service
1 2 nest g module user nest g service user
然后在自动生成的 src/user
目录下创建一个 user.entity.ts
文件
user.entity.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import { Column , Entity , PrimaryGeneratedColumn } from 'typeorm' const nullable = true @Entity ()export class User { @PrimaryGeneratedColumn () id : number @Column ({ length : 80 }) email : string @Column ({ length : 20 }) username : string @Column ({ nullable, type : 'text' }) bio : null | string @Column ({ nullable, type : 'text' }) image : null | string }
然后编辑 user.service.ts
, 注入 userRepository 在 UserService 中
user.service.ts 1 2 3 4 5 6 7 8 9 10 11 12 import { Injectable } from '@nestjs/common' import { InjectRepository } from '@nestjs/typeorm' import { User } from './user.entity' import { Repository } from 'typeorm' @Injectable ()export class UserService { constructor ( @InjectRepository (User) private readonly userRepository: Repository<User>, ) {}}
然后 user.module.ts
中导入 User
entity
user.module.ts 1 2 3 4 5 6 7 8 9 10 import { Module } from '@nestjs/common' import { TypeOrmModule } from '@nestjs/typeorm' import { User } from './user.entity' import { UserService } from './user.service' @Module ({ imports : [TypeOrmModule .forFeature ([User ])], providers : [UserService ], }) export class UserModule {}
最后, 启动服务, 我们就可以看到, 数据库表中已经多了一张 user 表啦
现在所有的东西都准备好了, 接下来就在 user service 中实现一个注册方法来看看我们是怎么来做 TDD 的.
不了解 TDD 的同学这里简单介绍一下 TDD TDD 要求我们先设计 Task, 将我们的需求拆成一个个的 Task, 然后每个 Task 写至少一个测试用例,解决语法错误的问题(如将要调用的方法还未声明)后, 跑一遍测试, 这时测试应该是“红”(不通过)的, 然后开始写实现, 写到刚好“绿”(通过测试)为止, 不要写多余的方法. 经过“红-绿”后,对自己的代码进行重构. 这个过程就是 TDD 三角 “红-绿-重构”
我们的代码应当按照 TDD 金字塔来设计: 首先要保证我们的代码通过测试, 其次最求最小元素实现功能、保证传达测试的意图,最后消除重复的代码.
首先我们要在应用添加一个注册入口, 根据 ConduitAPI 需求, 用户注册的路由是 POST /auth/register
, request 和 response 应该长这样
request body 1 2 3 4 5 6 7 { "user" : { "email" : "mutoe@foxmail.com" , "username" : "mutoe" , "password" : "12345678" } }
response body 1 2 3 4 5 6 7 8 9 10 11 12 { "user" : { "id" : 1 , "email" : "mutoe@foxmail.com" , "createdAt" : "2019-10-22T03:22:54.038Z" , "updatedAt" : "2019-10-22T03:22:54.046Z" , "username" : "mutoe" , "bio" : null , "image" : null , "token" : "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1N..." } }
根据软件设计三层架构(User Interface Layer, Business Logic Layer, Data Access Layer), 我们知道, 在 Controller 中需要调用用户相关的 Service, 那我们测 Controller 时只需要断言是否调用了 Service 即可, 我们也信任 Service 返回的内容是我们需要的, 因为我们也会通过单元测试验证 Service 返回的内容.
打开 app.controller.spec.ts
, 添加按照 Given-When-Then 以下测试用例
app.controller.spec.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 import { Test, TestingModule } from '@nestjs/testing' import { AppController } from './app.controller' - import { AppService } from './app.service' + import { UserService } from './user/user.service' describe('# AppController', () => { let appController: AppController + let userService: UserService beforeEach(async () => { const app: TestingModule = await Test.createTestingModule({ controllers: [AppController], - providers: [AppService], + providers: [ + UserService, + { + provide: getRepositoryToken(User), + useClass: Repository, + }, + ], }).compile() appController = app.get(AppController) + userService = app.get(UserService) }) + describe('Register', function () { + it('should return user response', async function () { + // Given + const requestBody = { + email: 'mutoe@foxmail.com', + username: 'mutoe', + password: '12345678', + } + jest.spyOn(userService, 'createUser').mockResolvedValue({ user: {} }) + + // When + const response = await appController.register(requestBody) + + // Then + expect(userService.createUser).toBeCalledTimes(1) + expect(response).toHaveProperty('user', expect.any(Object)) + }) }) })
简单解释一下上面的代码
Given 部分我们首先构造了一个 request body, 方便我们在接下来的调用时使用. 然后我们将 userService
下的 createUser
方法 mock 掉
接下来我们要消除语法错误, 我们可以看到, appController
下是没有 register
这个方法的, 而且 userService
下也没有 createUser
方法.
我们进入 app.controller.ts
声明一下 register
方法
app.controller.ts 1 2 3 4 5 6 ... export class AppController { ... + @Post('/auth/register') + register (@Body() requestBody: { email: string; username: string; password: string }): any {} }
然后进入 user.service.ts
声明一个 createUser
的方法
user.service.ts 1 2 3 4 5 ... export class UserService { ... + async createUser (user: { email: string; username: string; password: string }): Promise<any> {} }
消除语法错误后, 我们就可以跑一下测试啦
好, 测试成功的失败了
这就是“红”的过程
接下来我们进行实现, 让测试变“绿”
app.controller.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import { Body , Controller , Get , Post , Query } from '@nestjs/common' import { UserService } from './user/user.service' @Controller ()export class AppController { constructor (private readonly userService: UserService ) {} @Post ('/auth/register' ) register ( @Body () requestBody : { email : string ; username : string ; password : string }, ): any { return this .userService .createUser (requestBody) } }
然后 Run 一下测试, 当当当当~
好, 我们的 TDD Controller 就完成啦!
接下来的思路是一样的, 首先编写测试代码
user.service.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 import { Test , TestingModule } from '@nestjs/testing' import { UserService } from './user.service' import { getRepositoryToken } from '@nestjs/typeorm' import { User } from './user.entity' import { Repository } from 'typeorm' describe ('UserService' , () => { let service : UserService let repository : Repository <User > beforeEach (async () => { const module : TestingModule = await Test .createTestingModule ({ providers : [ UserService , { provide : getRepositoryToken (User ), useValue : { save : jest.fn (() => true ), }, }, ], }).compile () service = module .get (UserService ) repository = module .get (getRepositoryToken (User )) }) it ('should be defined' , () => { expect (service).toBeDefined () expect (repository).toBeDefined () }) it ('should create user correctly' , async function ( ) { const user = { email : 'mutoe@foxmail.com' , username : 'mutoe' , password : '12345678' , } await service.createUser (user) expect (repository.save ).toBeCalledWith (Object .assign (new User (), user)) }) })
写实现
user.service.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import { Injectable } from '@nestjs/common' import { InjectRepository } from '@nestjs/typeorm' import { User } from './user.entity' import { Repository } from 'typeorm' @Injectable ()export class UserService { constructor ( @InjectRepository (User) private readonly userRepository: Repository<User>, ) {} createUser (userInfo: { email: string ; username: string ; password: string } ) { return this .userRepository .save (Object .assign (new User (), userInfo)) } }
Run 一下测试, 单元测试应该是全通过的
接下来我们验证一下功能是不是正确, 发起一个真实的请求, 看看数据库是不是多了一个用户, 首先启动服务, 然后发起一个 POST 请求
1 2 yarn start curl -X POST http://localhost:3000/auth/register -d '{"username":"mutoe","email":"mutoe@foxmail.com","password":"12345678"}' -H "Content-Type: application/json"
应该会返回一个含有用户 ID 的 json 格式响应
那我们总不能每次写完一个功能都手动测试一次吧! 这太 Low 了, 我们要自动化!
E2E 是连接的真实的数据库,所以我们要为了这些自动化测试创建一个用于单独的数据库, 避免影响我们开发环境的数据.
1 2 3 psql postgres CREATE DATABASE nestjs_test OWNER realworld; GRANT ALL PRIVILEGES ON DATABASE nestjs_test TO realworld;
然后编辑 app.e2e-spec.ts
app.e2e-spec.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import { Test , TestingModule } from '@nestjs/testing' import * as request from 'supertest' import { AppModule } from '../src/app.module' import { TypeOrmModule , TypeOrmModuleOptions } from '@nestjs/typeorm' const ormConfig : TypeOrmModuleOptions = { type : 'postgres' , host : 'localhost' , port : 5432 , username : 'realworld' , password : '123456' , database : 'nestjs_test' , entities : ['dist/**/*.entity{.ts,.js}' ], synchronize : true , dropSchema : true , } describe ('AppController (e2e)' , () => { let app beforeEach (async () => { const moduleFixture : TestingModule = await Test .createTestingModule ({ imports : [TypeOrmModule .forRoot (ormConfig), AppModule ], }).compile () app = moduleFixture.createNestApplication () await app.init () }) afterEach (async () => { await app.close () }) })
在每次 E2E 测试启动时, 都会自动连入我们的测试数据库
开始写我们的 E2E 测试
test/app.e2e-spec.ts 1 2 3 4 5 6 7 8 9 10 11 it ('/auth/register (POST)' , async () => { const requestBody = { username : 'mutoe' , email : 'mutoe@foxmail.com' , password : '12345678' , } await request (app.getHttpServer ()) .post ('/auth/register' ) .send (requestBody) .expect (201 ) })
然后运行
成功啦!
好, 到这里, 我们这一章所有的内容就结束了, 如果你有什么疑惑和问题, 欢迎在下方留言.