← amorgunov
Об автореПосты

Как писать тесты в NodeJS

Сегодня наткнулся на проект, который я делал пару лет назад на фрилансе, это был полноценный кешбэк сервис, с партнеркой и системой вывода средств, который к сожалению не вышел в полноценный продакшен, но пару вещей через него было куплено и свои 15 рублей кэшбека успешно сняты. Полазив по директориям и файлам, поностальгировав как все удобно разбито на модули и сделано по уму, случайно открыл тест для одного модуля, который выкачивал из партнерки купоны. В этом небольшом тесте из двух тест-кэйсов были собраны чуть ли не все практики, которые я открывал для себя со временем при написании тестов для API на nodejs. Собственно, это и натолкнуло меня написать этот материал, в котором собраны советы для написания тестов.

В данном после не будет информации о том, зачем нужны тесты, я думаю если вы читаете это, то вы понимаете всю важность написания тестов хотя бы на самые критичные модули. Здесь так же не будет описание методологий, типа TTD, только практика.

Что использовать в качестве тест-раннера, решать вам. Стандартный выбор: это mocha (читается как мокка), ava (эйва) или jest. Сегодня я буду использовать jest.

Скриншот выполненных тестов

Расположение файлов с тестами в проекте

Начну с вопроса, где правильно в структуре проекта хранить тесты? Я сталкивался с тремя видами расположения тестов (в контексте этого шага под тестом я буду понимать файл с тестами):

  1. Создать директорию tests, которая внутри себя повторяет структуру проекта и складывать тесты там. Мы использовали этот подход на нескольких проектах и все было отлично, до того момента, когда проект вырос и стало неудобно при изменении модуля или ручки API искать файл с тестом;
  2. Складывать тест рядом с модулем. В таком случае нет проблем искать файл, он всегда лежит рядом, но такие файлы создают некий шум, и становится сложнее искать модули, потому что количество файлов в рамках одной директории возрастает в два раза;
  3. В директории создавать папку __tests__ и туда складывать тесты всех модулей из текущей директории. Файлы с тестами всегда лежат рядом и не увеличивают количество файлов.

На данный момент я остановился на третьем варианте, но любой из этих кейсов можно использовать, но нужно не забывать на счет консистентности, и в проекте использовать только один подход.

Get started

Я не буду повторять то, что есть в документации к каждому тест раннеру. В случае с jest можно почитать их "старт гайд": https://jestjs.io/docs/en/getting-started.

test('должен всегда выполняться', () => {
    expect(true).toBe(true);
});

Когда тестов много, запускать все заново и ждать выполнение всех не удобно. Можно пойти несколькими путями. Например, запускать тесты через IDE или при запуске указать конкретный файл для запуска. Так же тест-раннеры позволяют после it (аналог test) указывать различные команды. Например, skip для того, чтобы пропустить тест, а only - для запуска только этого теста:

test.skip('этот тест будет пропущен', () => {});
test.only('будет запущен только этот тест', () => {});

Некоторые раннеры позволяют запускать тесты параллельно, но это не будет работать, если нужно делать запросы в базу данных.

✒️ Стоит взять за правило не писать в рамках одного тест-кейса больше одного-двух assert-ов, так как один тест кейс должен проверять только одну ветвь поведения. Т.е если функция можно вернуть true или false, что бы ее покрыть, нужно написать два тест-кейса.

Работа с веб-сервером

Когда я начинаю делать новое API или микросервис, то начинаю с настройки инфраструктуры и ручки (API endpoint-a) ping. Простейший пример с использованием express-a:

// app.js
const express = require('express');
const bodyParser = require('body-parser');

const app = express();

app.use(bodyParser.json());
app.get('/ping', (req, res) => res.json({ pong: true }));

module.export = app;

Эту ручку удобно использовать для простейших хелсчеков, но об этом как-нибудь в другой раз. Сейчас давайте напишем для нее тест. Пара моментов:

Тест будет выглядеть следующим образом:

// __tests__/app.test.js
const request = require('supertest');
const app = require('./app');

test('/ping', async () => {
    await request(app)
        .get('/ping')
        .expect('Content-Type', /json/)
        .expect(200)
        .expect(({ body }) => {
            expect(body).toEqual({ pong: true });
        });
});

Таким образом можно писать интеграционные тесты для всех ручек API и быть уверенным, что новая функциональность не сломает остальной код (конечно же для этого должно быть отличное покрытие, а не только ручка ping :)).

Snapshot тесты

Ниже пример проверки результатов из реального тест-кейса, где API-ручка возвращает данные, а целью теста является проверка, что эти данные вернулись как и ожидалось:

