NestJS 项目实战-权限管理系统开发(三)

517 阅读9分钟

本系列教程将教你使用 NestJS 构建一个生产级别的 REST API 风格的权限管理后台服务【代码仓库地址】。

在线预览地址】账号:test,密码:d.12345

本章节内容: 1. 设置全局接口前缀;2. 创建 Auth 模块;3. 获取验证码接口;4. 注册 Redis 服务;5. 使用 redis 缓存验证码;6. 用全局拦截器统一接口返回数据格式;7. 添加 Swagger 响应类型装饰器;8. 单元测试。

1. 设置全局接口前缀(可选)

为了方便接口的版本管理,NestJS 提供了 app.setGlobalPrefix() 方法,该方法可以添加一个全局的接口前缀。

首先,在 .env.development 文件中添加相关变量:

PREFIX="/api/v1"

然后,在 /src/common/config/index.ts 中导入变量,在返回对象中添加以下属性:

prefix: configService.get<string>('PREFIX'),

最后,在 main.ts 中的 bootstrap 方法中插入以下代码:

app.setGlobalPrefix(config.prefix);

现在,我们就为所有的接口都加上了一个前缀 /api/v1

2. 创建 Auth 模块

NestJS cli 为我们提供了一些快捷命令,我们可以使用这些命令来创建模块、服务等等。

在项目根目录下新开一个终端窗口,运行以下命令:

nest generate res auth

然后选择 REST API 风格,下一步输入 Y 确认创建 CRUD 模版代码。

res 是 resource 的简写,这行命令是表示生成一个名为 auth 的模块(含 controller、module、service 与测试文件)。

现在可以看到 src 文件夹下多了一个 auth 文件夹,里面有 module、controller、service 与两个测试文件,查看 app.module.ts 会发现,NestJS 自动在 imports 中导入了该模块。

现在我们可以移除 app.module.ts 中的 controllers 与 providers 属性,然后删除 app.controller.tsapp.service.ts 以及相关测试文件了。因为不会使用到这两个文件,后续接口都将写在具体的模块中。

3. 获取验证码接口

在项目根目录下新开一个终端并运行命令安装 svg-captcha 库:

pnpm add svg-captcha

然后将 auth.service.ts 修改为以下内容:

import { Injectable } from '@nestjs/common';
import { create as createCaptcha } from 'svg-captcha';

@Injectable()
export class AuthService {
  generateCaptcha() {
    const captcha = createCaptcha({
      size: 4,
      noise: 2,
      color: true,
      ignoreChars: '0o1i',
      background: '#f0f0f0',
    });

    return {
      captcha: `data:image/svg+xml;base64,${Buffer.from(captcha.data).toString('base64')}`,
    };
  }
}

generateCaptcha 是一个生成 base64 格式的图形验证码方法,这里设置了文本长度为4、噪声2、开启字符颜色并设置了底色,然后忽略一些易混淆的字符。

打开 auth.entity.ts 添加以下内容:

import { ApiProperty } from '@nestjs/swagger';

export class AuthEntity {
  @ApiProperty({
    description: 'base64 格式验证码',
    default: 'data:image/svg+xml;base64,***',
  })
  captcha: string;
}

这是一个实体类,用作获取验证码接口返回的类型,该类只有一个 captcha 属性,并使用 ApiProperty 设置了要展示在 API 文档的内容。

最后,修改 auth.controller.ts 内容如下:

import { Controller, Get } from '@nestjs/common';
import { AuthService } from './auth.service';
import { ApiOkResponse, ApiOperation } from '@nestjs/swagger';

import { AuthEntity } from './entities/auth.entity';

@ApiTags('auth')
@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @ApiOperation({
    summary: '获取验证码',
  })
  @ApiOkResponse({
    type: AuthEntity,
  })
  @Get('captcha')
  getCaptcha(): AuthEntity {
    return this.authService.generateCaptcha();
  }
}

@ApiTags('auth') 装饰器用来给整个控制器添加 auth 标签,方便对 Swagger API 文档上接口进行分组管理。
@ApiOperation() 装饰器是用来为 Swagger API 文档添加接口说明的。
@ApiOkResponse() 装饰器用来定义接口的成功响应并为接口文档提供响应数据的类型说明。
@Get() 装饰器用来声明一个 GET 类型的接口,这里传入了 captcha 用作接口地址,该接口的完整地址为: /api/v1/auth/captcha

