如何使用Nest.js APIs的持续集成

554 阅读18分钟

Nest.js是一个用TypeScript构建的可扩展和高效的服务器端Node.js框架。Nest.js的创建是为了给Node.js开发领域提供一种结构性设计模式。它受到Angular.js的启发,在引擎盖下使用Express.js。Nest.js与大多数Express.js中间件兼容。

在本教程中,我将带领你用Nest.js构建一个RESTful API。该教程将让你熟悉Nest.js的基本原则和构建模块。我还将演示为每个 API 端点编写测试的推荐方法。在教程的最后,我将向你展示如何使用CircleCI实现测试过程的自动化。

先决条件

要想从本教程中获得最大的收获,你将需要一些东西。

我们在这篇文章中建立的RESTful API将提供端点来创建一个带有名称、描述和价格的产品。我们将编辑、删除和检索单个产品,也将检索保存在数据库中的整个产品列表。

本教程使用MySQL作为首选的关系型数据库,并将其与TypeORM相结合。不过Nest.js是与数据库无关的,所以你可以选择与你喜欢的任何数据库一起工作。

设置Nest.js应用程序

运行这个命令来创建一个新的应用程序。

nest new nest-starter-testing

在运行nest 命令后,你会被提示选择一个软件包管理器。选择npm ,然后按回车键,开始安装Nest.js。这个过程在nest-starter-testing 文件夹中创建一个新项目,并安装其所有需要的依赖。在运行应用程序之前,使用npm ,安装一个验证库,你将在本教程的后面使用。

npm install class-validator --save

移动到应用程序文件夹中,使用命令启动应用程序。

// move into the project
cd nest-starter-testing

// start the server
npm run start:dev

这将在默认的3000 端口上启动该应用程序。在你喜欢的浏览器中导航到http://localhost:3000 ,以查看它。

Nest.js Default page

配置和连接Nest.js到数据库

TypeORM是一个流行的对象关系映射器(ORM),用于TypeScript和JavaScript应用程序。为了方便它与Nest.js应用程序的集成,你需要为它安装一个附带的软件包,以及一个用于MySQL的Node.js驱动程序。要做到这一点,按CTRL + C停止应用程序的运行,然后运行这个命令。

npm install --save @nestjs/typeorm typeorm mysql

当安装过程完成后,你可以将TypeOrmModule ,导入到应用程序的根部。

更新 TypeScript 根模块

Nest.js的构建块,模块是用@Module 装饰的TypeScript文件。模块提供了Nest.js用来组织应用程序结构的元数据。./src/app.module.ts 中的根模块是顶级模块。Nest.js 建议将一个大型应用程序分成多个模块。它有助于维护应用程序的结构。

要创建与数据库的连接,打开./src/app.module.ts 文件,用这段代码替换其内容。

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { join } from 'path';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: DB_USER,
      password: DB_PASSWORD,
      database: 'test_db',
      entities: [join(__dirname, '**', '*.entity.{ts,js}')],
      synchronize: true,
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

注意用你的凭证替换DB_USERDB_PASSWORD

我们通过将TypeOrmModule 导入根AppModule 并指定连接选项,建立了与数据库的连接。这些包括数据库的详细信息和实体文件的存储目录。我将在下一节详细介绍实体文件的情况。

配置数据库连接

在本教程开始时的先决条件中,我提到了MySQL下载页面。在你下载后,你将需要配置数据库,以便它能为这个应用程序工作。

在你的终端,通过运行登录到MySQL。

mysql -u root -p

输入你在MySQL安装时设置的密码。现在运行。

ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password';

用你的密码替换'密码'。

这个命令为MySQL的Node.js驱动设置了首选的认证。要创建数据库,请运行。

CREATE DATABASE test_db;

为Nest.js应用程序创建产品模块、服务和控制器

现在你已经配置了数据库连接,我们将开始为应用程序创建更多结构。

生成一个模块

首先为Product 生成一个模块。这将是一个新的模块,用于分组所有与产品相关的项目。开始时,运行这个命令。

