Автоматическое тестирование - прекрасная вещь

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

Самое главное - это краткосрочное бизнес-приложение. Куда нам от этого идти? Как воссоединить потребности клиентов / компании и красивый, удобный в сопровождении код?

В этой статье я поделюсь ответом на вопрос, связанный с тестированием API.

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

Когда вы пишете API, вы хотите быть уверены, что каждый маршрут выполняет свою работу, но вам все равно, как это делается. Тем не менее, путем сокращения кода небольшие отдельные части, модульное тестирование сосредоточено на том, «как».

Это означает, что кто-то может изменить какое-то определение маршрута и сломать все ваше приложение, не заметив того, что тесты заметят, в то время как некоторый рефакторинг кода может нарушить ваши модульные тесты при улучшении вашего приложения.

Хорошо ... Если тесты написаны и поддерживаются должным образом, этого никогда не должно произойти. Тем не менее, это не имеет смысла с точки зрения бизнеса.

У большинства клиентов / компаний нет бюджета или времени для поддержки такого рода тестов, и они часто выбрасываются, как только мы дойдем до версии 1.1, как и многие вещи, которые делают код красивым и поддерживаемым в теория.

Итак, мой подход к поддержанию высокого покрытия тестами при сокращении времени, которое мы тратим на тестирование и особенно на их поддержку, - это сквозное тестирование API.

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

Позвольте мне показать вам пример. Большинство моих API создаются с помощью узла с использованием экспресс. В качестве среды тестирования я использую Jest, так как я заметил, что выполнение теста намного быстрее, чем у любой другой аналогичной среды. Чтобы имитировать сервер, я использую супертест.

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

Обычно я разделяю определение своего приложения и код, запускающий сервер, поэтому я могу импортировать приложение в файлы тестов. Итак, у меня есть файл app.js, экспортирующий экспресс-приложение.

Рассмотрим пример с простым маршрутом, например «вход». Мы не будем тестировать базовые вещи, такие как «Возвращает ли API тип содержимого json?», поскольку мы хотим сосредоточиться на потребностях бизнеса и обычно уверены в основных свойствах нашего API.

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

import supertest from 'supertest';
import mongoose from 'mongoose';
import jwt from 'jsonwebtoken';
import Application from '../src/app';
const mock_server = supertest(Application.app);
const route = '/sign-in';

А теперь приступим к настоящей работе. Какие важные моменты?

  • Мы хотим быть уверены, что наш маршрут существует и имеет правильные права доступа.
  • Мы хотим убедиться, что все ошибки обрабатываются правильно
  • Мы хотим подтвердить, что маршрут выполняет свою работу при правильных данных

По этой причине я обычно разделяю свои файлы тестов на 3 модуля: Доступ, Ошибки и Успех. Если вы не знакомы с jest или подобными библиотеками тестирования, я рекомендую вам быстро проверить их синтаксис, прежде чем читать код.

(...)
describe('SignIn: Access', () => {
    // We will test access properties here
});
describe('SignIn: Errors', () => {
    // We will test error handling here
});
describe('SignIn: Success', () => {
    // We will validate the functionality here
});

Практически для всего этого теста нам нужен пользователь, и мы не будем его обновлять. Таким образом, мы создадим одного пользователя перед запуском всех тестов. Мы предположим, что у вас есть модель User и вы настроили jest для использования смоделированной mongoDB с mongoose. Мы сохраним созданного пользователя в переменной, доступной для всех тестов, чтобы мы могли использовать ее для сравнения.

(...)
let user = null;
beforeAll(async (done) => {
    const User = mongoose.model('User');
    user = new User({
       username: 'test_user',
       email: '[email protected]',
       password: 'test_password',
    });
    user = await user.save();
    done();
});
(...)

1. Тестирование доступа

Обычно я сосредотачиваюсь на двух вещах:

  • Маршрут существует?
  • Правильно ли настроены права доступа?

Для маршрута входа это означает, что:

  • Запрос POST на / вход не возвращает 404
  • Запрос POST при входе в систему в качестве аутентифицированного пользователя возвращает 403

Заполним блок «Доступ»:

(...)
describe('SignIn: Access', () => {
    it('Should exist', async (done) => {
        const res = await mock_server.post(route).send();
        expect(res.status).not.toBe(404);
        done();
    });
    it('Should not be available for authenticated user', async (done) => { 
       const authToken = jwt.sign(user, `YOUR_JWT_SECRET`);
       const res = await mock_server.post(route)
            .set('Authorization', `Bearer ${authToken}`)
            .send();
        expect(res.status).toBe(403);
        done();
    });
});