打开浏览器并导航到 http://localhost:3000/api ,可以看到接口文档中已经有了第一个接口。 image.png 点击上图中的 Try it out 按钮,然后在点击 Execute 按钮,可以在文档中看到接口正常返回了一个 base64 字符串。

但有一个问题,我们没有保存生成的验证码,那登录时,如何校验用户输入的验证码呢?

4. 注册 Redis 服务

可以使用 Redis 内存数据库来保存生成的验证码数据。

4.1 添加环境变量

首先在 .env.development 中添加以下变量:

# redis
REDIS_PORT=6379
REDIS_HOST="localhost"
REDIS_URL="redis://${REDIS_HOST}:${REDIS_PORT}"

然后在 /src/common/config/index.ts 中导入变量:

redis: {
    url: configService.get<string>('REDIS_URL'),
}

只需要导入完整的连接地址即可,只会用到这个变量。

4.2 添加 Redis 容器

现在打开 docker-compose.yml 并增加 Redis 容器:

services:

  ...(之前postgres的内容)

  redis:
    image: redis:latest
    ports:
      - "${REDIS_PORT:-6379}:6379"
    volumes:
      - redis_data:/data

volumes:
  ...
  redis_data:

运行命令重新构建容器:

docker-compose --env-file .env.development up --build

可以看到成功启动了 Redis 与 Postgres 服务,如下图:

image.png

4.3 创建 Redis 模块

首先运行命令安装必要的库:

pnpm add ioredis @nestjs-modules/ioredis

ioredis 是一个功能齐全且功能强大的 Redis 客户端。@nestjs-modules/ioredis 是 NestJS 提供的一个封装库。

然后运行以下命令,创建 Redis 模块:

nest g res redis

选择 REST API 与不生成 crud 代码。

现在打开 redis.module.ts 文件,更改为以下内容:

import { Module } from '@nestjs/common';
import { RedisService } from './redis.service';
import { RedisModule as BaseRedisModule } from '@nestjs-modules/ioredis';
import { ConfigService } from '@nestjs/config';
import { getBaseConfig } from 'src/common/config';

@Module({
  imports: [
    BaseRedisModule.forRootAsync({
      useFactory: (configService: ConfigService) => ({
        type: 'single',
        url: getBaseConfig(configService).redis.url,
      }),
      inject: [ConfigService],
    }),
  ],
  providers: [RedisService],
  exports: [RedisService],
})
export class RedisModule {}

这里我们将从 @nestjs-modules/ioredis 包中导入的 Redis 模块导入到了我们自己创建的 RedisModule 中,这么做主要是为了方便管理 Redis。你也可以直接将这个 BaseRedisModuleapp.module.ts 中导入。

这里移除了 controllers 属性,因为不会用到控制器,redis.controller.ts 及其测试文件也可以删除了。

还需要导出 RedisService 服务,因为其他模块需要使用这个服务。

修改 redis.service.ts 的内容为:

import { Injectable } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';

@Injectable()
export class RedisService {
  constructor(
    @InjectRedis() private readonly redis: Redis,
  ) {}
}

这里需要使用 @nestjs-modules/ioredis 包提供的 @InjectRedis() 装饰器注入 Redis 服务。

提示:可以像添加 PostgreSQL 一样增加 Redis 的图形化管理界面(第一章节中)。

5. 使用 Redis 缓存验证码

Redis 缓存数据时可以设置有效时间,而验证码一般来说,有效期几分钟就行了。我们可以先添加一个验证码有效期的环境变量:

CAPTCHA_EXPIRES_IN=120
// src/common/config/index.ts
captchaExpireIn: +configService.get<number>('CAPTCHA_EXPIRES_IN', 120),

这里的单位是秒,设置了验证码的有效期为120秒。

redis.service.ts 中添加以下内容:

...

import { ConfigService } from '@nestjs/config';
import { createHash } from 'crypto';
import { getBaseConfig } from 'src/common/config';

@Injectable()
export class RedisService {
  constructor(
    @InjectRedis() private readonly redis: Redis,
    private readonly configService: ConfigService,
  ) {}

  generateCaptchaKey(ip: string, userAgent: string) {
    const data = `${ip}:${userAgent}`;
    const key = createHash('sha256').update(data).digest('hex');
    return `captcha:${key}`;
  }

  setCaptcha(key: string, value: string) {
    const expiresIn = getBaseConfig(this.configService).captchaExpireIn;
    return this.redis.set(key, value, 'EX', expiresIn);
  }

