做了一个Nest.js上手项目,很丑,但适合练手和收藏

开发 前端
最近爱了上 Nest.js 这个框架,边学边做了一个 nest-todo 这个项目。

 [[431813]]

前言

最近爱了上 Nest.js 这个框架,边学边做了一个 nest-todo 这个项目。

https://github.com/haixiangyan/nest-todo

没错,就是一个 UI 很丑陋的 Todo List App。不知道为啥,慢慢开始喜欢上这种原始风味的 UI 样式了,不写 CSS 也挺好看的。

虽然皮肤很丑,但是项目里面包含了大量 Nest.js 文档里的知识点(除了 GraphQL 和微服务,这部分平常用得不多就不瞎整了),能实现的点我基本都想个需求实现了:

为什么

为什么要做这个项目呢?市面上的文章和博客看了不少,很多都浅尝辄止,写个 CRUD 就完事了,也太 easy 了,一行 nest g resource 就搞定。所以,就想实现一个 大而全 的 Nest.js 的 Demo 出来。

除此之外,这个 Demo 还能给很多要马上上手的前端一个示范。虽然 Nest.js 文档也齐全,但是如果你稍微做重一点的业务,它就有点顶不住了,很多东西都要 试。那这个时候 nest-todo 就可以站出来说:“不会就抄我吧,我肯定能 Work”。

前端

前端部分主要使用 React 来实现,仅有 0.0000001% 的样式,几乎都是 JS 逻辑,且有 100% TypeScript 类型提示,可大胆学习观看。

由于本项目以后端为主,所以前端也只有这些东西:

后端

后端内容则比较多了,主要就是主角 Nest.js,以及非常多的模块:

下面例举几个我觉得比较重要的模块来说说吧,当然下面都是一些代码片段,想了解更具体的实现,可以到 Github 的 nest-todo 查看。

Todo 模块

最基础的增、删、改、查。相信很多人在一些博客或文章都见过这样的写法。