nest generate module product

上述命令将在src 目录中创建一个新的product 文件夹,在product.module.ts 文件中定义ProductModule ,并通过导入新创建的ProductModule 自动更新app.module.ts 文件中的根模块。./src/product/product.module.ts 文件现在将是空的,如下图所示。

import { Module } from '@nestjs/common';
@Module({})
export class ProductModule {}

创建一个实体

为了给Nest.js应用程序创建一个合适的数据库模式,TypeORM支持创建一个实体。一个实体是一个映射到特定数据库表的类。在这种情况下,它是产品表。

按照Nest.js应用程序的正确结构,在src/product 文件夹中创建一个新文件,并将其命名为product.entity.ts 。然后将此代码粘贴到其中。

import { PrimaryGeneratedColumn, BaseEntity, Column, Entity } from 'typeorm';

@Entity()
export class Product extends BaseEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column()
  description: string;

  @Column()
  price: string;
}

使用从typeorm 模块导入的装饰器,我们为产品表创建了四列。其中,主键列是唯一标识产品的。

创建一个数据传输对象

数据传输对象(DTO)有助于为进入应用程序的数据创建和验证一个适当的数据结构。例如,当你从前端向Node.js后端发送HTTP POST请求时,你需要从表单中提取发布的内容,并将其解析为后端代码可以轻松消费的格式。DTO有助于指定从请求正文中提取的对象的形状,并提供一种方法来轻松插入验证。

要为这个应用程序设置DTO,在src/product 目录中创建一个新的文件夹,并将其命名为dto 。接下来,在新创建的文件夹中创建一个文件,并将其称为create-product.dto.ts 。为它使用这个内容。

import { IsString } from 'class-validator';

export class CreateProductDTO {
  @IsString()
  name: string;

  @IsString()
  description: string;

  @IsString()
  price: string;
}

在这里,我们定义了一个类来表示CreateProductDTO ,还添加了一点验证,以确保字段的数据类型是字符串。接下来,我们将创建一个存储库来帮助将数据直接持久化到我们的应用程序数据库中。

创建一个自定义的资源库

一般来说,ORM中的存储库,如TypeORM,主要是作为一个持久层。它包含一些方法,如。

  • save()
  • delete()
  • find()

这有助于与应用程序的数据库进行通信。在本教程中,我们将为我们的产品实体创建一个自定义的资源库,扩展 TypeORM 的基础资源库,并为特定的查询创建一些自定义方法。首先,导航到src/product 文件夹,创建一个新的文件,命名为product.repository.ts 。一旦你完成了,把这些内容粘贴到其中。

import { Repository, EntityRepository } from 'typeorm';
import { Product } from './product.entity';
import { CreateProductDTO } from './dto/create-product.dto';

@EntityRepository(Product)
export class ProductRepository extends Repository<Product> {

  public async createProduct(
    createProductDto: CreateProductDTO,
  ): Promise<Product> {
    const { name, description, price } = createProductDto;

    const product = new Product();
    product.name = name;
    product.description = description;
    product.price = price;

    await product.save();
    return product;
  }

  public async editProduct(
    createProductDto: CreateProductDTO,
    editedProduct: Product,
  ): Promise<Product> {
    const { name, description, price } = createProductDto;

    editedProduct.name = name;
    editedProduct.description = description;
    editedProduct.price = price;
    await editedProduct.save();

    return editedProduct;
  }
}

从上面的代码中,我们定义了两个方法。

  • createProduct():这个方法以createProductDto 类为参数,该类将被用来提取HTTP请求的正文。然后我们解构createProductDto ,并使用这些值来创建一个新的产品。
  • editProduct:在这里,需要编辑的产品细节被传递给这个方法,基于来自客户端的新值,指定的细节将被更新并相应地保存在数据库中。

生成一个Nest.js服务

服务,也被称为提供者,是Nest.js的另一个构建块,被归类为关注点分离原则。它被设计用来处理和抽象复杂的业务逻辑,使其远离控制器,并返回适当的响应。Nest.js中的所有服务都是用@Injectable() 装饰器装饰的,这使得它很容易将服务注入任何其他文件中,如控制器和模块。