  getCaptcha(key: string) {
    return this.redis.get(key);
  }

  delCaptcha(key: string) {
    return this.redis.del(key);
  }
}

主要添加了四个方法,分别是:生成 key 的方法、保存验证码到 redis 中的方法、从 redis 中获取验证码数据的方法、从 redis 中删除验证码数据的方法。

现在打开 auth.module.ts 文件,导入 RedisModule

...
import { RedisModule } from 'src/redis/redis.module';

...
imports: [RedisModule],

修改 auth.service.ts 的内容为:

import { Injectable } from '@nestjs/common';
import { create as createCaptcha } from 'svg-captcha';
import { RedisService } from 'src/redis/redis.service';

@Injectable()
export class AuthService {
  constructor(
    private readonly redisService: RedisService,
  ) {}

  generateCaptcha(ip: string, userAgent: string) {
    const captcha = createCaptcha({
      size: 4,
      noise: 2,
      color: true,
      ignoreChars: '0o1i',
      background: '#f0f0f0',
    });

    this.redisService.setCaptcha(
      this.redisService.generateCaptchaKey(ip, userAgent),
      captcha.text,
    );

    return {
      captcha: `data:image/svg+xml;base64,${Buffer.from(captcha.data).toString('base64')}`,
    };
  }
}

增加了将生成的验证码文本保存到 Redis 中的步骤。

修改 auth.controller.ts 的内容为:

import { Controller, Get, Ip, Headers } from '@nestjs/common';
import { AuthService } from './auth.service';
import { ApiOkResponse, ApiOperation } from '@nestjs/swagger';

import { AuthEntity } from './entities/auth.entity';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @ApiOperation({
    summary: '获取验证码',
  })
  @ApiOkResponse({
    type: AuthEntity,
  })
  @Get('captcha')
  getCaptcha(
    @Ip() ip: string,
    @Headers('user-agent') userAgent: string,
  ): AuthEntity {
    return this.authService.generateCaptcha(ip, userAgent);
  }
}

这里获取了客户端的 IP 与用户代理字符串,并用作缓存验证码数据的 key。你可以根据你的需求调整 key 的生成策略。

但是现在这个接口的返回值还是有点问题,它只返回了一个验证码。一般来说接口还要返回 code 与 message 信息。

6. 用全局响应拦截器统一接口返回数据格式

我们可以在接口中直接返回一个包含 code 与 message 属性的对象,但最好还是在 interceptor 中来处理,这样可以统一返回格式,也不用在每个接口里都写一遍。

首先创建 src/common/entities/base-response.entity.ts 文件并添加两个实体类,这将是接口要返回数据的基本格式:

import { HttpStatus } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';

export class BaseResponseEntity<T = unknown> {
  @ApiProperty({ default: 200, description: '状态码' })
  statusCode: HttpStatus;

  @ApiProperty({ description: '返回数据' })
  data?: T;

  @ApiProperty({ default: 'Success', description: '返回信息' })
  message: string;
}

export class NullResponseEntity extends BaseResponseEntity<null> {
  @ApiProperty({ default: null })
  data: null;
}