2. Тестирование обработки ошибок.

Для маршрута входа обработка ошибок довольно проста:

  • Нам нужна ошибка, если учетные данные не указаны
  • Нам нужна ошибка, если пользователь не существует
  • Нам нужна ошибка, если пароль недействителен

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

(...)
describe('SignIn: Errors', () => {
    it('Should return missing credentials', async (done) => {
        const res = await mock_server.post(route).send();
        expect(res.status).toBe(400);
        expect(res.body.error).toBe('Missing credentials');
        done();
    });
    it('Should return user.doNotExist', async (done) => {
        const res = await mock_server.post(route).send({
            username: 'wrong_username',
            password: 'any',
        });
        expect(res.status).toBe(400);
        expect(res.body.error).toBe('user.doNotExist');
        done();
    });
    it('Should return user.wrongPassword', async (done) => {
        const res = await mock_server.post(route).send({
            username: 'test_user',
            password: 'wrong_password',
        });
        expect(res.status).toBe(400);
        expect(res.body.error).toBe('user.wrongPassword');
        done();
    });
});

3. Тестирование работоспособности

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

describe('SignIn: Success', () => {
    it('Should authenticate the user', async (done) => {
        const res = await mock_server.post(route).send({
            username: 'test_user',
            password: 'test_password',
        });
        expect(res.status).toBe(200);
        expect(res.body.token).not.toBe(null);
        const json_user = jwt.verify(res.body.token, `YOUR_JWT_SECRET`);
        expect(`${json_user._id}`).toBe(`${user._id}`);
        done();
    });
});

Вот и все ! Таким образом, мы не теряем времени на создание и поддержку тестов с подробностями, но уверены, что наш маршрут работает именно так, как нам нужно.

Конечно, в этом тесте есть еще много чего, что можно улучшить. Например, секрет веб-токена json должен поступать из некоторого конфигурационного файла, вы, вероятно, не захотите возвращать другую ошибку для «user.doNotExist» и «user.wrongPassword» по соображениям безопасности ... но основная идея такова:

Включите бизнес-потребности в принципы вашего кода, и ваш код останется красивым 😉

Дайте мне знать, что вы думаете!

Вы можете найти полный код ниже:

import supertest from 'supertest';
import mongoose from 'mongoose';
import jwt from 'jsonwebtoken';
import Application from '../src/app';
const mock_server = supertest(Application.app);
const route = '/sign-in';
let user = null;
beforeAll(async (done) => {
    const User = mongoose.model('User');
    user = new User({
       username: 'test_user',
       email: '[email protected]',
       password: 'test_password',
    });
    user = await user.save();
    done();
});
describe('SignIn: Access', () => {
    it('Should exist', async (done) => {
        const res = await mock_server.post(route).send();
        expect(res.status).not.toBe(404);
        done();
    });
    it('Should not be available for authenticated user', async (done) => { 
       const authToken = jwt.sign(user, `YOUR_JWT_SECRET`);
       const res = await mock_server.post(route)
            .set('Authorization', `Bearer ${authToken}`)
            .send();
        expect(res.status).toBe(403);
        done();
    });
});
describe('SignIn: Errors', () => {
    it('Should return missing credentials', async (done) => {
        const res = await mock_server.post(route).send();
        expect(res.status).toBe(400);
        expect(res.body.error).toBe('Missing credentials');
        done();
    });
    it('Should return user.doNotExist', async (done) => {
        const res = await mock_server.post(route).send({
            username: 'wrong_username',
            password: 'any',
        });
        expect(res.status).toBe(400);
        expect(res.body.error).toBe('user.doNotExist');
        done();
    });
    it('Should return user.wrongPassword', async (done) => {
        const res = await mock_server.post(route).send({
            username: 'test_user',
            password: 'wrong_password',
        });
        expect(res.status).toBe(400);
        expect(res.body.error).toBe('user.wrongPassword');
        done();
    });
});
describe('SignIn: Success', () => {
    it('Should authenticate the user', async (done) => {
        const res = await mock_server.post(route).send({
            username: 'test_user',
            password: 'test_password',
        });
        expect(res.status).toBe(200);
        expect(res.body.token).not.toBe(null);
        const json_user = jwt.verify(res.body.token, `YOUR_JWT_SECRET`);
        expect(`${json_user._id}`).toBe(`${user._id}`);
        done();
    });
});