使用这个命令为一个产品创建一个服务。

nest generate service product

运行上面的命令后,你会在终端看到这样的输出。

CREATE /src/product/product.service.spec.ts (467 bytes)
CREATE /src/product/product.service.ts (91 bytes)
UPDATE /src/product/product.module.ts (167 bytes)

nest 命令在src/product 文件夹中创建了两个新文件。这两个文件是。

  • product.service.spec.ts 文件将被用来为产品服务文件中将要创建的方法编写单元测试。
  • product.service.ts 文件保存了应用程序的所有业务逻辑。

nest 命令也已经导入了新创建的服务,并将其添加到product.module.ts 文件中。

接下来,你将在product.service.ts 文件中加入创建和检索所有产品的方法,以及获取、更新和删除某个特定产品的细节的方法。打开该文件,将其内容替换成这样。

import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Product } from './product.entity';
import { CreateProductDTO } from './dto/create-product.dto';
import { ProductRepository } from './product.repository';

@Injectable()
export class ProductService {
  constructor(
    @InjectRepository(ProductRepository)
    private productRepository: ProductRepository,
  ) {}

  public async createProduct(
    createProductDto: CreateProductDTO,
  ): Promise<Product> {
    return await this.productRepository.createProduct(createProductDto);
  }


  public async getProducts(): Promise<Product[]> {
    return await this.productRepository.find();
  }


  public async getProduct(productId: number): Promise<Product> {
    const foundProduct = await this.productRepository.findOne(productId);
    if (!foundProduct) {
      throw new NotFoundException('Product not found');
    }
    return foundProduct;
  }


  public async editProduct(
    productId: number,
    createProductDto: CreateProductDTO,
  ): Promise<Product> {
    const editedProduct = await this.productRepository.findOne(productId);
    if (!editedProduct) {
      throw new NotFoundException('Product not found');
    }
    return this.productRepository.editProduct(createProductDto, editedProduct);
  }


  public async deleteProduct(productId: number): Promise<void> {
    await this.productRepository.delete(productId);
  }
}

在这里,我们导入了应用程序所需的模块,并创建了单独的方法来。

  • 创建一个新产品。createProduct()
  • 获取所有创建的产品。getProducts()
  • 检索单个产品的细节。getProduct()
  • 编辑一个特定产品的细节。editProduct()
  • 删除一个产品。deleteProduct()

值得注意的是,我们把之前创建的ProductRepository 注入到这个服务中,以便于与数据库交互和通信。下面是显示这个文件的一个片段。

  ...

  constructor(
    @InjectRepository(ProductRepository)
    private productRepository: ProductRepository,
  ) {}

  ...

这只有在我们将ProductRepository 也导入到产品模块中时才会起作用。我们将在以后的教程中这样做。

生成一个Nest.js控制器

Nest.js中控制器的职责是接收和处理来自应用程序客户端的HTTP请求,并根据业务逻辑返回相应的响应。路由机制是由连接到每个控制器顶部的装饰器@Controller() ,通常决定哪个控制器接收哪些请求。要为我们的项目创建一个新的控制器文件,从终端运行这个命令。

nest generate controller product --no-spec

你会看到这个输出。

CREATE /src/product/product.controller.ts (103 bytes)
UPDATE /src/product/product.module.ts (261 bytes)

因为我们不会为这个控制器写一个测试,我们使用--no-spec 选项来指示nest 命令不要为控制器生成.spec.ts 文件。打开src/product/product.controller.ts 文件,把它的代码替换成这样。

import {
  Controller,
  Post,
  Body,
  Get,
  Patch,
  Param,
  Delete,
} from '@nestjs/common';
import { ProductService } from './product.service';
import { CreateProductDTO } from './dto/create-product.dto';
import { Product } from './product.entity';

@Controller('product')
export class ProductController {
  constructor(private productService: ProductService) {}