然后创建 src/common/interceptor/base-response.interceptor.ts 文件并添加以下内容:

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  StreamableFile,
} from '@nestjs/common';
import { BaseResponseEntity } from 'src/common/entities/base-response.entity';
import { throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';

@Injectable()
export class BaseResponseInterceptor<T> implements NestInterceptor<T, any> {
  intercept(context: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(
      map((data) => {
        if (data instanceof Error) {
          throw data;
        }

        if (data instanceof StreamableFile || Buffer.isBuffer(data)) {
          return data;
        }

        const result: BaseResponseEntity<T> = {
          statusCode: 200,
          data: data ?? null,
          message: 'Success',
        };

        if (data?.statusCode || data?.message) {
          result.statusCode = data?.statusCode ?? 200;
          result.message = data?.message ?? 'Success';
          result.data = data?.data ?? null;
        }

        return result;
      }),
      catchError((err) => {
        return throwError(() => err);
      }),
    );
  }
}

这段代码主要就是在接口格式不对时,添加 statusCodemessage 两个属性,并设置默认值。

最后,需要在 app.module.ts 中的 providers 注册这个响应拦截器:

...
import { BaseResponseInterceptor } from 'src/common/interceptor/base-response.interceptor';
import { APP_INTERCEPTOR } from '@nestjs/core';

// 添加到 providers 数组中
{
    provide: APP_INTERCEPTOR,
    useClass: BaseResponseInterceptor,
}

现在获取验证码接口就会自动返回 { statusCode: number; data: { captcha: string }; message: string } 格式的数据了。

我们还可以在拦截器中添加日志服务,来记录接口的返回信息。修改拦截器的内容为:

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  StreamableFile,
  Inject,
} from '@nestjs/common';
import { BaseResponseEntity } from 'src/common/entities/base-response.entity';
import { throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
// 新增部分
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';
import type { Response, Request } from 'express';

@Injectable()
export class BaseResponseInterceptor<T> implements NestInterceptor<T, any> {
  // 注入日志服务
  constructor(
    @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
  ) {}

  intercept(context: ExecutionContext, next: CallHandler) {
    const ctx = context.switchToHttp();
    const response = ctx.getResponse<Response>();
    const message = response.statusMessage ?? 'Success';
    const request = ctx.getRequest<Request>();

    return next.handle().pipe(
      map((data) => {
        if (data instanceof Error) {
          throw data;
        }

        if (data instanceof StreamableFile || Buffer.isBuffer(data)) {
          return data;
        }

        const result: BaseResponseEntity<T> = {
          statusCode: 200,
          data: data ?? null,
          message,
        };

        if (data?.statusCode || data?.message) {
          result.statusCode = data?.statusCode ?? 200;
          result.message = data?.message ?? message;
          result.data = data?.data ?? null;
        }
        // 调用记录日志方法
        this.addLogger(request, result);

        return result;
      }),
      catchError((err) => {
        return throwError(() => err);
      }),
    );
  }
  // 新增方法
  addLogger(req: Request, res: BaseResponseEntity<any>) {
    const { method, originalUrl, body, query, params, ip } = req;
    this.logger.info('response', {
      req: {
        method,
        url: originalUrl,
        body,
        query,
        params,
        ip,
      },
      res: res.data?.captcha
        ? { ...res, data: { captcha: 'data:image/png;base64,' } }
        : res,
    });
  }
}

现在请求验证码接口,就会在对应的日志文件中打印信息,如下图:

image.png

这里因为验证码大小的问题,所以没有将验证码的 base64 字符串记录到日志中。如果你有需要,可以调整相应代码。

7. 添加 Swagger 响应类型装饰器

现在修改 auth.controller.ts 中的获取验证码接口的 Swagger 类型为 @ApiOkResponse({ type: BaseResponseEntity<AuthEntity> })

这时我们在浏览器中打开 http://localhost:3000/api 链接,查看 api 文档会发现 data 的类型不对:

image.png

这是因为 Swagger 不支持 BaseResponseEntity<AuthEntity> 这种泛型写法,需要特殊处理这种 data 为传入泛型的情况。

下面我们来解决这个问题。

首先创建 src/common/decorator/api-base-response.decorator.ts 文件,添加以下内容:

import { applyDecorators, Type } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
import {
  BaseResponseEntity,
  NullResponseEntity,
} from '../entities/base-response.entity';

export const ApiBaseResponse = <TModel extends Type<any>>(
  model?: TModel,
  type: 'string' | 'array' | 'object' = 'object',
) => {
  if (!model) {
    return ApiOkResponse({ type: NullResponseEntity });
  }

  return applyDecorators(
    ApiExtraModels(BaseResponseEntity, model),
    ApiOkResponse({
      schema: {
        allOf: [
          { $ref: getSchemaPath(BaseResponseEntity) },
          {
            properties: {
              data: {
                type: type,
                $ref: type === 'object' ? getSchemaPath(model) : undefined,
                items:
                  type === 'array' ? { $ref: getSchemaPath(model) } : undefined,
              },
            },
          },
        ],
      },
    }),
  );
};

使用了 ApiExtraModelsgetSchemaPath 方法来加载与合并类型。

最后,修改 auth.controller.ts 的内容:

import { Controller, Get, Ip, Headers } from '@nestjs/common';
import { AuthService } from './auth.service';
import { ApiOperation } from '@nestjs/swagger';
import { ApiBaseResponse } from 'src/common/decorator/api-base-response.decorator';
import { AuthEntity } from './entities/auth.entity';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @ApiOperation({
    summary: '获取验证码',
  })
  @ApiBaseResponse(AuthEntity)
  @Get('captcha')
  getCaptcha(
    @Ip() ip: string,
    @Headers('user-agent') userAgent: string,
  ): AuthEntity {
    return this.authService.generateCaptcha(ip, userAgent);
  }
}

