
Как правильно писать и организовывать тесты API Node.js
24 ноября 2022 г.Тестирование написанного вами кода — важный этап в процессе разработки программного обеспечения. Это гарантирует, что ваше программное обеспечение работает должным образом, и снижает риск появления ошибок и уязвимостей в рабочей среде.
В частности, автоматизированные тесты играют важную роль, когда речь идет о частом и последовательном тестировании. Непрерывная интеграция делает их еще более мощными.
В этой записи блога я покажу вам архитектуру для тестирования REST API Node.js, использующих базу данных в фоновом режиме. Есть некоторые вещи, которые вы должны учитывать в этом сценарии, о которых мы поговорим.
Вы увидите, как разделить и организовать компоненты вашего приложения таким образом, чтобы вы могли тестировать их независимо. Поэтому мы будем использовать два разных подхода. С одной стороны, мы настроили тестовую среду, в которой мы запускаем наши тесты с тестовой базой данных.
С другой стороны, мы макетируем уровень базы данных, используя фиктивные функции, чтобы мы могли их запускать. в среде, в которой у нас нет доступа к базе данных.
Мы начнем с написания модульных тестов для тестирования отдельных компонентов нашего приложения. На следующем этапе мы объединяем эти компоненты и тестируем их с помощью интеграционных тестов. И последнее, но не менее важное: мы настраиваем конвейер CI/CD с помощью действий GitHub и запускаем тесты при каждой отправке.
Обратите внимание, что это не руководство о том, как вообще работает тестирование. Существуют тысячи статей о таких фреймворках, как Jest, Mocha, Supertest и т. д.
Это скорее руководство о том, как подготовить приложение Node и тестовую среду таким образом, чтобы вы могли легко и эффективно писать тесты с подключением к базе данных или без него. Также есть пример репозитория на GitHub. Вы обязательно должны это проверить.
<цитата>Отказ от ответственности: я знаю, что в архитектуре нет правильного или неправильного. Ниже я предпочитаю один.
Начнем с инструментов, которые мы будем использовать. Большинство из них должны быть вам знакомы:
- Язык: Typescript
- Сервер: Express
- База данных: Postgres
- Тестирование: Jest & Супертест & Чай
- CI/CD: Действия GitHub
- Контейнеризация: Docker
Одним из преимуществ этой архитектуры является то, что вы можете использовать ее и с другими базами данных, помимо Postgres, например, с MySQL. В этой архитектуре мы не используем ORM. Кроме того, вы можете заменить Jest на Mocha, если вам нужна такая среда тестирования.
Архитектура приложения
Архитектура нашего приложения выглядит примерно так:
node-api
├── api
│ ├── components
│ │ ├── user
| | │ ├── tests // Tests for each component
| | | │ ├── http.spec.ts
| | | │ ├── mock.spec.ts
| | │ | └── repo.spec.ts
| | │ ├── controller.ts
| | │ ├── dto.ts
| | │ ├── repository.ts
| | │ └── routes.ts
│ └── server.ts
├── factories // Factories to setup tests
| ├── abs.factory.ts
| ├── http.factory.ts
| └── repo.factory.ts
└── app.ts
Примечание. Пример репозитория содержит дополнительный код.
Каждый компонент состоит из следующих четырех файлов:
- controller.ts: обработчик HTTP
- dto.ts: объект передачи данных (подробнее)
- repository.ts: уровень базы данных
- routes.ts: HTTP-маршрутизация
Каталог tests
содержит тесты соответствующего компонента. Если вы хотите узнать больше об этой архитектуре, ознакомьтесь с этой моей статьей.
Конфигурация
Мы начнем с создания файла .env.test
, содержащего секретные переменные среды для тестирования. Пакет npm Postgres использует их автоматически при установке нового подключения к базе данных. Все, что нам нужно сделать, это убедиться, что они загружаются с помощью dotenv.
NODE_PORT=0
NODE_ENV=test
PGHOST=localhost
PGUSER=root
PGPASSWORD=mypassword
PGDATABASE=nodejs_test
PGPORT=5432
Установка NODE_PORT=0
позволяет Node выбирать первый случайным образом доступный порт, который он находит. Это может быть полезно, если во время тестирования вы запускаете несколько экземпляров HTTP-сервера. Вы также можете установить здесь фиксированное значение, отличное от 0
. Используя PGDATABASE
, мы указываем имя нашей тестовой базы данных.
Далее настраиваем Jest. Конфиг в jest.config.js
выглядит следующим образом:
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["src"],
setupFiles: ["<rootDir>/setup-jest.js"],
}
И setup-jest.js
вот так:
require("dotenv").config({
path: ".env.test",
})
Этот фрагмент обеспечивает загрузку соответствующих переменных среды из предоставленного файла .env
перед запуском тестов.
Тестирование с помощью базы данных
Начнем с предположения, что у нас есть тестовая база данных, которую мы можем использовать. Например, это может быть конвейер GitHub Actions CI/CD. Позже я покажу вам, как протестировать ваше приложение без подключения к базе данных.
3 правила
В начале я сказал, что есть несколько важных моментов, которые мы хотим учесть, чтобы значительно облегчить жизнь при тестировании Node API:
- Отделить слой базы данных
- Отдайте инициализацию подключения к базе данных на аутсорсинг
- Отдайте инициализацию HTTP-сервера на аутсорсинг
Что я имею в виду?
Отделите слой базы данных.
У вас должен быть собственный слой, отделенный от вашей бизнес-логики, который отвечает за связь с базой данных. В примере репозитория Git вы можете найти этот слой в файле компонента repository.ts
.
Это позволяет нам легко имитировать методы слоя, когда у нас нет базы данных, доступной для тестирования.
Более того, проще заменить вашу базу данных на другую.
export class UserRepository {
readAll(): Promise<IUser[]> {
return new Promise((resolve, reject) => {
client.query<IUser>("SELECT * FROM users", (err, res) => {
if (err) {
Logger.error(err.message)
reject("Failed to fetch users!")
} else resolve(res.rows)
})
})
}
Аутсорсинг инициализации подключения к базе данных.
Вы уже должны знать, что при написании тестов для базы данных вы не запускаете их для производственной. Вместо этого вы настраиваете тестовую базу данных. В противном случае вы рискуете испортить производственные данные.
В большинстве случаев ваше приложение подключается к базе данных в сценарии запуска, таком как index.js
. После установления соединения вы запускаете HTTP-сервер.
Это то, что мы хотим сделать и в нашей тестовой установке. Таким образом, мы можем изящно подключаться к базе данных и отключаться от нее до и после каждого теста.
Отдайте инициализацию HTTP-сервера на аутсорсинг
Хорошей практикой является запуск HTTP-сервера из ваших тестов независимо от того, используете вы базу данных или нет. Так же, как и при подключении к базе данных, мы создаем новый HTTP-сервер до и останавливаем его после каждого теста.
Это может выглядеть следующим образом: (позже вы увидите конкретную реализацию)
describe("Component Test", () => {
beforeEach(() => {
// Connect to db pool && start Express Server
});
afterEach(() => {
// Release db pool client && stop Express Server
});
afterAll(() => {
// End db pool
});
В частности, порядок выполнения таков:
- Подключиться к пулу баз данных
- Запустить начальное значение SQL (создать таблицы)
- Запустите экспресс-сервер
- Запустите тесты
- Выключите сервер Express & выпустить клиент пула БД
- Повторите шаги 1–5 для каждого набора тестов и закройте пул в конце.
Тесты
Каждый компонент состоит из двух тестовых файлов:
- repo.spec.ts
- http.spec.ts
Оба они используют так называемые TestFactories
, которые подготавливают настройку теста. Вы увидите их реализацию в следующей главе.
Примечание. Если вы посмотрите пример репозитория Git, вы увидите, что есть еще два: mock.spec.ts и dto.spec.ts. Первый обсуждается позже. Последнее не рассматривается в этой статье.
repo.spec.ts
Репозиторий — это дополнительный абстрактный уровень, отвечающий за взаимодействие с базой данных, например за чтение и вставку новых данных. Этот слой мы тестируем здесь.
Поскольку в этом случае требуется база данных, новый клиент пула создается для подключения к базе данных с помощью RepoTestFactory
перед каждым тестовым случаем. И он выпускается сразу после завершения тестового примера. В конце концов, когда все тестовые случаи завершены, соединение с пулом закрывается.
Пример на GitHub< /p>
describe("User component (REPO)", () => {
const factory: RepoTestFactory = new RepoTestFactory()
const dummyUser = new UserDTO("john@doe.com", "johndoe")
// Connect to pool
beforeEach(done => {
factory.prepare(done)
})
// Release pool client
afterEach(() => {
factory.closeEach()
})
// End pool
afterAll(done => {
factory.close(done)
})
test("create user", () => {
const repo: UserRepository = new UserRepository()
repo.create(dummyUser).then(user => {
expect(user).to.be.an("object")
expect(user.id).eq(1)
expect(user.email).eq(dummyUser.email)
expect(user.username).eq(dummyUser.username)
})
})
})
http.spec.ts
Здесь мы тестируем интеграцию маршрутов, контроллера и репозитория пользовательского компонента. Перед каждым тестовым случаем создается новый клиент пула, как мы делали выше. Кроме того, новый сервер Express запускается с помощью HttpTestFactory
. В итоге оба снова закрываются.
Пример на GitHub< /p>
describe("User component (HTTP)", () => {
const factory: HttpTestFactory = new HttpTestFactory()
const dummyUser = new UserDTO("john@doe.com", "johndoe")
// Connect to pool && start Express Server
beforeEach(done => {
factory.prepare(done)
})
// Release pool client && stop Express Server
afterEach(done => {
factory.closeEach(done)
})
// End pool
afterAll(done => {
factory.close(done)
})
test("create user", async () => {
const postRes = await factory.app
.post("/users")
.send(dummyUser)
.expect(201)
.expect("Content-Type", /json/)
const postResUser: IUser = postRes.body
expect(postResUser).to.be.an("object")
expect(postResUser.id).eq(1)
expect(postResUser.email).eq(dummyUser.email)
expect(postResUser.username).eq(dummyUser.username)
})
})
Фабрики
Фабрики тестов — это сердце наших тестов. Они отвечают за настройку и подготовку среды для каждого теста. В том числе:
* Отбрасывание & создание всех таблиц БД * Инициализация подключения к БД * Инициализация HTTP-сервера * Закрытие обоих снова
Всего существует четыре фабрики: AbsTestFactory
, RepoTestFactory
, HttpTestFactory
и MockTestFactory
. У каждого из них есть свой класс Typescript. Последнее обсуждается в главе "Тестирование без базы данных".
Фабрика AbsTestFactory
Первый AbsTestFactory
— это абстрактный базовый класс, который реализуется тремя другими. Среди прочего, он включает в себя метод подключения к пулу баз данных и метод отключения от него.
export abstract class AbsTestFactory implements ITestFactory {
private poolClient: PoolClient
private seed = readFileSync(
join(__dirname, "../../db/scripts/create-tables.sql"),
{
encoding: "utf-8",
}
)
abstract prepareEach(cb: (err?: Error) => void): void
abstract closeEach(cb: (err?: Error) => void): void
protected connectPool(cb: (err?: Error) => void) {
pool
.connect()
.then(poolClient => {
this.poolClient = poolClient
this.poolClient.query(this.seed, cb)
})
.catch(cb)
}
protected releasePoolClient() {
this.poolClient.release(true)
}
protected endPool(cb: (err?: Error) => void) {
pool.end(cb)
}
}
С помощью сценария create-tables.sql
фабрика удаляет и воссоздает все таблицы после установления соединения:
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(50) UNIQUE NOT NULL,
username VARCHAR(30) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Фабрика тестов репо
RepoTestFactory
используется тестом репозитория каждого компонента (repo.spec.ts
), который вы только что видели выше. Все, что он делает, это использует родительский класс AbsTestFactory
для подключения к базе данных.
export class RepoTestFactory extends AbsTestFactory {
prepareEach(cb: (err?: Error) => void) {
this.connectPool(cb)
}
closeEach() {
this.releasePoolClient()
}
closeAll(cb: (err?: Error) => void) {
this.endPool(cb)
}
}
Методы prepareEach
, closeEach
и closeAll
вызываются для каждого теста в Jest beforeEach
, afterEach
и жизненный цикл afterAll
.
HttpTestFactory
Последний, HttpTestFactory
, используется HTTP-тестом каждого компонента (http.spec.ts
). Как и RepoTestFactory
, он использует родительский класс для подключения к базе данных. Кроме того, он инициализирует сервер Express.
export class HttpTestFactory extends AbsTestFactory {
private readonly server: Server = new Server()
private readonly http: HttpServer = createServer(this.server.app)
get app() {
return supertest(this.server.app)
}
prepareEach(cb: (err?: Error) => void) {
this.connectPool(err => {
if (err) return cb(err)
this.http.listen(process.env.NODE_PORT, cb)
})
}
closeEach(cb: (err?: Error) => void) {
this.http.close(err => {
this.releasePoolClient()
cb(err)
})
}
closeAll(cb: (err?: Error) => void) {
this.endPool(cb)
}
}
Перемотать назад
Давайте вернемся к тестовым файлам repo.spec.ts
и http.spec.ts
, указанным выше. В обоих случаях мы использовали заводской метод prepareEach
перед каждым тестом и его метод afterEach
сразу после каждого теста.
Метод closeAll
вызывается в самом конце тестового файла. Как вы только что видели, в зависимости от типа фабрики мы устанавливаем соединение с базой данных и при необходимости запускаем HTTP-сервер.
describe("Component Test", () => {
beforeEach(done => {
factory.prepareEach(done)
})
afterEach(() => {
factory.closeEach()
})
afterAll(done => {
factory.closeAll(done)
})
})
Следует иметь в виду одну важную вещь: для каждого тестового примера, в котором используется база данных, фабрика удаляет все таблицы и впоследствии воссоздает их с помощью предоставленного сценария SQL. Таким образом, у нас будет чистая база данных с пустыми таблицами в каждом тестовом примере.
Тестирование без базы данных
До сих пор мы запускали наши тесты с тестовой базой данных, но что, если у нас нет доступа к базе данных? В этом случае нам нужно макетировать нашу реализацию уровня базы данных (repository.ts
) , что довольно просто, если отделить его от бизнес-логики, как я рекомендовал в правиле №1.
С макетами слой больше не зависит от внешнего источника данных. Вместо этого мы предоставляем собственную реализацию класса и каждого из его методов. Имейте в виду, что это не влияет на поведение нашего контроллера. поскольку ему все равно, откуда берутся данные.
Пример на GitHub< /p>
const dummyUser: IUser = {
id: 1,
email: "john@doe.com",
username: "john",
created_at: new Date(),
}
// Mock methods
const mockReadAll = jest.fn().mockResolvedValue([dummyUser])
// Mock repository
jest.mock("../repository", () => ({
UserRepository: jest.fn().mockImplementation(() => ({
readAll: mockReadAll,
})),
}))
После имитации уровня базы данных мы можем писать наши тесты как обычно. Используя toHaveBeenCalledTimes()
, мы убеждаемся, что наша реализация пользовательского метода была вызвана.
describe("User component (MOCK)", () => {
const factory: MockTestFactory = new MockTestFactory()
// Start Express Server
beforeEach(done => {
factory.prepareEach(done)
})
// Stop Express Server
afterEach(done => {
factory.closeEach(done)
})
test("get users", async () => {
const getRes = await factory.app
.get("/users")
.expect(200)
.expect("Content-Type", /json/)
const getResUsers: IUser[] = getRes.body
cExpect(getResUsers).to.be.an("array")
cExpect(getResUsers.length).eq(1)
const getResUser = getResUsers[0]
cExpect(getResUser).to.be.an("object")
cExpect(getResUser.id).eq(dummyUser.id)
cExpect(getResUser.email).eq(dummyUser.email)
cExpect(getResUser.username).eq(dummyUser.username)
expect(mockReadAll).toHaveBeenCalledTimes(1)
})
})
Примечание. cExpect — это именованный импорт из пакета "chai".
MockTestFactory
Как и в других тестовых файлах, здесь мы также используем тестовую фабрику. Все, что делает MockTestFactory
, — это запускает новый экземпляр Express HTTP. Он не устанавливает соединение с базой данных, поскольку мы имитируем уровень базы данных.
export class MockTestFactory extends AbsTestFactory {
private readonly server: Server = new Server()
private readonly http: HttpServer = createServer(this.server.app)
get app() {
return supertest(this.server.app)
}
prepareEach(cb: (err?: Error) => void) {
this.http.listen(process.env.NODE_PORT, cb)
}
closeEach(cb: (err?: Error) => void) {
this.http.close(cb)
}
}
Один из недостатков этого подхода заключается в том, что слой (repository.ts
) вообще не тестируется, потому что мы перезаписываем его. Тем не менее, мы все еще можем протестировать остальную часть нашего приложения, например, бизнес-логику. Отлично!
Бег
Используя приведенные ниже команды, мы можем запустить тесты с базой данных или без нее. В зависимости от сценария файлы, которые мы не хотим тестировать, исключаются из выполнения.
{
"test:db": "jest --testPathIgnorePatterns mock.spec.ts",
"test:mock": "jest --testPathIgnorePatterns "(repo|http).spec.ts""
}
Действия GitHub
Последний шаг — создать конвейер CI/CD с помощью действий GitHub, которые запускают наши тесты. Соответствующий файл yaml доступен здесь. Также на GitHub опубликовано очень хорошее руководство.
Вы можете решить, запускать ли тесты с тестовой базой данных или использовать уровень фиктивных данных. Я решил остановиться на первом.
При запуске конвейера с тестовой базой данных нам нужно убедиться, что мы установили для него правильные переменные среды. Здесь вы можете найти тестовый запуск.
Последние слова
Мой последний совет: взгляните на репозиторий примеров на GitHub и внимательно его прочитайте. еще несколько тестов и фрагментов кода, которые я не рассмотрел в этой статье. Кроме того, проверьте ссылки ниже. Удачного кодирования!
Дополнительные ресурсы
- Моки класса ES6
- Заполнение базы данных в Node.js
- Как я структурирую REST API Node.js
- Создание сервисных контейнеров PostgreSQL
Оригинал