  @Post('create')
  public async createProduct(
    @Body() createProductDto: CreateProductDTO,
  ): Promise<Product> {
    const product = await this.productService.createProduct(createProductDto);
    return product;
  }

  @Get('all')
  public async getProducts(): Promise<Product[]> {
    const products = await this.productService.getProducts();
    return products;
  }

  @Get('/:productId')
  public async getProduct(@Param('productId') productId: number) {
    const product = await this.productService.getProduct(productId);
    return product;
  }

  @Patch('/edit/:productId')
  public async editProduct(
    @Body() createProductDto: CreateProductDTO,
    @Param('productId') productId: number,
  ): Promise<Product> {
    const product = await this.productService.editProduct(
      productId,
      createProductDto,
    );
    return product;
  }

  @Delete('/delete/:productId')
  public async deleteProduct(@Param('productId') productId: number) {
    const deletedProduct = await this.productService.deleteProduct(productId);
    return deletedProduct;
  }
}

在这个文件中,我们导入了必要的模块来处理HTTP请求,并将之前创建的ProductService ,注入到控制器中。这是通过构造函数完成的,以利用已经在ProductService 中定义的函数。 接下来,我们创建了这些异步方法。

  • createProduct() 方法用于处理从客户端发送的POST HTTP请求,以创建一个新的产品并将其持久化在数据库中。
  • getProducts() 方法从数据库中获取整个产品列表。
  • getProduct() 方法将productId 作为参数,并使用它从数据库中检索具有该唯一的productId 的产品的详细信息。
  • editProduct() 方法用于编辑某个特定产品的细节。
  • deleteProduct() 方法也接受唯一的productId ,以识别一个特定的产品并从数据库中删除它。

这里需要注意的另一件事是,我们定义的每个异步方法都有一个元数据装饰器作为HTTP动词。它们吸收了一个前缀,Nest.js用它来进一步识别和指向应该处理请求并作出相应响应的方法。

例如,我们创建的ProductController 有一个前缀product 和一个名为createProduct() 的方法,该方法吸收了前缀create 。这意味着任何指向product/create (http://localhost:3000/product/create)的GET 请求都将由createProduct() 方法处理。这个过程对于在这个ProductController 中定义的其他方法也是一样的。

更新产品模块

现在,控制器和服务已经创建,并通过使用nest 命令自动添加到ProductModule ,我们需要更新ProductModule 。打开./src/product/product.module.ts ,用这段代码更新其内容。

import { Module } from '@nestjs/common';
import { ProductController } from './product.controller';
import { ProductService } from './product.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProductRepository } from './product.repository';

@Module({
  imports: [TypeOrmModule.forFeature([ProductRepository])], // add this
  controllers: [ProductController],
  providers: [ProductService],
})

export class ProductModule {}

在这里,我们将ProductRepository 类传递给TypeOrm.forFeature() 方法。现在这将使ProductRepository 类的使用成为可能。

现在应用程序已经准备好了,我们可以运行它来测试到目前为止创建的所有端点。从终端运行这个程序。

npm run start:dev

这将在http://localhost:3000 上启动应用程序。在这一点上,你可以使用像Postman这样的工具来测试API。Postman是一个测试工具,用于在部署到生产之前确认和检查你的API的行为。

使用Nest.js应用程序创建一个产品

Create product

在这里,我们向http://localhost:3000/product/create 端点发送了一个POST HTTP请求,其中有一个产品的namedescription ,和price 。创建的产品被作为响应返回。

获取产品

Get products

我们向http://localhost:3000/product/all 发出了一个 GET HTTP 请求,以检索所创建的整个产品列表。

获取产品

Get product

为了检索单个产品的详细信息,我们向http://localhost:3000/product/2 端点发送了一个GET HTTP请求。请注意,2 是我们感兴趣的产品的唯一productId 。请随意尝试其他值。

编辑一个产品

Edit product

在这里,我们向http://localhost:3000/product/edit/2 端点发送了一个PATCH HTTP请求,我们更新了用2productId 确定的产品的细节。

为Nest.js应用程序编写测试

现在,我们的API已按预期工作,在这一节中,我们将重点为先前创建的ProductService 类中定义的方法编写测试。我们觉得只测试应用程序的这一部分是合适的,因为它处理了大部分的业务逻辑。

Nest.js有一个内置的测试基础设施,这意味着我们不需要在测试方面设置很多配置。尽管Nest.js与测试工具无关,但它提供了与Jest开箱即用的集成。Jest将提供断言函数和test-double实用程序,以帮助嘲弄。

目前,product.service.spec.ts 文件有这样的代码。

import { Test, TestingModule } from '@nestjs/testing';
import { ProductService } from './product.service';

describe('ProductService', () => {
  let service: ProductService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [ProductService],
    }).compile();
    service = module.get<ProductService>(ProductService);
  });

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