现在,刷新一下 Swagger 文档后,会发现可以正常显示接口返回值类型了。如下图:

image.png

8. 单元测试

单元测试是现代开发的基础。单元测试的主要目的是确保每个单元都能独立地正常工作,这对于提高整个程序的质量、可靠性和可维护性至关重要。

安装 automock 相关库:

pnpm add -D @automock/jest @automock/adapters.nestjs

Automock 是一个功能强大的独立库,专为单元测试而设计。它在内部利用 TypeScript Reflection API 生成模拟对象,通过自动模拟类的外部依赖来简化测试过程。Automock 能够简化测试开发并专注于编写健壮且高效的单元测试。

package.jsonjest 属性中添加以下内容:

"moduleNameMapper": {
      "^src/(.*)$": "<rootDir>/$1"
},
"moduleDirectories": [
      "node_modules",
      "src"
],

以上配置设置了jest的路径映射与查找目录,防止jest找不到相关文件。

8.1 添加 RedisService 的单元测试

/src/redis 目录下新建一个 test 文件夹,并将 redis.service.spec.ts 文件移入该文件夹。

修改 redis.service.spec.ts 的内容为:

import { TestBed } from '@automock/jest';
import { RedisService } from '../redis.service';
import Redis from 'ioredis';
import { createHash } from 'crypto';
import { getBaseConfig } from 'src/common/config';

jest.mock('src/common/config');

describe('RedisService Unit Test', () => {
  let redisService: RedisService;
  let redis: jest.Mocked<Redis>;

  const mockIp = '127.0.0.1';
  const mockUserAgent = 'test-agent';
  const mockValue = 'test-value';
  const mockBaseConfig = {
    captchaExpireIn: 300,
  };

  beforeAll(() => {
    const { unit, unitRef } = TestBed.create(RedisService).compile();

    redisService = unit;
    redis = unitRef.get('default_IORedisModuleConnectionToken');
  });

  beforeEach(() => {
    jest.clearAllMocks();
    (getBaseConfig as jest.Mock).mockReturnValue(mockBaseConfig);
  });

  describe('Captcha Operations', () => {
    describe('generateCaptchaKey', () => {
      it('should generate correct captcha key', () => {
        const expectedHash = createHash('sha256')
          .update(`${mockIp}:${mockUserAgent}`)
          .digest('hex');

        const result = redisService.generateCaptchaKey(mockIp, mockUserAgent);

        expect(result).toBe(`captcha:${expectedHash}`);
      });
    });

    describe('setCaptcha', () => {
      it('should set captcha with default expiration', async () => {
        const mockKey = 'test-key';
        redis.set.mockResolvedValue('OK');

        await redisService.setCaptcha(mockKey, mockValue);

        expect(redis.set).toHaveBeenCalledWith(
          mockKey,
          mockValue,
          'EX',
          mockBaseConfig.captchaExpireIn,
        );
      });
    });

    describe('getCaptcha', () => {
      it('should get captcha value', async () => {
        const mockKey = 'test-key';
        redis.get.mockResolvedValue(mockValue);

        const result = await redisService.getCaptcha(mockKey);

        expect(result).toBe(mockValue);
        expect(redis.get).toHaveBeenCalledWith(mockKey);
      });
    });

    describe('delCaptcha', () => {
      it('should delete captcha', async () => {
        const mockKey = 'test-key';
        redis.del.mockResolvedValue(1);

        await redisService.delCaptcha(mockKey);

        expect(redis.del).toHaveBeenCalledWith(mockKey);
      });
    });
  });
});

TestBed.create(RedisService).compile() 会自动分析 RedisService 的依赖关系,并为所有依赖创建模拟对象。

注意:使用 InjectRedis 注入的 Redis 服务的 key 为 default_IORedisModuleConnectionToken,只能使用这个 key 获取 Redis 实例。

8.2 添加 auth 模块的单元测试

现在,在 /src/auth 目录下创建一个 test 文件夹,并将 auth.service.spec.tsauth.controller.spec.ts 文件移入该文件夹。

打开 auth.service.spec.ts 文件,更改为:

import { TestBed } from '@automock/jest';
import { AuthService } from '../auth.service';
import { RedisService } from 'src/redis/redis.service';
import { create as createCaptcha } from 'svg-captcha';

