Пошук уроків, статей та іншого контенту
Напишемо інтеграційні тести для репозиторіїв і сервісів із тестовою базою даних та ізоляцією даних.
Юніт-тест сервісу зазвичай підміняє репозиторій моками. Це дозволяє перевірити бізнес-логіку, але не перевіряє:
правильність опису сутності;
відповідність запитів схемі бази даних;
роботу TypeORM;
збереження та читання реальних даних;
унікальні обмеження й інші правила бази даних;
взаємодію сервісу з репозиторієм.
Інтеграційний тест запускає частину застосунку разом із тестовою базою даних. У цьому тесті репозиторій не підміняється моком.
Для тестів потрібно забезпечити:
окрему тестову базу даних;
створення схеми перед тестами;
ізоляцію даних між тестами;
очищення ресурсів після завершення тестів.
Розглянемо сутність товару та сервіс, який створює товар і шукає його за артикулом.
Для прикладу використаємо SQLite у пам'яті. Така база:
не створює файлів на диску;
існує лише протягом життя підключення;
швидко запускається;
підходить для більшості перевірок репозиторіїв і сервісів.
Встановимо залежності:
npm install @nestjs/typeorm typeorm sqlite3// src/products/product.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('products')
export class Product {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
sku: string;
@Column()
name: string;
@Column({ type: 'integer', default: 0 })
stock: number;
}// src/products/products.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Product } from './product.entity';
@Injectable()
export class ProductsService {
constructor(
@InjectRepository(Product)
private readonly productsRepository: Repository<Product>,
) {}
async create(
sku: string,
name: string,
stock: number,
): Promise<Product> {
const product = this.productsRepository.create({
sku,
name,
stock,
});
return this.productsRepository.save(product);
}
async findBySku(sku: string): Promise<Product | null> {
return this.productsRepository.findOne({
where: { sku },
});
}
}У модулі застосунку репозиторій підключається звичайним способом:
// src/products/products.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Product } from './product.entity';
import { ProductsService } from './products.service';
@Module({
imports: [TypeOrmModule.forFeature([Product])],
providers: [ProductsService],
exports: [ProductsService],
})
export class ProductsModule {}У тесті створимо окремий TestingModule. До нього потрібно додати:
TypeOrmModule.forRoot(...) для підключення до тестової бази;
TypeOrmModule.forFeature([Product]) для отримання репозиторію;
ProductsService для тестування сервісу.
// test/products.integration-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { ProductsService } from '../src/products/products.service';
import { Product } from '../src/products/product.entity';
describe('Products integration', () => {
let moduleRef: TestingModule;
let dataSource: DataSource;
let productsRepository: Repository<Product>;
let productsService: ProductsService;
beforeAll(async () => {
moduleRef = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: ':memory:',
dropSchema: true,
synchronize: true,
entities: [Product],
}),
TypeOrmModule.forFeature([Product]),
],
providers: [ProductsService],
}).compile();
dataSource = moduleRef.get(DataSource);
productsRepository = dataSource.getRepository(Product);
productsService = moduleRef.get(ProductsService);
});
beforeEach(async () => {
await productsRepository.clear();
});
afterAll(async () => {
await moduleRef.close();
});
it('зберігає товар у базі даних', async () => {
const product = await productsService.create(
'KB-001',
'Механічна клавіатура',
10,
);
expect(product.id).toEqual(expect.any(Number));
expect(product.sku).toBe('KB-001');
expect(product.name).toBe('Механічна клавіатура');
expect(product.stock).toBe(10);
const savedProduct = await productsRepository.findOneBy({
sku: 'KB-001',
});
expect(savedProduct).toEqual(
expect.objectContaining({
sku: 'KB-001',
name: 'Механічна клавіатура',
stock: 10,
}),
);
});
it('знаходить товар за артикулом', async () => {
await productsRepository.save({
sku: 'MS-001',
name: 'Бездротова миша',
stock: 7,
});
const product = await productsService.findBySku('MS-001');
expect(product).toEqual(
expect.objectContaining({
sku: 'MS-001',
name: 'Бездротова миша',
stock: 7,
}),
);
});
it('повертає null, якщо товар не знайдено', async () => {
const product = await productsService.findBySku('UNKNOWN');
expect(product).toBeNull();
});
it('не бачить дані з попереднього тесту', async () => {
const products = await productsRepository.find();
expect(products).toHaveLength(0);
});
});Запустити такий тест можна командою:
npx jest test/products.integration-spec.ts --runInBandПараметр --runInBand запускає тести послідовно в одному процесі. Він не завжди необхідний, але спрощує роботу з тестами, які використовують спільні зовнішні ресурси.
У прикладі кожен тест починається з:
beforeEach(async () => {
await productsRepository.clear();
});Це означає, що:
тест не залежить від порядку виконання інших тестів;
записи, створені попереднім тестом, не впливають на поточний;
кожен тест отримує передбачуваний початковий стан.
Важливо очищати дані до тесту, а не лише після нього. Якщо тест завершиться помилкою, очищення в afterEach може не вирішити проблему для наступного запуску або повторного використання бази.
Для кількох сутностей очищення потрібно виконувати з урахуванням зовнішніх ключів. Наприклад, спочатку очищають дочірні записи, а потім батьківські. В іншому разі база даних може відхилити операцію через обмеження цілісності.
database: ':memory:' створює базу даних у пам'яті для конкретного підключення DataSource. Коли модуль закривається, база зникає.
Це дає ізоляцію між наборами тестів, якщо кожен набір створює власний TestingModule і власний DataSource.
У кожному наборі тестів потрібно закривати модуль:
afterAll(async () => {
await moduleRef.close();
});Це закриває підключення до бази даних і запобігає ситуаціям, коли Jest не може завершити процес через відкриті ресурси.
Інтеграційний тест може перевірити не лише результат сервісу, а й правила, які забезпечує база даних. У сутності sku має обмеження unique, тому повторне збереження такого самого артикулу повинно завершитися помилкою.
it('не дозволяє зберегти два товари з однаковим артикулом', async () => {
await productsRepository.save({
sku: 'KB-001',
name: 'Перша клавіатура',
stock: 5,
});
await expect(
productsRepository.save({
sku: 'KB-001',
name: 'Друга клавіатура',
stock: 3,
}),
).rejects.toThrow();
});Такий тест важливий, оскільки перевірка лише в коді сервісу не гарантує захист від паралельних запитів або інших частин застосунку, які записують дані без використання цього сервісу.
synchronize у тестахУ прикладі використано:
synchronize: trueTypeORM автоматично створює таблиці на основі сутностей. Для тимчасової бази в пам'яті це зручно.
У робочому середовищі synchronize: true використовувати не слід, оскільки автоматична синхронізація може небезпечно змінити або видалити структуру таблиць. Для production-застосунку схему бази даних зазвичай змінюють міграціями.
Тестові налаштування можуть використовувати synchronize: true, якщо:
база створюється з нуля для кожного запуску;
тестується поточна структура сутностей;
швидкість і простота важливіші за перевірку міграцій.
Якщо потрібно перевіряти саме міграції, тестову базу створюють через міграційний механізм, а не через synchronize.
DataSourceІноді в тесті потрібно очистити кілька таблиць. Для цього можна отримати репозиторії через DataSource:
beforeEach(async () => {
const productRepository = dataSource.getRepository(Product);
await productRepository.clear();
});Отримання репозиторію через DataSource не створює нового підключення. Використовується той самий DataSource, який був створений для тестового модуля.
Не потрібно створювати новий DataSource у кожному тесті без необхідності. Це ускладнює керування життєвим циклом і може залишити відкриті підключення.
SQLite у пам'яті добре підходить для базових інтеграційних перевірок, але це не завжди точна заміна PostgreSQL або MySQL. Відмінності можуть стосуватися:
типів колонок;
синтаксису SQL;
поведінки індексів;
обмежень зовнішніх ключів;
блокувань і транзакцій;
специфічних функцій конкретної СУБД.
Якщо код використовує особливості PostgreSQL, тестувати його потрібно на PostgreSQL. Для цього тестове підключення налаштовують на окрему тестову базу, яка не використовується розробницьким або production-середовищем.
У такій конфігурації принцип залишається тим самим:
підняти тестову базу;
виконати міграції або створити схему;
виконати тест;
очистити дані;
закрити підключення.
Юніт-тест із моком може виглядати так:
const repositoryMock = {
findOne: jest.fn(),
};Такий підхід корисний для ізольованої перевірки логіки сервісу, але мок не перевіряє реальну роботу TypeORM.
Інтеграційний тест натомість отримує справжній репозиторій:
productsRepository = dataSource.getRepository(Product);Тому він може виявити помилки, які не побачить юніт-тест:
неправильне ім'я колонки;
відсутню колонку;
помилковий тип поля;
неправильне поле у where;
порушення унікального обмеження;
проблеми зі зв'язками між сутностями.
Тести не повинні підключатися до бази розробки або production-бази. Тест може очистити, змінити або видалити реальні дані.
Використовуйте окрему базу або SQLite у пам'яті.
Якщо тести залежать від даних, створених іншими тестами, вони стають нестабільними. Зміна порядку виконання може призвести до випадкових помилок.
Очищайте таблиці в beforeEach або використовуйте інший визначений механізм ізоляції.
Репозиторій не є окремим підключенням, яке потрібно закривати. Закривати потрібно модуль або DataSource:
await moduleRef.close();Якщо репозиторій замінено моком, тест більше не перевіряє інтеграцію з базою даних. Моки доречні в юніт-тестах, але не в інтеграційному тесті репозиторію.
synchronizesynchronize: true зручно для простої тестової схеми, але воно не перевіряє коректність міграцій. Якщо міграції є частиною критичного процесу розгортання, їх потрібно запускати в окремих тестах на сумісній СУБД.
Після збереження база даних додає id, а інколи змінює інші значення за замовчуванням. Тому краще перевіряти потрібні поля через expect.objectContaining, а не порівнювати весь об'єкт із початковим значенням.
Інтеграційні тести перевіряють сервіс, репозиторій і реальну базу даних разом.
Для тестів можна використовувати SQLite у пам'яті або окрему базу тієї самої СУБД, що й у production.
TestingModule має містити TypeOrmModule.forRoot і TypeOrmModule.forFeature.
Дані потрібно ізолювати між тестами, наприклад очищенням таблиць у beforeEach.
Після завершення тестів необхідно закривати TestingModule.
Інтеграційні тести здатні перевірити обмеження бази даних, структуру сутностей і правильність реальних запитів.
synchronize: true зручно використовувати для тимчасової тестової бази, але не для production.