我们将添加更多的测试,使其完全覆盖ProductService 内定义的所有方法。

为 "创建 "和 "获取 "产品的方法写一个测试

记住,我们没有使用测试驱动的开发方法来开始这个项目。因此,我们将编写测试,以确保ProductService 内的所有业务逻辑接收到适当的参数并返回预期的响应。要开始,打开product.service.spec.ts 文件,并将其内容替换为这样。

import { Test, TestingModule } from '@nestjs/testing';
import { ProductService } from './product.service';
import { ProductRepository } from './product.repository';
import { NotFoundException } from '@nestjs/common';

describe('ProductService', () => {
  let productService;
  let productRepository;
  const mockProductRepository = () => ({
    createProduct: jest.fn(),
    find: jest.fn(),
    findOne: jest.fn(),
    delete: jest.fn(),
  });

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ProductService,
        {
          provide: ProductRepository,
          useFactory: mockProductRepository,
        },
      ],
    }).compile();
    productService = await module.get<ProductService>(ProductService);
    productRepository = await module.get<ProductRepository>(ProductRepository);
  });

  describe('createProduct', () => {
    it('should save a product in the database', async () => {
      productRepository.createProduct.mockResolvedValue('someProduct');
      expect(productRepository.createProduct).not.toHaveBeenCalled();
      const createProductDto = {
        name: 'sample name',
        description: 'sample description',
        price: 'sample price',
      };
      const result = await productService.createProduct(createProductDto);
      expect(productRepository.createProduct).toHaveBeenCalledWith(
        createProductDto,
      );
      expect(result).toEqual('someProduct');
    });
  });

  describe('getProducts', () => {
    it('should get all products', async () => {
      productRepository.find.mockResolvedValue('someProducts');
      expect(productRepository.find).not.toHaveBeenCalled();
      const result = await productService.getProducts();
      expect(productRepository.find).toHaveBeenCalled();
      expect(result).toEqual('someProducts');
    });
  });
});

首先,我们从@nestjs/testing 模块中导入TestTestingModule 包。这提供了方法createTestingModule ,它创建了一个测试模块,将作为测试中先前定义的模块。在这个testingModuleproviders 数组由ProductService 和一个mockProductRepository 组成,以模拟使用工厂的自定义ProductRepository

然后我们创建了测试套件的两个不同的组件,以确保我们可以创建一个产品并检索产品的列表。

让我们再添加几个脚本来测试应用程序中检索和删除单个产品的功能。还是在product.service.spec.ts 文件中,通过在我们现有的测试脚本下面添加这段代码来更新。

import { Test, TestingModule } from '@nestjs/testing';
import { ProductService } from './product.service';
import { ProductRepository } from './product.repository';
import { NotFoundException } from '@nestjs/common';