test('должен вернуть feed-событий', async () => {
    // генерируем данные ...

    await request(app)
        .get('/v1/front/events/feed')
        .expect('Content-Type', /json/)
        .expect(200)
        .expect(({ body }) => {
            expect(body.length).toEqual(3);

            const [firstEvent, secondEvent] = body;

            expect(firstEvent.slug).toEqual('slug-1');
            expect(firstEvent.name).toEqual('Name 1');
            expect(firstEvent.city).toEqual('City');
            expect(firstEvent.date_start).toEqual(moment(startDate).format('YYYY-MM-DD'));
            expect(firstEvent.url).toEqual(`${config.frontend.endpoint}/events/right-1`);

            expect(secondEvent.url).toEqual('https://test.redirect.url');
        });
});

Что здесь не так? С первого взгляда все хорошо: проверили поля первого элемента массива, у второго тоже одно из полей проверили. Но на самом деле есть проблемы:

В таком кейсе очень удобно использовать снепшот-тесты, когда мы сохраняем результат в файл и при следующем запуске актуальные данные сверяются со снепшотом.

В случае со снепшотами тест можно переписать следующим образом:

test('should find events to feed', async () => {
    // генерируем данные ...

    await request(app)
        .get('/v1/front/events/feed')
        .expect('Content-Type', /json/)
        .expect(200)
        .expect(({ body }) => expect(body).toMatchSnapshot());
});

При первом запуске создастся файл __snapshots__/app.test.js.snap со снимком переменной body, и при следующих запусках результат работы будет сравниваться с этим снимком.

На самом деле я не люблю снепшоты, особенно при их использовании на фронтэнде, потому что разработчики ленятся и вместо unit тестов пишут только снепшот, которые должным образом не проверяют функционал. Но в некоторых кейсах они очень кстати, как например, в примере выше.

Работа с внешними API

Кейс: нужно написать юнит-тест на модуль, который ходит во внешнее API. Внешнее API может быть платное или без dev-среды, поэтому хорошим вариантом будет замокать ответ. Сделать это очень просто с помощью библиотеки nock, которая как раз и занимается тем, что нам нужно - перехватывает http-запросы.

Например, у нас есть модуль для загрузки купонов:

// coupon.js
const request = require('./request');

module.exports = async function couponParser() {
    return await request(`${config.couponEndpoint}/coupons?status=active`);
}

И вот так для него может быть написан тест:

const nock = require('nock');
const couponParser = require('../coupon');
// Файл с замоканным ответом от API
const couponsMock = require('./couponsMock.json');

describe('couponParser', () => {
    // Обязательно чистим все "ноки" для следующих тестов
    // Так же можно отключать сеть для того, что бы случайно не отправить запрос
    // c помощью nock.disableNetConnect
    afterEach(nock.cleanAll);

    test('должен спарсить купоны', async () => {
        // Мокаем запрос до внешнего API
        const nockInstance = nock(cfg.couponEndpoint)
            .get('/coupons/')
            .query({ status: 'active' })
            .reply(200, { results: couponsMock });

        const actualCoupons = await couponParser();
        
        // Проверяем что запрос был отправлен
        expect(isDone()).toBe(true);

        // Проверяем данные
        expect(actualCoupons.length).isEqual(couponsMock.length);
        expect(actualCoupons[0].name).isEqual(couponsMock[0].name);
    });
});

✒️ Нужно не забыть написать тест на случай, если внешний API недоступен, и в случае подобной ситуации, ваше приложение уже будет готово к такому повороту событий.

Если в каждом тесте используется один и тот же «nock» (например, запрос на авторизацию), то удобно выносить их в отдельный модуль.


Бывают ситуации, когда используется sdk для работы с API, и мокать запросы через nock становится довольно неудобно по различным причинам (например, отправляется много запросов и нужно много времени, что бы все верно замокать). В таком случае лучше мокать модули самого sdk средствами тест раннера (например, jest.mock) или с помощью sinon.

Работа с таймзонами

Начну с истории. На одном проекте я реализовал работу с календарем докторов, т.е. было много работы с датой. Я покрыл функциональность тестами, отправил их в ПР (Pull request), успешно прошел ревью и замержил. Через пару часов коллега мне пишет, что мои тесты локально у него работают, а в ПР упали. Я запустил в ПР - прошли. Через время он перезапустил, и снова не прошли. Очень странно. Есть предположения, из-за чего такое могло случится? Не буду томить, как потом оказалось, на сервере был выставлен другой часовой пояс и тесты в первую половину часа проходили (например, с 10:00 до 10:30), а во вторую - нет. Вот такой вот плавающий тест (это устоявшееся выражение для тестов, которые проходят не стабильно и периодически падают).