jest.mock('svg-captcha');
jest.mock('src/common/config');

describe('AuthService Unit Test', () => {
  let authService: AuthService;
  let redisService: jest.Mocked<RedisService>;

  const mockIp = '127.0.0.1';
  const mockUserAgent = 'test-agent';
  const mockCaptchaText = 'abcd';
  const mockCaptchaSvg = '<svg>test</svg>';
  const mockCaptchaKey = 'captcha-key';

  beforeAll(() => {
    const { unit, unitRef } = TestBed.create(AuthService).compile();

    authService = unit;
    redisService = unitRef.get(RedisService);
  });

  beforeEach(() => {
    jest.clearAllMocks();
    (createCaptcha as jest.Mock).mockReturnValue({
      text: mockCaptchaText,
      data: mockCaptchaSvg,
    });
  });

  describe('generateCaptcha', () => {
    beforeEach(() => {
      redisService.generateCaptchaKey.mockReturnValue(mockCaptchaKey);
      redisService.setCaptcha.mockResolvedValue('OK');
    });

    it('should generate captcha and store in redis', () => {
      const result = authService.generateCaptcha(mockIp, mockUserAgent);

      expect(createCaptcha).toHaveBeenCalledWith({
        size: 4,
        noise: 2,
        color: true,
        ignoreChars: '0o1i',
        background: '#f0f0f0',
      });

      expect(redisService.generateCaptchaKey).toHaveBeenCalledWith(
        mockIp,
        mockUserAgent,
      );

      expect(redisService.setCaptcha).toHaveBeenCalledWith(
        mockCaptchaKey,
        mockCaptchaText,
      );

      expect(result).toEqual({
        captcha: `data:image/svg+xml;base64,${Buffer.from(mockCaptchaSvg).toString('base64')}`,
      });
    });
  });

  describe('validateCaptcha', () => {
    beforeEach(() => {
      redisService.generateCaptchaKey.mockReturnValue(mockCaptchaKey);
    });

    it('should return true for valid captcha and delete it', async () => {
      redisService.getCaptcha.mockResolvedValue(mockCaptchaText);

      const result = await authService.validateCaptcha(
        mockIp,
        mockUserAgent,
        mockCaptchaText,
      );

      expect(result).toBe(true);
      expect(redisService.delCaptcha).toHaveBeenCalledWith(mockCaptchaKey);
    });

    it('should return false for invalid captcha', async () => {
      redisService.getCaptcha.mockResolvedValue(mockCaptchaText);

      const result = await authService.validateCaptcha(
        mockIp,
        mockUserAgent,
        'wrong',
      );

      expect(result).toBe(false);
      expect(redisService.delCaptcha).not.toHaveBeenCalled();
    });

    it('should return false when captcha not found', async () => {
      redisService.getCaptcha.mockResolvedValue(null);

      const result = await authService.validateCaptcha(
        mockIp,
        mockUserAgent,
        mockCaptchaText,
      );

      expect(result).toBe(false);
    });
  });
});

TestBed.create(AuthService).compile() 会自动分析 AuthService 的依赖关系,并为所有依赖创建模拟对象。

接下来修改 auth.controller.spec.ts 的内容为:

import { TestBed } from '@automock/jest';
import { AuthController } from '../auth.controller';
import { AuthService } from '../auth.service';
import { AuthEntity } from '../entities/auth.entity';

describe('AuthController Unit Test', () => {
  let authController: AuthController;
  let authService: jest.Mocked<AuthService>;

  beforeAll(() => {
    const { unit, unitRef } = TestBed.create(AuthController).compile();

    authController = unit;
    authService = unitRef.get(AuthService);
  });

  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should be defined', () => {
    expect(authController).toBeDefined();
  });

  it('should return a captcha', () => {
    const ip = '127.0.0.1';
    const userAgent = 'Mozilla/5.0';
    const mockCaptcha = new AuthEntity();

    authService.generateCaptcha.mockReturnValue(mockCaptcha);

    const result = authController.getCaptcha(ip, userAgent);

    expect(result).toBe(mockCaptcha);
    expect(authService.generateCaptcha).toHaveBeenCalledWith(ip, userAgent);
  });
});

现在我们可以运行 pnpm run test 命令测试 authredis 模块了。在终端中可以看到测试都通过了。

也可以运行 pnpm run test:cov 命令查看测试覆盖率。

测试代码使用了 Claude 3.5 SonnetGithub copliot 配合生成。

下一章节见~