初识Jest
- 很好的错误消息和内置Mocking实用程序
- 可靠的并行运行测试
- 优先运行以前失败的测试
- 根据测试文件花费的时间重新组织测试运行
// npm run test # for unit tests // npm run test:cov # for test coverage // npm run test:e2e # for e2e tests
注意: 测试程序不支持绝对路径的导入,VSCODE自动导入的需要换成相对路径
开始unit
- 对于nest的单元测试,通常的做法是将
.spec.ts
文件保存在与它们测试的应用程序源代码相同的文件夹中。控制器、提供者、服务等都应该有自己的专用测试文件并且必须是.spec.ts
后缀 - 对于nest的端到端测试,默认这些文件位于专用的
/test/
目录下,自动化的端到端测试帮助我们确保系统的整体行为是正确的。
coffees.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing'; import { CoffeesService } from './coffees.service'; // describe块将所有与CoffeeService类相关的单元测试分组 describe('CoffeesService', () => { let service: CoffeesService; // 在每次测试之前执行的钩子函数,称为设置阶段,出此之外,还有beforeAll(),afterEach(),afterAll() beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [CoffeesService], }).compile(); // 利用这个模块获取CoffeesService,compile()引导模块及其依赖项,类似于main.ts中的bootstrap service = module.get<CoffeesService>(CoffeesService); // 然后存储在该变量中 // service = await module.resolve(CoffeesService); // 检索请求范围和瞬态范围的提供程序 }); // it表达单独测试,该测试目前仅检查是否定义了service变量 it('should be defined', () => { expect(service).toBeDefined(); }); });
行:npm run test:watch --coffees.service
<会出现依赖错误>
我们的TestingModule
仅包含1个提供程序,即CoffeesService
,理论上我们要修复错误只需将所需的提供程序添加到prociders[]
中。然而,这将违反最佳实践和单元测试背后的通用理念。单元测试应该在-isolation-中执行,但这并不意味着完全隔离,隔离是指测试不应该依赖于外部依赖,单元测试的理念是在这种情况下模拟一切,但这通常会导致难以维护的脆弱测试,并且不会带来任何重大价值。我们的CoffeesService
依赖于与数据库相关的提供者,但我们要做的最后一件事是实例化一个真实数据库的Connection
,只是为了单元测试。所以需要其他选择:无需创建复杂的Mocks或连接到真实数据库,我们真正要做的就是确保u偶有请求的提供者都可用于TestingModule
,作为临时解决方案,我们使用自定义提供程序语法来提供我们CoffeesSerice
所依赖的所有类:
providers: [ CoffeesService, { provide: Connection, useValue: {} }, { provide: getRepositoryToken(Flavor), useValue: {} }, { provide: getRepositoryToken(Coffee), useValue: {} }, // { provide: getRepositoryToken(Event), useValue: {} }, ],
getRepositoryToken
接受一个实体,返回一个InjectionToken
。为这些所有的Providers一个空对象作为值,一旦我们开始测试特定的方法,我们将用Mocks替换这些空对象。
添加单元测试
在测试包含业务逻辑的服务或类似的类时,我们更喜欢按方法对相关测试进行分组,使用方法名称作为我们的describe()块。
这里测试下面这个方法:
async findOne(id: string) { const coffee = await this.coffeeRepository.findOne(id, { //我们必须确保模拟这个coffeeRepository方法才能让我们的测试正常运行 relations: ['flavors'], }); if (!coffee) { // 我们必须通过单元测试覆盖两种不同的场景 throw new NotFoundException(`Coffee ${id} not found`); } return coffee; }
定义测试用例:
describe('findOne', () => { describe('when coffee with ID exists', () => { it('should return the coffee object', async () => { const coffeeId = '1'; const expectedCoffee = {}; const coffee = await service.findOne(coffeeId); expect(coffee).toEqual(expectedCoffee); }); }); describe('otherwise', () => { it('shuold throw the "NotFountException"', async () => {}); }); });
运行会发现:
这里理所当然的,因为我们之前使用空对象作为了我们的实体,显然里面没有定义任何方法,所以这个错误时有道理的。
最好的方法是创建一个通用函数,该函数仅返回一个Mock对象,其中包含存储库类提供的所有相同方法,然后对这些方法进行stub,以根据特定条件操纵它们的行为:
swift
// 由该存储库类型的一些属性组成,并由Jest提供的模拟函数模拟值 type MockRepository<T = any> = Partial<Record<keyof Repository<T>, jest.Mock>>; const createMockRepository = <T = any>():MockRepository<T> => ({ findOne: jest.fn(), create: jest.fn(), })
然后替换:
{ provide: getRepositoryToken(Flavor), useValue: createMockRepository() }, { provide: getRepositoryToken(Coffee), useValue: createMockRepository() },
然后第二步我们需要在我们的测试函数中使用coffeeRespository
变量,所以我们需要判断其是否定义。
describe('CoffeesService', () => { let service: CoffeesService; let coffeeRepository: MockRepository; // ...
beforeEach(async () => { // ... service = module.get<CoffeesService>(CoffeesService); // 然后存储在该变量中 coffeeRepository = module.get<MockRepository>(getRepositoryToken(Coffee)); });
模拟对应的方法:
it('should return the coffee object', async () => { const coffeeId = '1'; const expectedCoffee = {}; coffeeRepository.findOne.mockReturnValue(expectedCoffee); // 这里模拟了返回值 const coffee = await service.findOne(coffeeId); expect(coffee).toEqual(expectedCoffee); });
最后测试成功:
接下来完成失败路径的测试逻辑:
describe('otherwise', () => { it('shuold throw the "NotFountException"', async () => { const coffeeId = '1'; coffeeRepository.findOne.mockReturnValue(undefined); try{ await service.findOne(coffeeId); }catch(err){ expect(err).toBeInstanceOf(NotFoundException); expect(err.message).toEqual(`Coffee ${coffeeId} not found`); } }); });
最后也成功:
完整代码:
import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import exp from 'constants'; import { Connection, Repository } from 'typeorm'; import { CoffeesService } from './coffees.service'; import { Coffee } from './entities/coffee.entity'; import { Flavor } from './entities/flavor.entity'; // import { Event } from 'src/events/entities/event.entity'; // 由该存储库类型的一些属性组成,并由Jest提供的模拟函数模拟值 type MockRepository<T = any> = Partial<Record<keyof Repository<T>, jest.Mock>>; const createMockRepository = <T = any>():MockRepository<T> => ({ findOne: jest.fn(), create: jest.fn(), }) // describe块将所有与CoffeeService类相关的单元测试分组 describe('CoffeesService', () => { let service: CoffeesService; let coffeeRepository: MockRepository; // 在每次测试之前执行的钩子函数,称为设置阶段,出此之外,还有beforeAll(),afterEach(),afterAll() beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ CoffeesService, { provide: Connection, useValue: {} }, { provide: getRepositoryToken(Flavor), useValue: createMockRepository() }, { provide: getRepositoryToken(Coffee), useValue: createMockRepository() }, // { provide: getRepositoryToken(Event), useValue: {} }, ], }).compile(); // 利用这个模块获取CoffeesService,compile()引导模块及其依赖项,类似于main.ts中的bootstrap service = module.get<CoffeesService>(CoffeesService); // 然后存储在该变量中 // service = await module.resolve(CoffeesService); // 检索请求范围和瞬态范围的提供程序 coffeeRepository = module.get<MockRepository>(getRepositoryToken(Coffee)); }); // it表达单独测试,该测试目前仅检查是否定义了service变量 it('should be defined', () => { expect(service).toBeDefined(); }); describe('findOne', () => { describe('when coffee with ID exists', () => { it('should return the coffee object', async () => { const coffeeId = '1'; const expectedCoffee = {}; coffeeRepository.findOne.mockReturnValue(expectedCoffee); const coffee = await service.findOne(coffeeId); expect(coffee).toEqual(expectedCoffee); }); }); describe('otherwise', () => { it('shuold throw the "NotFountException"', async () => { const coffeeId = '1'; coffeeRepository.findOne.mockReturnValue(undefined); try{ await service.findOne(coffeeId); }catch(err){ expect(err).toBeInstanceOf(NotFoundException); expect(err.message).toEqual(`Coffee ${coffeeId} not found`); } }); }); }); });
开始e2e
初始文件:
import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; // 用于测试HTTP应用的高级抽象包 import { AppModule } from './../src/app.module'; describe('AppController (e2e)', () => { let app: INestApplication; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); // 实例化一个实际的Nest运行时环境,而不是单元测试中保存对service的引用 app = moduleFixture.createNestApplication(); await app.init(); }); it('/ (GET)', () => { return request(app.getHttpServer()) .get('/') // .set('Authorization', process.env.API_KEY) .expect(200) .expect('Hello World!'); }); });
运行:npm run test:e2e
这个警告意味着有一些异步操作在我们的测试中没有终止,你需要关闭应用程序:
afterAll(async () => { await app.close(); });
最终代码:
import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; // 用于测试HTTP应用的高级抽象包 import { AppModule } from './../src/app.module'; describe('AppController (e2e)', () => { let app: INestApplication; beforeAll(async () => { // 我们不想为每个端到端测试重新创建应用程序 const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); // 实例化一个实际的Nest运行时环境,而不是单元测试中保存对service的引用 app = moduleFixture.createNestApplication(); await app.init(); }); it('/ (GET)', () => { return request(app.getHttpServer()) .get('/') .expect(200) .expect('Hello World!'); }); afterAll(async () => { await app.close(); }); });
创建e2e测试
在test文件夹下创建/coffee/
文件夹,并在内创建coffee.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import { CoffeesModule } from '../../src/coffees/coffees.module'; describe('[Feature] Coffees - /coffees', () => { let app: INestApplication; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [CoffeesModule], }).compile(); app = moduleFixture.createNestApplication(); await app.init(); }); afterAll(async () => { await app.close(); }); });
加入待做事项提醒:
it.todo('Create [POST /]'); it.todo('Get ll [GET /]'); it.todo('Get one [GET /:id]'); it.todo('Update one [PATCH /:id]'); it.todo('Delete one [DELETE /:id]');
但显然此时运行会出现之前见过的依赖错误,就是没有连接数据库,总的来说有三种方法解决:
- mock
- 使用较为简单的SQLite替代
- 直接使用原数据库postgresql
这里用第三种方法:
打开docker-compose
文件
version: '3' services: db: image: postgres restart: always ports: ["5432:5432"] environment: POSTGRES_PASSWORD: pass123 test-db: image: postgres restart: always ports: ["5433:5432"] environment: POSTGRES_PASSWORD: pass123
然后在package.json下添加脚本简化操作:
"scripts": { // ... "pretest:e2e":"docker-compose up -d test-db", // 测试前创建 "test:e2e": "jest --config ./test/jest-e2e.json", "posttest:e2e":"docker-compose stop test-db && docker-compose rm -f test-db" //测试后删除 }, // npm允许我们添加生命周期钩子脚本,在对应的脚本名上加上'pre'和'post'前缀,就会在该脚本执行前后自动执行对应命令。 、
回到coffees.e2e-spec
文件并导入TypeOrmModule.forRoot()
进行初始化:
describe('[Feature] Coffees - /coffees', () => { let app: INestApplication; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [ CoffeesModule, TypeOrmModule.forRoot({ type: 'postgres', host: 'localhost', port: +5433, // 注意这里使用的是不同的端口 username: 'postgres', password: 'pass123', database: 'postgres', autoLoadEntities: true, synchronize: true, }), ], }).compile(); // ... });
createTestingModule
会创建一个应用实例,我们需要将main.ts
中的配置全部添加到该文件下:
beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [ // ... ], }).compile(); app = moduleFixture.createNestApplication(); app.useGlobalPipes(new ValidationPipe({ // 这里 whitelist: true, transform: true, forbidNonWhitelisted: true, transformOptions: { enableImplicitConversion: true, }, })); await app.init(); });
使用需要jasmine需要安装并添加最后一行:
// jest-e2e.json { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "transform": { "^.+\.(t|j)s$": "ts-jest" }, "testRunner":"jasmine2" }
添加逻辑之后的代码:
import { Test, TestingModule } from '@nestjs/testing'; import { HttpStatus, INestApplication, ValidationPipe } from '@nestjs/common'; import { CoffeesModule } from '../../src/coffees/coffees.module'; import { TypeOrmModule } from '@nestjs/typeorm'; import * as request from 'supertest'; import { CreateCoffeeDto } from 'src/coffees/dto/create-coffee.dto'; describe('[Feature] Coffees - /coffees', () => { const coffee = { name: 'Shipwreack Roast', brand: 'Buddy Brew', flavors: ['chocolate', 'vanilla'], }; let app: INestApplication; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [ CoffeesModule, TypeOrmModule.forRoot({ type: 'postgres', host: 'localhost', port: +5433, // 注意这里使用的是不同的端口 username: 'postgres', password: 'pass123', database: 'postgres', autoLoadEntities: true, synchronize: true, }), ], }).compile(); app = moduleFixture.createNestApplication(); app.useGlobalPipes( new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true, transformOptions: { enableImplicitConversion: true, }, }), ); await app.init(); }); it('Create [POST /]', () => { return request(app.getHttpServer()) .post('/coffees') .send(coffee as CreateCoffeeDto) .expect(HttpStatus.CREATED) .then(({body})=>{ // 使用jasmine进行部分撇皮,当期望在执行实际而是时只关心某些键\值对时很有用。 const expectdCoffee = jasmine.objectContaining({ ...coffee, flavors: jasmine.arrayContaining( // 每种flavor在应用中都是一个实体 coffee.flavors.map(name=> jasmine.objectContaining({name})), ), }); expect(body).toEqual(expectdCoffee); }) }); it.todo('Get ll [GET /]'); it.todo('Get one [GET /:id]'); it.todo('Update one [PATCH /:id]'); it.todo('Delete one [DELETE /:id]'); afterAll(async () => { await app.close(); }); });