В тот раз проблема была решена с помощью библиотеки sinon: внутренний таймер был замокан на определенное время.

Но потом я нашел более простое решение. Можно использовать переменную окружения TZ с установленной таймзоной (например, UTC) и после этого тесты на любой системе будут запущены с одинаковой указанной таймзоной:

TZ=UTC npm test

Работа с базой данных

На проекте с докторами наша команда совершила ошибку по подходу работы с базой данных. У нас был общий файл, который наполнял базу нужными данными и потом запускались тесты. Изначально это было очень удобно, но когда количество тестов выросло, проблема стала очевидна. Каждый тест меняет состояние БД, и все тесты становятся зависимыми друг от друга. Если удалить какой-то тест или даже переименовать файл (изменив порядок запуска), можно было сломать другие тесты.

Поэтому на всех следующих проектах мы делали тесты независимыми, путем удаления всех данных из базы данных. Тесты в таком случае независимы друг от друга, но возникает новая проблеема - общее время выполнения возрастает.

На одном из проектов количество тестов выросло до 1к и время выполнения достигало несколько минут. Перед каждым тестом, которые работали с БД, можно было увидеть вот такую конструкцию:

beforeEach(clearDatabase);

В методе clearDatabase БД удалялась (DROP DATABASE) и потом создавалась (CREATE DATABASE). Эти операции выполнялись долго, поэтому было использовано следующее решение: не удалять базу данных, а просто отчищать все таблицы:

module.exports = async function clearDatabase() {
    try {
        await db.userRole.destroy({ where: {} });
        await db.settings.destroy({ where: {} });
        // ...
    } catch (err) {
        // В случае ошибки удаляем и создаем БД принудительно
    }   
}

Это позволило уменьшить время выполнения тестов на 30%.

✒️ Тест-кейсы нужно делать независимыми друг от друга, например, перед каждым тестом чистить данные из базы данных.

Но осталась проблема, что если для тестов нужны данные сразу в нескольких таблицах, то нужно вручную заполнять базу в каждом тесте. Для решения этого кейса мы использовали паттерн фабрики для создания сущностей.

Фабрики для сущностей базы данных

Звучит довольно странно и непонятно, но на самом деле здесь нет ничего сложного, достаточно реализовать подобный API:

// Ниже typescript-подобный псевдокод:
interface BaseFactory<T> {
    static defaultData: T
    static model: Model<T>;
    static create: (data: T) => Promise
}

Т.е. фабрика должна иметь 2 поля (дефолтные значения и ссылку на модель для работы с сущностью) и метод create, который с помощью модели и дефолтных значений заполняет БД.

Например, у нас в коде фабрика выглядела подобным образом:

class ThemeFactory extends BaseFactory {
    static get defaultData() {
        const { id } = this;

        return {
            id,
            slug: `any-slug-${id}`,
            title: 'Any title',
            isStable: false,
            description: 'Description',
            previewImage: null
        };
    }

    static get table() {
        return require('db').theme;
    }
}

Метод create реализован в базовом классе, который делает запрос в БД. Здесь есть небольшая магия с this.id, но на самом деле это обычный геттер с внутренним счетчиком.

static get id() {
    this._id = this._id || 1;

    return this._id++;
}

Теперь создание необходимых данных в тесте может выглядеть подобным образом:

await factory.userRole.create({ login: 'yoda', role: 'admin' });
await factory.theme.create([
    { slug: 'planet', title: 'Planet' },
    { slug: 'starship', title: 'Starship' }
]);

Логин «йода» здесь указан не случайно. На парочке своих проектов мы в тестах задавали какую-то определенную тему для названия сущностей (звездные войны, марвел или вселенная Гарри Потера) и придерживались ее. Это не дает никакого прироста к производительности, это просто прикольно :)

Мы пошли дальше и сделали автоматическое создание связанных сущностей (sub-factories), но это выходит за рамки этого материала и сильно завязывается на используемую базу данных.

✒️ Фабрики сущностей позволяют быстро генерировать необходимые данные для каждого теста.

Итого

Я попытался разобрать все основные кейсы, постоянно встречающиеся при написании тестов для проектов на nodejs и которые точно стоит иметь в виду. А вот придерживаться или нет, уже решать вам и вашей команде. Надеюсь, было познавательно, до встречи!

Вам может быть интересно