Повне керівництво з наскрізного тестування API за допомогою Docker

Тестування - це біль загалом. Деякі не бачать сенсу. Деякі бачать це, але вважають це додатковим кроком, який уповільнює їх. Іноді тести є, але дуже тривалі або нестабільні. У цій статті ви побачите, як ви можете самостійно розробляти тести за допомогою Docker.

Ми хочемо швидких, значущих та надійних тестів, написаних та підтримуваних з мінімальними зусиллями. Це означає тести, які є корисними для вас як розробника щодня. Вони повинні підвищити вашу продуктивність та покращити якість вашого програмного забезпечення. Проводити тести, тому що всі кажуть, що "ви повинні пройти тести", не годиться, якщо це гальмує вас.

Давайте подивимося, як цього досягти не так вже й багато.

Приклад, який ми збираємось перевірити

У цій статті ми збираємося протестувати API, побудований за допомогою Node / express, і використовувати для тестування chai / mocha. Я вибрав стек JS'y, тому що код надзвичайно короткий і легко читається. Застосовані принципи діють для будь-якого стека технологій. Продовжуйте читати, навіть якщо від Javascript вам нудно.

Приклад охоплює простий набір кінцевих точок CRUD для користувачів. Цього більш ніж достатньо, щоб зрозуміти концепцію та застосувати до більш складної бізнес-логіки вашого API.

Ми будемо використовувати досить стандартне середовище для API:

  • База даних Postgres
  • Скупчення Редіс
  • Наш API використовуватиме інші зовнішні API для виконання своєї роботи

Можливо, для вашого API потрібне інше середовище. Принципи, застосовані в цій статті, залишаться незмінними. Ви будете використовувати різні базові зображення Docker для запуску будь-якого компонента, який вам може знадобитися.

Чому Docker? А насправді Docker Compose

Цей розділ містить багато аргументів на користь використання Docker для тестування. Ви можете пропустити його, якщо хочете відразу перейти до технічної частини.

Болісні альтернативи

Для тестування вашого API у наближеному до виробничого середовищі у вас є два варіанти. Ви можете глузувати з середовища на рівні коду або запускати тести на реальному сервері з встановленою базою даних тощо.

Знущання над усім на рівні коду захаращують код і конфігурацію нашого API. Також часто не дуже репрезентативно, як API буде поводитися у виробництві. Запуск цього на справжньому сервері важкий для інфраструктури. Це багато налаштування та обслуговування, і це не масштабується. Маючи спільну базу даних, ви можете виконувати лише 1 тест за раз, щоб переконатися, що тестові запуски не заважають один одному.

Docker Compose дозволяє нам отримати найкраще з обох світів. Він створює "контейнерні" версії всіх зовнішніх частин, які ми використовуємо. Це глузливо, але з зовнішньої сторони нашого коду. Наш API думає, що це в реальному фізичному середовищі. Складання Docker також створить ізольовану мережу для всіх контейнерів для даного тестового запуску. Це дозволяє паралельно запускати кілька з них на локальному комп’ютері або хості CI.

Надмірне?

Ви можете задатися питанням, чи не надто вправно виконувати наскрізні тести за допомогою Docker compose. А як щодо простого запуску модульних тестів?

Протягом останніх 10 років великі монолітні програми були розділені на менші служби (що спрямовуються на галасливі "мікросервіси"). Даний компонент API покладається на більше зовнішніх частин (інфраструктури чи інших API). Оскільки послуг стає менше, інтеграція з інфраструктурою стає більшою частиною роботи.

Ви повинні зберегти невеликий розрив між виробництвом та середовищем розробки. Інакше проблеми виникатимуть під час розгортання виробництва. За визначенням ці проблеми виникають у найгірший можливий момент. Вони призведуть до швидких виправлень, падіння якості та розчарування команди. Цього ніхто не хоче.