TodoController 负责路由实现: 

  1. @ApiTags('待办事项')  
  2. @ApiBearerAuth()  
  3. @Controller('todo')  
  4. export class TodoController {  
  5.   constructor(private readonly todoService: TodoService) {}  
  6.   @Post()  
  7.   async create(  
  8.     @Request() request,  
  9.     @Body() createTodoDto: CreateTodoDto,  
  10.   ): Promise<Todo> {  
  11.     return this.todoService.create(request.user.id, createTodoDto);  
  12.   }  
  13.   @Get()  
  14.   async findAll(@Request() request): Promise<Todo[]> {  
  15.     const { id, is_admin } = request.user;  
  16.     if (is_admin === 1) {  
  17.       return this.todoService.findAll();  
  18.     } else { 
  19.       return this.todoService.findAllByUserId(id);  
  20.     }  
  21.   }  
  22.   @Get(':id')  
  23.   async findOne(@Param('id', ParseIntPipe) id: number): Promise<Todo> { 
  24.      return this.todoService.findOne(id);  
  25.   }  
  26.   @Patch(':id')  
  27.   async update(  
  28.     @Param('id', ParseIntPipe) id: number,  
  29.     @Body() updateTodoDto: UpdateTodoDto,  
  30.   ) {  
  31.     await this.todoService.update(id, updateTodoDto);  
  32.     return updateTodoDto;  
  33.   }  
  34.   @Delete(':id') 
  35.   async remove(@Param('id', ParseIntPipe) id: number) {  
  36.     await this.todoService.remove(id);  
  37.     return { id }; 
  38.   }  

而 TodoService 则实现更底层的业务逻辑,这里则是要从数据库增、删、改、查: 

  1. @Injectable()  
  2. export class TodoService {  
  3.   constructor(  
  4.     private todoRepository: TodoRepository,  
  5.     private userRepository: UserRepository,  
  6.   ) {} 
  7.   async create(userId: number, createTodoDto: CreateTodoDto): Promise<Todo> {  
  8.     const user = await this.userRepository.findOne(userId);  
  9.     const { title, description, media } = createTodoDto;  
  10.     const todo = new Todo();  
  11.     todo.title = title;  
  12.     todo.description = description;  
  13.     todo.status = createTodoDto.status || TodoStatus.TODO;  
  14.     todo.media = media;  
  15.     todo.author = user
  16.     return this.todoRepository.save(todo);  
  17.   } 
  18.   async findAll(): Promise<Todo[]> {  
  19.     return this.todoRepository.find();  
  20.   }  
  21.   async findAllByUserId(userId: number): Promise<Todo[]> {  
  22.     const user = await this.userRepository.findOne({  
  23.       relations: ['todos'],  
  24.       where: { id: userId },  
  25.     });  
  26.     return user ? user.todos : [];  
  27.   }  
  28.   async findOne(id: number): Promise<Todo> {  
  29.     return this.todoRepository.findOne(id);  
  30.   }  
  31.   async update(id: number, updateTodoDto: UpdateTodoDto) {  
  32.     const { title, description, status, media } = updateTodoDto;  
  33.     return this.todoRepository.update(id, {  
  34.       title,  
  35.       description,  
  36.       status: status || TodoStatus.TODO,  
  37.       media: media || '',  
  38.     });  
  39.   }  
  40.   async remove(id: number) { 
  41.     return this.todoRepository.delete({  
  42.       id,  
  43.     });  
  44.   }  

可惜的是,这些文章和博客到此就结束了,可能作者看到这里也不想再继续搞下去了。不过,我并不打算到此结束,这才刚开始呢。

数据库模块

上面的 TodoService 里用到了数据库,那就来聊聊数据库模块。我这里的选型是 TypeORM + mariadb,为啥不用 mysql 呢?因为我用 M1 的 Mac,装不了 mysql 这个镜像,非常蛋疼。

要使用 TypeORM,就需要在 AppModule 上添加这个配置,然而,明文写配置是个沙雕做法,更好的实现应该用 Nest.js 提供的 ConfigModule 来读取配置。

这里的读取配置目前我先采用读取 .env 的配置实现,其实一般在公司里都应该有个配置中心,里面存放了 username, password 这些敏感字段,ConfigModule 则负责开启应用时读取这些配置。

读取配置这里使用 读取 .env 文件” 实现: 

  1. const loadConfig = () => {  
  2.   const { env } = process;  
  3.   return {  
  4.     db: {  
  5.       database: env.TYPEORM_DATABASE,  
  6.       host: env.TYPEORM_HOST,  
  7.       port: parseInt(env.TYPEORM_PORT, 10) || 3306,  
  8.       username: env.TYPEORM_USERNAME,  
  9.       password: env.TYPEORM_PASSWORD,  
  10.     },  
  11.     redis: { 
  12.       host: env.REDIS_HOST,  
  13.       port: parseInt(env.REDIS_PORT) || 6379,  
  14.     }, 
  15.   };  
  16. }; 

然后再在 AppModule 使用 ConfigModule 和 TypeORMModule: 

  1. const libModules = [  
  2.   ConfigModule.forRoot({  
  3.     load: [loadConfig],  
  4.     envFilePath: [DOCKER_ENV ? '.docker.env' : '.env'],  
  5.   }),  
  6.   ScheduleModule.forRoot(),  
  7.   TypeOrmModule.forRootAsync({  
  8.     imports: [ConfigModule],  
  9.     inject: [ConfigService],  
  10.     useFactory: (configService: ConfigService) => {  
  11.       const { host, port, username, password, database } =  
  12.         configService.get('db');  
  13.       return {  
  14.         type: 'mariadb',  
  15.         // .env 获取  
  16.         host,  
  17.         port,  
  18.         username,  
  19.         password,  
  20.         database,  
  21.         // entities  
  22.         entities: ['dist/**/*.entity{.ts,.js}'],  
  23.       };  
  24.     },  
  25.   }),  
  26. ];  
  27. @Module({  
  28.   imports: [...libModules, ...businessModules],  
  29.   controllers: [AppController],  
  30.   providers: [AppService],  
  31. })  
  32. export class AppModule {} 

最后一步,在 Todo 业务模块里注入数据表对应的 Repository,这里一来 TodoService 就可以用 Repository 来操作数据库表了: 

  1. @Module({  
  2.   imports: [  
  3.     TypeOrmModule.forFeature([TodoRepository, UserRepository]),  
  4.     UserModule,  
  5.   ],  
  6.   controllers: [TodoController],  
  7.   providers: [TodoService],  
  8. })  
  9. export class TodoModule {} 

数据库模块还没完...

除了连接数据库,数据库的迁移与初始化是很多人经常忽略的点。

先说初始化,非常简单,就是一个脚本的事: 

  1. const checkExist = async (userRepository: Repository<User>) => {  
  2.   console.log('检查是否已初始化...');  
  3.   const userNum = await userRepository.count();  
  4.   const exist = userNum > 0;  
  5.   if (exist) {  
  6.     console.log(`已存在 ${userNum} 条用户数据,不再初始化。`);  
  7.     return true;  
  8.   }  
  9.   return false;  
  10. }; 
  11. const seed = async () => {  
  12.   console.log('开始插入数据...');  
  13.   const connection = await createConnection(ormConfig);  
  14.   const userRepository = connection.getRepository<User>(User); 
  15.   const dataExist = await checkExist(userRepository); 
  16.   if (dataExist) {  
  17.     return;  
  18.   }  
  19.   const initUsers = getInitUsers();  
  20.   console.log('生成初始化数据...');  
  21.   initUsers.forEach((user) => { 
  22.     user.todos = lodash.range(3).map(getRandomTodo);  
  23.   });  
  24.   const users = lodash.range(10).map(() => {  
  25.     const todos = lodash.range(3).map(getRandomTodo);  
  26.     return getRandomUser(todos);  
  27.   });  
  28.   const allUsers = [...initUsers, ...users];  
  29.   console.log('插入初始化数据...');  
  30.   await userRepository.save(allUsers);  
  31.   console.log('数据初始化成功!');  
  32. }; 
  33. seed()  
  34.   .then(() => process.exit(0))  
  35.   .catch((e) => {  
  36.     console.error(e);  
  37.     process.exit(1);  
  38.   }); 

当然,最好也提供重置数据库的能力: 

  1. const reset = async () => {  
  2.   const connection = await createConnection(ormConfig);  
  3.   await connection.createQueryBuilder().delete().from(Todo).execute();  
  4.   await connection.createQueryBuilder().delete().from(User).execute();  
  5. };  
  6. reset()  
  7.   .then(() => process.exit(0))  
  8.   .catch((e) => {  
  9.     console.error(e);  
  10.     process.exit(1);  
  11.   }); 

这样一来,小白上手完全不慌。只要改坏数据库,一个 reset + seed 的操作,数据库又回来的了。当然,这一步仅仅是针对 数据 来说的。

针对数据库表结构则需要 数据库迁移。令人激动的是 TypeORM 已经提供了一条非常 NB 的迁移命令: 

  1. // package.json  
  2. "db:seed": "ts-node scripts/db/seed.ts",  
  3. "db:reset": "ts-node scripts/db/reset.ts",  
  4. "migration:generate": "npm run build && npm run typeorm migration:generate -- -n",  
  5. "migration:run": "npm run build && npm run typeorm migration:run" 

但是,TypeORM 是从哪知道数据表的结构的呢?这就是 Entity 的作用了,下面就是一个 Todo entity: 

  1. @Entity()  
  2. export class Todo {  
  3.   @ApiProperty()  
  4.   @PrimaryGeneratedColumn()  
  5.   id: number; // 自增 id  
  6.   @ApiProperty()  
  7.   @Column({ length: 500 })  
  8.   title: string; // 标题  
  9.   @ApiProperty()  
  10.   @Column('text')  
  11.   description?: string; // 具体内容  
  12.   @ApiProperty()  
  13.   @Column('int', { default: TodoStatus.TODO })  
  14.   status: TodoStatus; // 状态  
  15.   @ApiProperty({ required: false })  
  16.   @Column('text') 
  17.   media?: string;  
  18.   @ManyToOne(() => User, (user) => user.todos)  
  19.   author: User;  

然后在 .env 里添加配置: 

  1. # Type ORM 专有变量  
  2. # 详情:https://typeorm.io/#/using-ormconfig  
  3. # 生产环境在服务器上的容器里配置  
  4. TYPEORM_CONNECTION=mariadb  
  5. TYPEORM_DATABASE=nest_todo  
  6. TYPEORM_HOST=127.0.0.1  
  7. TYPEORM_PORT=3306  
  8. TYPEORM_USERNAME=root  
  9. TYPEORM_PASSWORD=123456  
  10. TYPEORM_ENTITIES=dist/**/*.entity{.ts,.js}  
  11. TYPEORM_MIGRATIONS=dist/src/db/migrations/*.js  
  12. TYPEORM_MIGRATIONS_DIR=src/db/migrations 

有了上面的命令,还有什么数据库我不敢删的?遇事不决 npm run migration:run + npm run db:seed 一下。

上传模块

从上面 Demo 可看到,Todo 是支持图片上传的,所以这里还需要提供上传功能。Nest.js 非常给力,直接内置了 multer 这个库: 

  1. @ApiTags('文件上传')  
  2. @ApiBearerAuth()  
  3. @Controller('upload')  
  4. export class UploadController {  
  5.   @Post('file')  
  6.   @UseInterceptors(FileInterceptor('file'))  
  7.   uploadFile(@UploadedFile() file: Express.Multer.File) {  
  8.     return {  
  9.       file: staticBaseUrl + file.originalname,  
  10.     }; 
  11.   }  
  12.   @Post('files')  
  13.   @UseInterceptors(FileInterceptor('files'))  
  14.   uploadFiles(@UploadedFiles() files: Array<Express.Multer.File>) {  
  15.     return {  
  16.       files: files.map((f) => staticBaseUrl + f.originalname),  
  17.     }; 
  18.   }  

当然,必不可少,需要在 UploadModule 里注入模块: 

  1. @Module({ 
  2.   imports: [  
  3.     MulterModule.register({  
  4.       storage: diskStorage({  
  5.         destination: path.join(__dirname, '../../upload_dist'),  
  6.         filename(req, file, cb) {  
  7.           cb(null, file.originalname);  
  8.         },  
  9.       }),  
  10.     }),  
  11.   ],  
  12.   controllers: [UploadController],  
  13.   providers: [UploadService],  
  14. })  
  15. export class UploadModule {} 

静态资源模块

首先,必须说明一下上面的上传应该是要上传到 COS 桶或者 CDN 上,而不应该上传到自己服务器,使用自己服务器来管理文件。这里仅为了用一用这个静态资源模块。

回到主题,上面上传是上传到 /upload_dist 这个文件夹里,那我们静态资源就是要 host 这个文件夹下面的文件: 

  1. const uploadDistDir = join(__dirname, '../../', 'upload_dist');  
  2. @Controller('static')  
  3. export class StaticController {  
  4.   @SkipJwtAuth()  
  5.   @Get(':subPath')  
  6.   render(@Param('subPath') subPath, @Res() res) {  
  7.     const filePath = join(uploadDistDir, subPath);  
  8.     return res.sendFile(filePath);  
  9.   }  
  10.  
  1. @Module({  
  2.   controllers: [StaticController],  
  3. })  
  4. export class StaticModule {} 

Very easy ~ 过

登录模块

相信细心的你一定看到上面的 @SkipJwtAuth,这是因为我全局开了 JWT 鉴权,只有请求头带有 Bearer Token 才能访问这个接口,而 @SkipJwtAuth 则表示这个接口不需要 JWT 鉴权。不妨来看看普通的鉴权是怎么实现的。

首先,你必要熟悉 Passport.js 里的 Strategy 和 verifyCallback 概念,否则咱还是别聊了。这里 Nest.js 将这个 verifyCallback 封装成了 Strategy 里的 validate 方法,当编写 valiate 则是在写 verifyCallback: 

  1. @Injectable()  
  2. export class LocalStrategy extends PassportStrategy(Strategy) {  
  3.   constructor(  
  4.     private moduleRef: ModuleRef,  
  5.     private reportLogger: ReportLogger,  
  6.   ) {  
  7.     super({ passReqToCallback: true });  
  8.     this.reportLogger.setContext('LocalStrategy');  
  9.   }  
  10.   async validate(  
  11.     request: Request, 
  12.     username: string,  
  13.     password: string,  
  14.   ): Promise<Omit<User, 'password'>> {  
  15.     const contextId = ContextIdFactory.getByRequest(request);  
  16.     // 现在 authService 是一个 request-scoped provider  
  17.     const authService = await this.moduleRef.resolve(AuthService, contextId);  
  18.     const user = await authService.validateUser(username, password);  
  19.     if (!user) {  
  20.       this.reportLogger.error('无法登录,sb');  
  21.       throw new UnauthorizedException();  
  22.     }  
  23.     return user;  
  24.   }  

上面是用 username + password 实现鉴权的一种策略,当然我们正常服务是可以存在多种鉴权策略的,要使用这个策略,需要用到 Guard: 

  1. @Injectable()  
  2. export class LocalAuthGuard extends AuthGuard('local') {} 

然后将这个 Guard 放在对应的接口头顶就 O 了: 

  1. @ApiTags('登录验证')  
  2. @Controller('auth')  
  3. export class AuthController {  
  4.   constructor(private authService: AuthService) {}  
  5.   @ApiBody({ type: LoginDto })  
  6.   @SkipJwtAuth()  
  7.   @UseGuards(LocalAuthGuard)  
  8.   @Post('login')  
  9.   async login(@Request() req) {  
  10.     return this.authService.login(req.user);  
  11.   }  

和 local 这个 Strategy 相似的,JWT 也有对应的 Strategy: 

  1. @Injectable()  
  2. export class JwtStrategy extends PassportStrategy(Strategy) {  
  3.   constructor(private userService: UserService) {  
  4.     super({  
  5.       jwtFromRequest: ExtractJwt.fromAuthHeaderAsbearerToken(),  
  6.       ignoreExpiration: false,  
  7.       secretOrKey: jwtConstants.secret,  
  8.     });  
  9.   }  
  10.   async validate(payload: any) {  
  11.     const existUser = this.userService.findOne(payload.sub);  
  12.     if (!existUser) {  
  13.       throw new UnauthorizedException();  
  14.     }  
  15.     return { ...payload, id: payload.sub };  
  16.   }  

而在 JwtGuard 里,用 canActive 实现了 权限控制: 

  1. @Injectable()  
  2. export class JwtAuthGuard extends AuthGuard('jwt') {  
  3.   constructor(private reflector: Reflector) {  
  4.     super();  
  5.   }  
  6.   canActivate(  
  7.     context: ExecutionContext,  
  8.   ): boolean | Promise<boolean> | Observable<boolean> {  
  9.     // 自定义用户身份验证逻辑  
  10.     const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [  
  11.       context.getHandler(),  
  12.       context.getClass(), 
  13.     ]);  
  14.     // skip  
  15.     if (isPublic) return true;  
  16.     return super.canActivate(context);  
  17.   }  
  18.   handleRequest(err, user) {  
  19.     // 处理 info  
  20.     if (err || !user) {  
  21.       throw err || new UnauthorizedException();  
  22.     }  
  23.     return user;  
  24.   }  

格式化输出

写完接口了,就得格式化输出,我比较喜欢的格式是: 

  1.  
  2.   retcode: 0,  
  3.   message: "",  
  4.   data: ...  

我们更希望不要在 Controller 里重复添加上面的 “格式化” 数据结构。Nest.js 提供了 Interceptor,可以让我们在 拉 数据给前端之前 “加点料”: 

  1. export class TransformInterceptor<T>  
  2.   implements NestInterceptor<T, Response<T>>  
  3.  
  4.   intercept(context: ExecutionContext, next: CallHandler<T>) {  
  5.     return next.handle().pipe(  
  6.       map((data) => ({  
  7.         retcode: 0,  
  8.         message: 'OK',  
  9.         data,  
  10.       })),  
  11.     );  
  12.   }  

然后在 main.ts 入口里全局使用: 

  1. app.useGlobalInterceptors(  
  2.   new LogInterceptor(reportLogger),  
  3.   new TransformInterceptor(),  
  4. ); 

测试

写完了一个接口,肯定免不了要写测试。我相信绝大部分人是不会写测试,当然他们自己也是不会写的。

它不是 “Jest”,也不是 “Cypress”,而是一个可以研究得很深的领域。它难的点并不在于 “写”,而在于 “造”,以及 测试策略。

先来说测试策略吧,请问什么东西应该测?什么东西可以不测?什么东西不应该测?这三问是个人觉得是个玄学问题,没有正确答案,只能根据自己的项目来判断。并不是 100% 的覆盖率就是好的,也要看更新迭代时测试代码的改造成本。

我先给出这个项目的测试原则:

  •  数据库操作不测,因为这个测试内容 TypeORM 能保证 API 的调用是 OK 的
  •  简单实现不测,比如一个函数只有一行,那还测个 P
  •  我只测一个模块,因为我懒,剩下大家自己看我那个模块的测试就能学会了
  •  我的 测试策略 不一定正确,只能说是我目前想到比较好的 测试策略

对 TodoService 进行测试,比较难的点是对 TypeOrm 的 Repository 进行 Mock,这玩意我自己搞了一整天才搞通,相信没人有耐心整这些了: 

  1. const { mockTodos, mockUsers } = createMockDB();  
  2. describe('TodoService', () => {  
  3.   let mockTodoRepository;  
  4.   let mockUserRepository;  
  5.   let service: TodoService;  
  6.   beforeEach(async () => {  
  7.     mockUserRepository = new MockUserRepository(mockUsers);  
  8.     mockTodoRepository = new MockTodoRepository(mockTodos);  
  9.     const module: TestingModule = await Test.createTestingModule({  
  10.       providers: [  
  11.         TodoService,  
  12.         {  
  13.           provide: TodoRepository,  
  14.           useValue: mockTodoRepository,  
  15.         },  
  16.         {  
  17.           provide: UserRepository,  
  18.           useValue: mockUserRepository,  
  19.         },  
  20.       ],  
  21.     }).compile();  
  22.     service = module.get<TodoService>(TodoService);  
  23.   });  
  24.   it('create', async () => {  
  25.     expect(service).toBeDefined();  
  26.     // 创建一个 todo  
  27.     const returnTodos = await service.create(99, {  
  28.       title: 'title99',  
  29.       description: 'desc99',  
  30.       status: TodoStatus.TODO,  
  31.     });  
  32.     // expect  
  33.     expect(returnTodos.title).toEqual('title99');  
  34.     expect(returnTodos.description).toEqual('desc99');  
  35.     expect(returnTodos.status).toEqual(TodoStatus.TODO);  
  36.   });  
  37.   it('findAll', async () => {  
  38.     expect(service).toBeDefined();  
  39.     const returnTodos = await service.findAll();  
  40.     // expect  
  41.     expect(returnTodos).toEqual(mockTodos);  
  42.   });  
  43.   it('findAllByUserId', async () => {  
  44.     expect(service).toBeDefined();  
  45.     // 直接返回第一个 user  
  46.     jest.spyOn(mockUserRepository, 'findOne').mockImplementation(async () => {  
  47.       return mockUsers[0];  
  48.     });  
  49.     // 找到 userId 为 0 的所有 todo  
  50.     const returnTodos = await service.findAllByUserId(0);  
  51.     const [firstTodo] = returnTodos;  
  52.     // expect  
  53.     expect(mockUserRepository.findOne).toBeCalled();  
  54.     expect(firstTodo.id).toEqual(0);  
  55.     expect(firstTodo.title).toEqual('todo1'); 
  56.     expect(firstTodo.description).toEqual('desc1');  
  57.   });  
  58.   it('findOne', async () => {  
  59.     expect(service).toBeDefined();  
  60.     // 找到一个 todo  
  61.     const returnTodo = await service.findOne(0);  
  62.     // expect  
  63.     expect(returnTodo.id).toEqual(0);  
  64.     expect(returnTodo.title).toEqual('todo1');  
  65.     expect(returnTodo.description).toEqual('desc1');  
  66.   });  
  67.   it('update', async () => {  
  68.     expect(service).toBeDefined();  
  69.     // 所有 todo  
  70.     const allTodos = await service.findAll();  
  71.     // 更新一个 todo  
  72.     await service.update(0, {  
  73.       title: 'todo99',  
  74.       description: 'desc99',  
  75.     });  
  76.     // expect  
  77.     const targetTodo = allTodos.find((todo) => todo.id === 0);  
  78.     expect(targetTodo.id).toEqual(0);  
  79.     expect(targetTodo.title).toEqual('todo99');  
  80.     expect(targetTodo.description).toEqual('desc99');  
  81.   });  
  82.   it('remote', async () => {  
  83.     expect(service).toBeDefined();  
  84.     // 删除 todo  
  85.     await service.remove(0);  
  86.     // 获取所有 todo  
  87.     const allTodos = await service.findAll();  
  88.     // expect  
  89.     expect(allTodos.length).toEqual(1);  
  90.     expect(allTodos.find((todo) => todo.id === 0)).toBeUndefined();  
  91.   });  
  92. }); 

对 TodoController 的单元测试,我觉得这个 class 没什么可测的,因为里面的函数太简单了: 

  1. const { mockTodos, mockUsers } = createMockDB();  
  2. describe('TodoController', () => {  
  3.   let todoController: TodoController;  
  4.   let todoService: TodoService;  
  5.   let mockTodoRepository;  
  6.   let mockUserRepository;  
  7.   beforeEach(async () => {  
  8.     mockTodoRepository = new MockTodoRepository(mockTodos);  
  9.     mockUserRepository = new MockUserRepository(mockUsers);  
  10.     const app: TestingModule = await Test.createTestingModule({  
  11.       controllers: [TodoController],  
  12.       providers: [  
  13.         TodoService, 
  14.         {  
  15.           provide: TodoRepository,  
  16.           useValue: mockTodoRepository,  
  17.         },  
  18.         {  
  19.           provide: UserRepository,  
  20.           useValue: mockUserRepository,  
  21.         },  
  22.       ],  
  23.     }).compile();  
  24.     todoService = app.get<TodoService>(TodoService);  
  25.     todoController = app.get<TodoController>(TodoController); 
  26.   });  
  27.   describe('findAll', () => {  
  28.     const [firstTodo] = mockTodos;  
  29.     it('普通用户只能访问自己的 todo', async () => {  
  30.       jest  
  31.         .spyOn(todoService, 'findAllByUserId')  
  32.         .mockImplementation(async () => {  
  33.           return [firstTodo];  
  34.         });  
  35.       const todos = await todoController.findAll({  
  36.         user: { id: 0, is_admin: 0 },  
  37.       });  
  38.       expect(todos).toEqual([firstTodo]);  
  39.     });  
  40.     it('管理员能访问所有的 todo', async () => {  
  41.       jest.spyOn(todoService, 'findAll').mockImplementation(async () => {  
  42.         return mockTodos;  
  43.       });  
  44.       const todos = await todoController.findAll({  
  45.         user: { id: 0, is_admin: 1 },  
  46.       });  
  47.       expect(todos).toEqual(mockTodos); 
  48.     });  
  49.   });  
  50. }); 

最后就是 e2e 的测试,难点在于 Bearer Token 鉴权的获取,这玩意也同样搞了我一天时间: 

  1. describe('TodoController (e2e)', () => {  
  2.   const typeOrmModule = TypeOrmModule.forRoot({  
  3.     type: 'mariadb',  
  4.     database: 'nest_todo',  
  5.     username: 'root',  
  6.     password: '123456',  
  7.     entities: [User, Todo],  
  8.   });  
  9.   let app: INestApplication;  
  10.   let bearerToken: string;  
  11.   let createdTodo: Todo; 
  12.   beforeAll(async (done) => {  
  13.     const moduleFixture: TestingModule = await Test.createTestingModule({  
  14.       imports: [TodoModule, AuthModule, typeOrmModule],  
  15.       providers: [TodoRepository, UserRepository],  
  16.     }).compile();  
  17.     app = moduleFixture.createNestApplication();  
  18.     await app.init();  
  19.     // 生成测试用户的 token  
  20.     request(app.getHttpServer())  
  21.       .post('/auth/login')  
  22.       .send({ username: 'user', password: 'user' })  
  23.       .expect(201) 
  24.       .expect((res) => {  
  25.         bearerToken = `Bearer ${res.body.token}`;  
  26.       })  
  27.       .end(done);  
  28.   });  
  29.   it('GET /todo', (done) => {  
  30.     return request(app.getHttpServer())  
  31.       .get('/todo')  
  32.       .set('Authorization', bearerToken)  
  33.       .expect(200)  
  34.       .expect((res) => {  
  35.         expect(typeof res.body).toEqual('object');  
  36.         expect(res.body instanceof Array).toBeTruthy();  
  37.         expect(res.body.length >= 3).toBeTruthy();  
  38.       })  
  39.       .end(done);  
  40.   }); 
  41.   it('POST /todo', (done) => {  
  42.     const newTodo: CreateTodoDto = {  
  43.       title: 'todo99',  
  44.       description: 'desc99',  
  45.       status: TodoStatus.TODO,  
  46.       media: '',  
  47.     };  
  48.     return request(app.getHttpServer())  
  49.       .post('/todo')  
  50.       .set('Authorization', bearerToken)  
  51.       .send(newTodo)  
  52.       .expect(201)  
  53.       .expect((res) => {  
  54.         createdTodo = res.body;  
  55.         expect(createdTodo.title).toEqual('todo99');  
  56.         expect(createdTodo.description).toEqual('desc99');  
  57.         expect(createdTodo.status).toEqual(TodoStatus.TODO);  
  58.       })  
  59.       .end(done);  
  60.   });  
  61.   it('PATCH /todo/:id', (done) => {  
  62.     const updatingTodo: UpdateTodoDto = {  
  63.       title: 'todo9999',  
  64.       description: 'desc9999',  
  65.     }; 
  66.     return request(app.getHttpServer())  
  67.       .patch(`/todo/${createdTodo.id}`)  
  68.       .set('Authorization', bearerToken)  
  69.       .send(updatingTodo)  
  70.       .expect(200)  
  71.       .expect((res) => {  
  72.         expect(res.body.title).toEqual(updatingTodo.title);  
  73.         expect(res.body.description).toEqual(updatingTodo.description);  
  74.       })  
  75.       .end(done);  
  76.   });  
  77.   it('DELETE /todo/:id', (done) => {  
  78.     return request(app.getHttpServer())  
  79.       .delete(`/todo/${createdTodo.id}`) 
  80.       .set('Authorization', bearerToken)  
  81.       .expect(200)  
  82.       .expect((res) => {  
  83.         expect(res.body.id).toEqual(createdTodo.id);  
  84.       })  
  85.       .end(done);  
  86.   });  
  87.   afterAll(async () => {  
  88.     await app.close();  
  89.   }); 
  90. }); 

Swagger

Swagger 是一个非常强大的文档工具,可以识别接口的 URL,入参,出参,简直是前端使用者的福音:

首先在 main.ts 里接入 Swagger: 

  1. const setupSwagger = (app) => {  
  2.   const config = new DocumentBuilder()  
  3.     .addBearerAuth()  
  4.     .setTitle('待办事项')  
  5.     .setDescription('nest-todo 的 API 文档')  
  6.     .setVersion('1.0')  
  7.     .build();  
  8.   const document = SwaggerModule.createDocument(app, config);  
  9.   SwaggerModule.setup('docs', app, document, {  
  10.     swaggerOptions: {  
  11.       persistAuthorization: true,  
  12.     },  
  13.   });  
  14. }; 

然后在 nest-cli.json 里也接入 Swagger 的插件,这样才能自动识别,不然就要一个 ApiProperty 一个 ApiProperty 去声明了: 

  1.  
  2.   "collection": "@nestjs/schematics",  
  3.   "sourceRoot": "src",  
  4.   "compilerOptions": {  
  5.     "plugins": ["@nestjs/swagger"]  
  6.   }  

最后

还有非常多的模块没讲,我觉得那些并不是那么重要,只要看过文档就会了。上面的模块是踩了很多坑才实现出来的,中间走走停停花了大概 1 个月左右的时间。

大家先本地 Clone 玩吧。如果你对 Nest.js 也感兴趣,也想学一下它,不妨 Clone 一下 nest-todo 这个项目,抄抄改改学一下吧。 

 

责任编辑:庞桂玉 来源: 前端大全
相关推荐

2021-06-18 06:48:54

前端Nest.js技术热点

2024-02-04 19:15:09

Nest.js管理项目

2021-12-27 20:29:21

机制PipeExceptionFi

2022-03-18 21:51:10

Nest.jsAOP 架构后端

2018-05-07 08:29:56

机器学习开源适合

2022-02-02 20:21:24

短信验证码登录

2022-12-27 09:22:06

Nest.js框架

2021-12-22 06:56:06

MySQCrudjs

2022-03-02 14:00:46

Nest.jsExpress端口

2021-09-18 12:05:59

Python 开发编程语言

2020-07-16 07:22:10

PythonNode.JS编程语言

2021-08-27 07:31:54

项目

2020-06-18 10:36:12

GitHub代码开发者

2021-06-29 06:25:22

Nest.jsTypeORM数据库

2019-08-30 15:14:55

Python开发工具

2018-05-09 00:10:18

Python 项目编程语言

2018-01-15 15:00:06

工程师项目设计师

2015-09-08 10:32:21

开源项目选择方式

2015-09-11 10:29:13

开源项目阅读

2023-01-30 09:01:34

DecoratorsJS语法
点赞
收藏

51CTO技术栈公众号