describe('ProductService', () => {
  ...
  describe('getProduct', () => {
    it('should retrieve a product with an ID', async () => {
      const mockProduct = {
        name: 'Test name',
        description: 'Test description',
        price: 'Test price',
      };
      productRepository.findOne.mockResolvedValue(mockProduct);
      const result = await productService.getProduct(1);
      expect(result).toEqual(mockProduct);
      expect(productRepository.findOne).toHaveBeenCalledWith(1);
    });

    it('throws an error as a product is not found', () => {
      productRepository.findOne.mockResolvedValue(null);
      expect(productService.getProduct(1)).rejects.toThrow(NotFoundException);
    });
  });

  describe('deleteProduct', () => {
    it('should delete product', async () => {
      productRepository.delete.mockResolvedValue(1);
      expect(productRepository.delete).not.toHaveBeenCalled();
      await productService.deleteProduct(1);
      expect(productRepository.delete).toHaveBeenCalledWith(1);
    });
  });
});

为了获得一个特定的产品,我们简单地用一些默认的细节创建了一个mockProduct ,并验证了我们可以检索和删除一个产品。

在GitHub上查看完整的测试脚本。

在本地运行测试

在运行测试之前,你应该删除为位于src/app.controller.spec.tsAppController 创建的测试文件,如果你想为它写一个测试,你可以以后手动创建。现在,继续运行测试,用。

npm run test

你会看到这个输出。

 PASS  src/product/product.service.spec.ts
  ProductService
    createProduct
      ✓ should save a product in the database (11ms)
    getProducts
      ✓ should get all products (2ms)
    getProduct
      ✓ should retrieve a product with an ID (2ms)
      ✓ throws an error as a product is not found (4ms)
    deleteProduct
      ✓ should delete product (2ms)

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        1.951s, estimated 3s
Ran all test suites.

Test result terminal

自动测试

现在你已经用Nest.js构建了一个完整的RESTful API,并对其业务逻辑进行了测试。接下来,你将需要添加配置文件来设置CircleCI的持续集成。持续集成有助于确保代码的更新不会破坏任何现有功能。一旦它被推送到GitHub仓库,测试将自动运行。

首先,创建一个名为.circleci 的文件夹,在其中创建一个名为config.yml 的新文件。打开新文件,把这段代码粘贴进去。

version: 2.1
orbs:
  node: circleci/node@3.0.0
jobs:
  build-and-test:
    executor:
      name: node/default
    steps:
      - checkout
      - node/install-packages
      - run:
          command: npm run test
workflows:
  build-and-test:
    jobs:
      - build-and-test

在这里,我们指定了要使用的CircleCI的版本,并使用CircleCINode orb来设置和安装Node.js。然后,我们继续安装项目的所有依赖项。最后的命令是实际的测试命令,它运行我们的测试。

创建一个GitHub存储库

为了方便持续集成过程,把你的项目推送到GitHub

添加项目

通过导航到这个页面,在CircleCI上创建一个账户。接下来,如果你是任何组织的一部分,你将需要选择你希望工作的组织,以便在CircleCI上设置你的仓库。

一旦你进入项目页面,找到我们之前在GitHub上创建的项目,点击设置项目

Project page

这将显示一个配置页面,上面有先前创建的config.yml 文件中的细节。现在,点击开始构建

Start building page

按照提示,点击手动添加,因为我们已经包含了配置文件。

Add manually page

在下一个提示中点击开始构建

Start building prompt

你会被带到管道页面,在那里你可以看到你新运行的构建。

Pipeline page

你会看到你的管道开始自动运行,并且通过了!你还可以点击工作流程。

Build page

你也可以点击工作流程,查看构建的细节。

Test result page

所有的测试都成功运行,并显示出与我们在本地运行测试时类似的输出。随后,你所要做的就是给你的项目添加更多的功能,编写更多的测试,并推送到GitHub。持续集成管道将自动运行,测试将被执行。

使用你所学到的知识,用Nest.js构建你自己的RESTful API

Nest.js鼓励并执行Web应用程序的优秀结构。它可以帮助你的团队组织工作并遵循最佳实践。在本教程中,我们学习了如何用Nest.js构建RESTful APIs,并使用Postman测试功能。最后,我们写了几个测试,并使用CircleCI将其自动化。

在本教程中,我们重点测试了ProductService 。如果你想进一步探索,你可以将你获得的知识应用到应用程序的其他部分。