Ви можете задатися питанням, чи наскрізні тести з Docker compose працюють довше, ніж традиційні модульні тести. Не зовсім. У наведеному нижче прикладі ви побачите, що ми можемо легко тримати тести менше 1 хвилини, і це має велику користь: тести відображають поведінку програми в реальному світі. Це цінніше, ніж знати, працює ваш клас десь посередині програми нормально чи ні.

Крім того, якщо у вас немає тестів прямо зараз, починаючи з кінця до кінця, ви отримуєте великі переваги за невеликі зусилля. Ви будете знати, що всі стеки програми працюють разом для найбільш поширених сценаріїв. Це вже щось! Звідти ви завжди можете вдосконалити стратегію модульного тестування критичних частин вашого додатка.

Наш перший тест

Почнемо з найпростішої частини: наш API та база даних Postgres. І давайте проведемо простий CRUD-тест. Після того, як ми створимо цей фреймворк, ми можемо додати більше можливостей як до нашого компонента, так і до тесту.

Ось наш мінімальний API із GET / POST для створення та складання списку користувачів:

const express = require('express'); const bodyParser = require('body-parser'); const cors = require('cors'); const config = require('./config'); const db = require('knex')({ client: 'pg', connection: { host : config.db.host, user : config.db.user, password : config.db.password, }, }); const app = express(); app.use(bodyParser.urlencoded({ extended: false })); app.use(bodyParser.json()); app.use(cors()); app.route('/api/users').post(async (req, res, next) => { try { const { email, firstname } = req.body; // ... validate inputs here ... const userData = { email, firstname }; const result = await db('users').returning('id').insert(userData); const id = result[0]; res.status(201).send({ id, ...userData }); } catch (err) { console.log(`Error: Unable to create user: ${err.message}. ${err.stack}`); return next(err); } }); app.route('/api/users').get((req, res, next) => { db('users') .select('id', 'email', 'firstname') .then(users => res.status(200).send(users)) .catch(err => { console.log(`Unable to fetch users: ${err.message}. ${err.stack}`); return next(err); }); }); try { console.log("Starting web server..."); const port = process.env.PORT || 8000; app.listen(port, () => console.log(`Server started on: ${port}`)); } catch(error) { console.error(error.stack); }

Ось наші тести, написані chai. Тести створюють нового користувача та отримують його назад. Ви бачите, що тести ніяк не пов'язані з кодом нашого API. SERVER_URLМінлива визначає кінцеву точку для тестування. Це може бути локальне або віддалене середовище.

const chai = require("chai"); const chaiHttp = require("chai-http"); const should = chai.should(); const SERVER_URL = process.env.APP_URL || "//localhost:8000"; chai.use(chaiHttp); const TEST_USER = { email: "[email protected]", firstname: "John" }; let createdUserId; describe("Users", () => { it("should create a new user", done => { chai .request(SERVER_URL) .post("/api/users") .send(TEST_USER) .end((err, res) => { if (err) done(err) res.should.have.status(201); res.should.be.json; res.body.should.be.a("object"); res.body.should.have.property("id"); done(); }); }); it("should get the created user", done => { chai .request(SERVER_URL) .get("/api/users") .end((err, res) => { if (err) done(err) res.should.have.status(200); res.body.should.be.a("array"); const user = res.body.pop(); user.id.should.equal(createdUserId); user.email.should.equal(TEST_USER.email); user.firstname.should.equal(TEST_USER.firstname); done(); }); }); });

Добре. Тепер для тестування нашого API давайте визначимо середовище складання Docker. Файл із назвою docker-compose.ymlописує контейнери, які повинен запускати Docker.

version: '3.1' services: db: image: postgres environment: POSTGRES_USER: john POSTGRES_PASSWORD: mysecretpassword expose: - 5432 myapp: build: . image: myapp command: yarn start environment: APP_DB_HOST: db APP_DB_USER: john APP_DB_PASSWORD: mysecretpassword expose: - 8000 depends_on: - db myapp-tests: image: myapp command: dockerize -wait tcp://db:5432 -wait tcp://myapp:8000 -timeout 10s bash -c "node db/init.js && yarn test" environment: APP_URL: //myapp:8000 APP_DB_HOST: db APP_DB_USER: john APP_DB_PASSWORD: mysecretpassword depends_on: - db - myapp

То що ми маємо тут. Є 3 контейнери:

  • db обертає новий екземпляр PostgreSQL. Ми використовуємо загальнодоступне зображення Postgres від Docker Hub. Ми встановлюємо ім'я користувача та пароль бази даних. Ми кажемо Docker виставити порт 5432, який буде слухати база даних, щоб інші контейнери могли підключатися
  • myapp - це контейнер, який буде запускати наш API. buildКоманда говорить докер насправді побудувати контейнер зображення з нашого джерела. Решта - як контейнер db: змінні середовища та порти
  • myapp-tests - це контейнер, який буде виконувати наші тести. Він використовуватиме те саме зображення, що і myapp, оскільки код вже буде там, тому немає необхідності будувати його знову. Команда, node db/init.js && yarn testзапущена на контейнері, ініціалізує базу даних (створює таблиці тощо) та запускає тести. Ми використовуємо dockerize, щоб дочекатися, поки всі необхідні сервери запрацюють. Ці depends_onопції будуть гарантувати , що контейнери починають в певному порядку. Це не гарантує, що база даних всередині контейнера db насправді готова приймати підключення. Також, що наш сервер API вже працює.

Визначення середовища - це як 20 рядків дуже простого для розуміння коду. Єдиною розумною частиною є визначення середовища. Імена користувачів, паролі та URL-адреси повинні узгоджуватися, щоб контейнери могли фактично працювати разом.

Одне, на що слід звернути увагу, це те, що Docker compose встановить хост створених ним контейнерів на ім'я контейнера. Таким чином, база даних не буде доступна під localhost:5432але db:5432. Так само, як буде обслуговуватися наш API myapp:8000. Тут немає жодного локального хоста.

Це означає, що ваш API повинен підтримувати змінні середовища, коли мова йде про визначення середовища. Немає твердо закодованих речей. Але це не має нічого спільного з Docker чи цією статтею. Настроюваний додаток - це пункт 3 маніфесту програми з 12 факторів, тому ви вже повинні це робити.

Останнє, що нам потрібно сказати Docker, - це те, як насправді побудувати контейнер myapp . Ми використовуємо файл Docker, як показано нижче. Вміст специфічний для вашого технологічного стеку, але ідея полягає в тому, щоб об’єднати ваш API у запущений сервер.

Наведений нижче приклад для нашого Node API встановлює Dockerize, встановлює залежності API і копіює код API всередині контейнера (сервер написаний у JS-форматі, тому не потрібно його компілювати).

FROM node AS base # Dockerize is needed to sync containers startup ENV DOCKERIZE_VERSION v0.6.0 RUN wget //github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ && tar -C /usr/local/bin -xzvf dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ && rm dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz RUN mkdir -p ~/app WORKDIR ~/app COPY package.json . COPY yarn.lock . FROM base AS dependencies RUN yarn FROM dependencies AS runtime COPY . .

Зазвичай з рядка WORKDIR ~/appта нижче ви запускаєте команди, які будують вашу програму.

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

docker-compose up --build --abort-on-container-exit

Ця команда скаже Docker compose, щоб закрутити компоненти, визначені у нашому docker-compose.ymlфайлі. --buildПрапор буде ініціювати складання контейнера MYAPP шляхом виконання змісту Dockerfileвище. --abort-on-container-exitРозповість Docker скласти для завершення роботи середовища , як тільки один контейнер виходів.

Це працює добре, оскільки єдиним компонентом, призначеним для виходу, є тестовий контейнер myapp-tests після виконання тестів. Cherry on the cake, docker-composeкоманда вийде з тим самим кодом виходу, що і контейнер, який ініціював вихід. Це означає, що ми можемо перевірити, чи успішно виконали тести за допомогою командного рядка. Це дуже корисно для автоматизованих збірок в середовищі CI.

Хіба це не ідеальна настройка тесту?

Повний приклад - тут, на GitHub. Ви можете клонувати сховище та запустити команду docker compose:

docker-compose up --build --abort-on-container-exit

Звичайно, вам потрібно встановити Docker. Докер має клопітку тенденцію змушувати вас реєструватися в обліковому записі лише для завантаження речі. Але насправді не потрібно. Перейдіть до приміток до випуску (посилання для Windows і посилання для Mac) і завантажте не останню версію, а попередню. Це пряме посилання для завантаження.

Перший запуск тестів буде довшим, ніж зазвичай. Це тому, що Docker повинен буде завантажити базові зображення для ваших контейнерів та кешувати кілька речей. Наступні пробіжки будуть набагато швидшими.

Журнали з запуску будуть виглядати, як показано нижче. Ви бачите, що Docker досить крутий, щоб розміщувати журнали з усіх компонентів на одній часовій шкалі. Це дуже зручно при пошуку помилок.

Creating tuto-api-e2e-testing_db_1 ... done Creating tuto-api-e2e-testing_redis_1 ... done Creating tuto-api-e2e-testing_myapp_1 ... done Creating tuto-api-e2e-testing_myapp-tests_1 ... done Attaching to tuto-api-e2e-testing_redis_1, tuto-api-e2e-testing_db_1, tuto-api-e2e-testing_myapp_1, tuto-api-e2e-testing_myapp-tests_1 db_1 | The files belonging to this database system will be owned by user "postgres". redis_1 | 1:M 09 Nov 2019 21:57:22.161 * Running mode=standalone, port=6379. myapp_1 | yarn run v1.19.0 redis_1 | 1:M 09 Nov 2019 21:57:22.162 # WARNING: The TCP backlog setting of 511 cannot be enforced because /proc/sys/net/core/somaxconn is set to the lower value of 128. redis_1 | 1:M 09 Nov 2019 21:57:22.162 # Server initialized db_1 | This user must also own the server process. db_1 | db_1 | The database cluster will be initialized with locale "en_US.utf8". db_1 | The default database encoding has accordingly been set to "UTF8". db_1 | The default text search configuration will be set to "english". db_1 | db_1 | Data page checksums are disabled. db_1 | db_1 | fixing permissions on existing directory /var/lib/postgresql/data ... ok db_1 | creating subdirectories ... ok db_1 | selecting dynamic shared memory implementation ... posix myapp-tests_1 | 2019/11/09 21:57:25 Waiting for: tcp://db:5432 myapp-tests_1 | 2019/11/09 21:57:25 Waiting for: tcp://redis:6379 myapp-tests_1 | 2019/11/09 21:57:25 Waiting for: tcp://myapp:8000 myapp_1 | $ node server.js redis_1 | 1:M 09 Nov 2019 21:57:22.163 # WARNING you have Transparent Huge Pages (THP) support enabled in your kernel. This will create latency and memory usage issues with Redis. To fix this issue run the command 'echo never > /sys/kernel/mm/transparent_hugepage/enabled' as root, and add it to your /etc/rc.local in order to retain the setting after a reboot. Redis must be restarted after THP is disabled. db_1 | selecting default max_connections ... 100 myapp_1 | Starting web server... myapp-tests_1 | 2019/11/09 21:57:25 Connected to tcp://myapp:8000 myapp-tests_1 | 2019/11/09 21:57:25 Connected to tcp://db:5432 redis_1 | 1:M 09 Nov 2019 21:57:22.164 * Ready to accept connections myapp-tests_1 | 2019/11/09 21:57:25 Connected to tcp://redis:6379 myapp_1 | Server started on: 8000 db_1 | selecting default shared_buffers ... 128MB db_1 | selecting default time zone ... Etc/UTC db_1 | creating configuration files ... ok db_1 | running bootstrap script ... ok db_1 | performing post-bootstrap initialization ... ok db_1 | syncing data to disk ... ok db_1 | db_1 | db_1 | Success. You can now start the database server using: db_1 | db_1 | pg_ctl -D /var/lib/postgresql/data -l logfile start db_1 | db_1 | initdb: warning: enabling "trust" authentication for local connections db_1 | You can change this by editing pg_hba.conf or using the option -A, or db_1 | --auth-local and --auth-host, the next time you run initdb. db_1 | waiting for server to start....2019-11-09 21:57:24.328 UTC [41] LOG: starting PostgreSQL 12.0 (Debian 12.0-2.pgdg100+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 8.3.0-6) 8.3.0, 64-bit db_1 | 2019-11-09 21:57:24.346 UTC [41] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432" db_1 | 2019-11-09 21:57:24.373 UTC [42] LOG: database system was shut down at 2019-11-09 21:57:23 UTC db_1 | 2019-11-09 21:57:24.383 UTC [41] LOG: database system is ready to accept connections db_1 | done db_1 | server started db_1 | CREATE DATABASE db_1 | db_1 | db_1 | /usr/local/bin/docker-entrypoint.sh: ignoring /docker-entrypoint-initdb.d/* db_1 | db_1 | waiting for server to shut down....2019-11-09 21:57:24.907 UTC [41] LOG: received fast shutdown request db_1 | 2019-11-09 21:57:24.909 UTC [41] LOG: aborting any active transactions db_1 | 2019-11-09 21:57:24.914 UTC [41] LOG: background worker "logical replication launcher" (PID 48) exited with exit code 1 db_1 | 2019-11-09 21:57:24.914 UTC [43] LOG: shutting down db_1 | 2019-11-09 21:57:24.930 UTC [41] LOG: database system is shut down db_1 | done db_1 | server stopped db_1 | db_1 | PostgreSQL init process complete; ready for start up. db_1 | db_1 | 2019-11-09 21:57:25.038 UTC [1] LOG: starting PostgreSQL 12.0 (Debian 12.0-2.pgdg100+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 8.3.0-6) 8.3.0, 64-bit db_1 | 2019-11-09 21:57:25.039 UTC [1] LOG: listening on IPv4 address "0.0.0.0", port 5432 db_1 | 2019-11-09 21:57:25.039 UTC [1] LOG: listening on IPv6 address "::", port 5432 db_1 | 2019-11-09 21:57:25.052 UTC [1] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432" db_1 | 2019-11-09 21:57:25.071 UTC [59] LOG: database system was shut down at 2019-11-09 21:57:24 UTC db_1 | 2019-11-09 21:57:25.077 UTC [1] LOG: database system is ready to accept connections myapp-tests_1 | Creating tables ... myapp-tests_1 | Creating table 'users' myapp-tests_1 | Tables created succesfully myapp-tests_1 | yarn run v1.19.0 myapp-tests_1 | $ mocha --timeout 10000 --bail myapp-tests_1 | myapp-tests_1 | myapp-tests_1 | Users myapp-tests_1 | Mock server started on port: 8002 myapp-tests_1 | ✓ should create a new user (151ms) myapp-tests_1 | ✓ should get the created user myapp-tests_1 | ✓ should not create user if mail is spammy myapp-tests_1 | ✓ should not create user if spammy mail API is down myapp-tests_1 | myapp-tests_1 | myapp-tests_1 | 4 passing (234ms) myapp-tests_1 | myapp-tests_1 | Done in 0.88s. myapp-tests_1 | 2019/11/09 21:57:26 Command finished successfully. tuto-api-e2e-testing_myapp-tests_1 exited with code 0

Ми бачимо, що db - це контейнер, який ініціалізується найдовше. Має сенс. Після цього тести починаються. Загальний час роботи на моєму ноутбуці становить 16 секунд. Порівняно з 880 мс, які використовувались для фактичного виконання тестів, це дуже багато. На практиці тести, які тривають менше 1 хвилини, є золотим, оскільки це майже негайний відгук. Накладні витрати в 15 секунд - це час покупки, який буде постійним, коли ви додасте більше тестів. Ви можете додати сотні тестів і все одно тримати час виконання менше 1 хвилини.

Вуаля! У нас є своя тестова структура, яка працює. У реальному проекті наступними кроками буде розширення функціонального покриття вашого API за допомогою більшої кількості тестів. Давайте розглянемо CRUD-операції. Пора додати більше елементів до нашого тестового середовища.

Додавання кластера Redis

Давайте додамо ще один елемент до нашого середовища API, щоб зрозуміти, що для цього потрібно. Попередження спойлера: це не багато.

Уявімо, що наш API зберігає сеанси користувачів у кластері Redis. Якщо вам цікаво, чому ми це робимо, уявіть 100 екземплярів вашого API у виробництві. Користувачі потрапляють на той чи інший сервер на основі балансування навантаження. Кожен запит повинен бути автентифікований.

Для цього потрібні дані профілю користувача для перевірки привілеїв та іншої бізнес-логіки, яка стосується конкретних додатків. Один із шляхів - здійснити зворотний шлях до бази даних, щоб отримати дані кожного разу, коли вам це потрібно, але це не дуже ефективно. Використання кластера баз даних in memory робить дані доступними на всіх серверах за вартість зчитування локальної змінної.

Ось як ви покращуєте тестове середовище Docker для створення додаткових послуг. Давайте додамо кластер Redis з офіційного образу Docker (я зберігав лише нові частини файлу):

services: db: ... redis: image: "redis:alpine" expose: - 6379 myapp: environment: APP_REDIS_HOST: redis APP_REDIS_PORT: 6379 ... myapp-tests: command: dockerize ... -wait tcp://redis:6379 ... environment: APP_REDIS_HOST: redis APP_REDIS_PORT: 6379 ... ...

Ви бачите, що це не багато. Ми додали новий контейнер під назвою redis . Він використовує офіційне мінімальне зображення redis, яке називається redis:alpine. Ми додали конфігурацію хосту та порту Redis до нашого контейнера API. І ми змусили тести дочекатися його, як і інших контейнерів, перш ніж виконувати тести.

Давайте змінимо наш додаток, щоб насправді використовувати кластер Redis:

const redis = require('redis').createClient({ host: config.redis.host, port: config.redis.port, }) ... app.route('/api/users').post(async (req, res, next) => { try { const { email, firstname } = req.body; // ... validate inputs here ... const userData = { email, firstname }; const result = await db('users').returning('id').insert(userData); const id = result[0]; // Once the user is created store the data in the Redis cluster await redis.set(id, JSON.stringify(userData)); res.status(201).send({ id, ...userData }); } catch (err) { console.log(`Error: Unable to create user: ${err.message}. ${err.stack}`); return next(err); } });

Давайте тепер змінимо наші тести, щоб перевірити, чи кластер Redis заповнений правильними даними. Ось чому контейнер myapp-tests також отримує конфігурацію хосту та порту Redis у docker-compose.yml.

it("should create a new user", done => { chai .request(SERVER_URL) .post("/api/users") .send(TEST_USER) .end((err, res) => { if (err) throw err; res.should.have.status(201); res.should.be.json; res.body.should.be.a("object"); res.body.should.have.property("id"); res.body.should.have.property("email"); res.body.should.have.property("firstname"); res.body.id.should.not.be.null; res.body.email.should.equal(TEST_USER.email); res.body.firstname.should.equal(TEST_USER.firstname); createdUserId = res.body.id; redis.get(createdUserId, (err, cacheData) => { if (err) throw err; cacheData = JSON.parse(cacheData); cacheData.should.have.property("email"); cacheData.should.have.property("firstname"); cacheData.email.should.equal(TEST_USER.email); cacheData.firstname.should.equal(TEST_USER.firstname); done(); }); }); });

Подивіться, як легко це було. Ви можете створити складне середовище для своїх тестів, наче збираєте цеглинки Lego.

Ми можемо побачити ще одну перевагу такого типу контейнерного тестування повного середовища. Тести можуть насправді вивчити компоненти навколишнього середовища. Наші тести можуть не лише перевірити, чи наш API повертає правильні коди відповіді та дані. Ми також можемо перевірити, чи дані в кластері Redis мають належні значення. Ми також могли перевірити вміст бази даних.

Додавання макетів API

Загальним елементом для компонентів API є виклик інших компонентів API.

Скажімо, наш API повинен перевіряти спам-повідомлення користувачів при створенні користувача. Перевірка здійснюється за допомогою сторонньої служби:

const validateUserEmail = async (email) => { const res = await fetch(`${config.app.externalUrl}/validate?email=${email}`); if(res.status !== 200) return false; const json = await res.json(); return json.result === 'valid'; } app.route('/api/users').post(async (req, res, next) => { try { const { email, firstname } = req.body; // ... validate inputs here ... const userData = { email, firstname }; // We don't just create any user. Spammy emails should be rejected const isValidUser = await validateUserEmail(email); if(!isValidUser) { return res.sendStatus(403); } const result = await db('users').returning('id').insert(userData); const id = result[0]; await redis.set(id, JSON.stringify(userData)); res.status(201).send({ id, ...userData }); } catch (err) { console.log(`Error: Unable to create user: ${err.message}. ${err.stack}`); return next(err); } });

Зараз у нас виникла проблема з тестуванням чого-небудь. Ми не можемо створити користувачів, якщо API для виявлення спаму не доступний. Зміна нашого API для обходу цього кроку в тестовому режимі є небезпечним захаращенням коду.

Навіть якби ми могли скористатися справжньою сторонньою службою, ми не хочемо цього робити. Як правило, наші тести не повинні залежати від зовнішньої інфраструктури. Перш за все тому, що ви, мабуть, багато будете запускати свої тести як частину процесу CI. Не дуже класно споживати для цього інший виробничий API. По-друге, API може тимчасово не працювати, не вдаючись до тестів з неправильних причин.

Правильним рішенням є знущання над зовнішніми API в наших тестах.

Не потрібно ніяких вигадливих фреймворків. Ми створимо загальний макет у ванільному JS у ~ 20 рядків коду. Це дасть нам можливість контролювати, що API поверне нашому компоненту. Це дозволяє протестувати сценарії помилок.

А тепер давайте вдосконалюємо наші тести.

const express = require("express"); ... const MOCK_SERVER_PORT = process.env.MOCK_SERVER_PORT || 8002; // Some object to encapsulate attributes of our mock server // The mock stores all requests it receives in the `requests` property. const mock = { app: express(), server: null, requests: [], status: 404, responseBody: {} }; // Define which response code and content the mock will be sending const setupMock = (status, body) => { mock.status = status; mock.responseBody = body; }; // Start the mock server const initMock = async () => { mock.app.use(bodyParser.urlencoded({ extended: false })); mock.app.use(bodyParser.json()); mock.app.use(cors()); mock.app.get("*", (req, res) => { mock.requests.push(req); res.status(mock.status).send(mock.responseBody); }); mock.server = await mock.app.listen(MOCK_SERVER_PORT); console.log(`Mock server started on port: ${MOCK_SERVER_PORT}`); }; // Destroy the mock server const teardownMock = () => { if (mock.server) { mock.server.close(); delete mock.server; } }; describe("Users", () => { // Our mock is started before any test starts ... before(async () => await initMock()); // ... killed after all the tests are executed ... after(() => { redis.quit(); teardownMock(); }); // ... and we reset the recorded requests between each test beforeEach(() => (mock.requests = [])); it("should create a new user", done => { // The mock will tell us the email is valid in this test setupMock(200, { result: "valid" }); chai .request(SERVER_URL) .post("/api/users") .send(TEST_USER) .end((err, res) => { // ... check response and redis as before createdUserId = res.body.id; // Verify that the API called the mocked service with the right parameters mock.requests.length.should.equal(1); mock.requests[0].path.should.equal("/api/validate"); mock.requests[0].query.should.have.property("email"); mock.requests[0].query.email.should.equal(TEST_USER.email); done(); }); }); });

Тепер тести перевіряють, чи під час виклику нашого API зовнішній API потрапив у відповідні дані.

Ми також можемо додати інші тести, що перевіряють поведінку нашого API на основі зовнішніх кодів відповідей API:

describe("Users", () => { it("should not create user if mail is spammy", done => { // The mock will tell us the email is NOT valid in this test ... setupMock(200, { result: "invalid" }); chai .request(SERVER_URL) .post("/api/users") .send(TEST_USER) .end((err, res) => { // ... so the API should fail to create the user // We could test that the DB and Redis are empty here res.should.have.status(403); done(); }); }); it("should not create user if spammy mail API is down", done => { // The mock will tell us the email checking service // is down for this test ... setupMock(500, {}); chai .request(SERVER_URL) .post("/api/users") .send(TEST_USER) .end((err, res) => { // ... in that case also a user should not be created res.should.have.status(403); done(); }); }); });

Звичайно, вирішувати, як ви обробляєте помилки сторонніх API у вашому додатку. Але ви зрозуміли суть.

Для запуску цих тестів нам потрібно сказати контейнеру myapp, що є базовою URL-адресою сторонньої служби:

 myapp: environment: APP_EXTERNAL_URL: //myapp-tests:8002/api ... myapp-tests: environment: MOCK_SERVER_PORT: 8002 ...

Висновок та кілька інших думок

Сподіваємось, ця стаття дала вам уявлення про те, що Docker може створити для вас, коли мова заходить про тестування API. Повний приклад - тут, на GitHub.

Використання Docker compose дозволяє швидко виконувати тести в середовищі, близькому до виробничого. Він не потребує адаптації до коду вашого компонента. Єдина вимога - підтримувати конфігурацію, керовану змінними середовища.

Логіка компонентів у цьому прикладі дуже проста, але принципи застосовуються до будь-якого API. Ваші тести будуть просто довшими або складнішими. Вони також застосовуються до будь-якого технологічного стеку, який можна помістити всередину контейнера (це всі вони). І як тільки ви там, вам потрібно лише крок від розгортання контейнерів на виробництві, якщо це необхідно.

Якщо у вас зараз немає тестів, я рекомендую вам розпочати: кінцеве тестування за допомогою Docker compose. Це настільки просто, що ви можете провести перший тест за кілька годин. Не соромтеся звертатися до мене, якщо у вас є запитання або потрібна порада. Я був би радий допомогти.

Сподіваюся, вам сподобалась ця стаття і ви почнете тестувати свої API за допомогою Docker Compose. Після того, як ви підготуєте тести, ви зможете запускати їх із коробки на нашій платформі постійної інтеграції Fire CI.

Остання ідея досягти успіху за допомогою автоматизованого тестування.

Що стосується обслуговування великих наборів тестів, найважливішою особливістю є те, що тести легко читати і розуміти. Це є ключовим мотивом вашої команди постійно проводити тести. Навряд чи складні тестові рамки будуть належним чином використовуватися в довгостроковій перспективі.

Незалежно від стека для вашого API, ви можете розглянути можливість використання chai / mocha для написання тестів для нього. Може здатися незвичним наявність різних стеків для коду виконання та тестового коду, але якщо це виконає роботу ... Як ви можете бачити з прикладів у цій статті, тестування REST API за допомогою chai / mocha настільки просте, наскільки це стає можливим . Крива навчання близька до нуля.

Отже, якщо у вас взагалі немає тестів і у вас є REST API для тестування, написаний на Java, Python, RoR, .NET або будь-якому іншому стеку, ви можете спробувати chai / mocha спробувати.

Якщо ви задаєтеся питанням, як взагалі розпочати з постійної інтеграції, я написав про це більш широке керівництво. Ось воно: Як розпочати роботу з Постійною інтеграцією

Спочатку опубліковано в блозі Fire CI.