Eine vollständige Anleitung zum End-to-End-API-Testen mit Docker

Testen ist im Allgemeinen ein Schmerz. Einige sehen den Punkt nicht. Einige sehen es, aber betrachten es als einen zusätzlichen Schritt, der sie verlangsamt. Manchmal sind Tests vorhanden, aber sehr lange oder instabil. In diesem Artikel erfahren Sie, wie Sie mit Docker selbst Tests durchführen können.

Wir möchten schnelle, aussagekräftige und zuverlässige Tests, die mit minimalem Aufwand geschrieben und gewartet werden. Dies bedeutet Tests, die für Sie als Entwickler täglich nützlich sind. Sie sollten Ihre Produktivität steigern und die Qualität Ihrer Software verbessern. Tests zu haben, weil jeder sagt "Sie sollten Tests haben", ist nicht gut, wenn es Sie verlangsamt.

Mal sehen, wie man das mit nicht so viel Aufwand erreicht.

Das Beispiel werden wir testen

In diesem Artikel werden wir eine mit Node / Express erstellte API testen und Chai / Mocha zum Testen verwenden. Ich habe einen JS'y-Stack gewählt, weil der Code sehr kurz und leicht zu lesen ist. Die angewandten Prinzipien gelten für jeden Tech-Stack. Lesen Sie weiter, auch wenn Javascript Sie krank macht.

Das Beispiel behandelt einen einfachen Satz von CRUD-Endpunkten für Benutzer. Es ist mehr als genug, um das Konzept zu verstehen und auf die komplexere Geschäftslogik Ihrer API anzuwenden.

Wir werden eine ziemlich standardmäßige Umgebung für die API verwenden:

  • Eine Postgres-Datenbank
  • Ein Redis-Cluster
  • Unsere API verwendet andere externe APIs, um ihre Arbeit zu erledigen

Ihre API benötigt möglicherweise eine andere Umgebung. Die in diesem Artikel angewandten Grundsätze bleiben unverändert. Sie verwenden verschiedene Docker-Basisimages, um die Komponenten auszuführen, die Sie möglicherweise benötigen.

Warum Docker? Und tatsächlich Docker Compose

Dieser Abschnitt enthält viele Argumente für die Verwendung von Docker zum Testen. Sie können es überspringen, wenn Sie sofort zum technischen Teil gelangen möchten.

Die schmerzhaften Alternativen

Um Ihre API in einer produktionsnahen Umgebung zu testen, haben Sie zwei Möglichkeiten. Sie können die Umgebung auf Codeebene verspotten oder die Tests auf einem realen Server mit installierter Datenbank usw. ausführen.

Wenn Sie alles auf Codeebene verspotten, wird der Code und die Konfiguration unserer API unübersichtlich. Es ist auch oft nicht sehr repräsentativ für das Verhalten der API in der Produktion. Das Ding auf einem echten Server auszuführen ist infrastrukturintensiv. Es ist viel Einrichtung und Wartung und es skaliert nicht. Mit einer gemeinsam genutzten Datenbank können Sie jeweils nur einen Test ausführen, um sicherzustellen, dass sich die Testläufe nicht gegenseitig stören.

Mit Docker Compose können wir das Beste aus beiden Welten herausholen. Es werden "containerisierte" Versionen aller von uns verwendeten externen Teile erstellt. Es ist spöttisch, aber außerhalb unseres Codes. Unsere API glaubt, dass es sich um eine reale physische Umgebung handelt. Docker Compose erstellt außerdem ein isoliertes Netzwerk für alle Container für einen bestimmten Testlauf. Auf diese Weise können Sie mehrere davon parallel auf Ihrem lokalen Computer oder einem CI-Host ausführen.

Overkill?

Sie werden sich vielleicht fragen, ob es nicht übertrieben ist, End-to-End-Tests mit Docker Compose durchzuführen. Wie wäre es stattdessen mit Unit-Tests?

In den letzten 10 Jahren wurden große Monolithanwendungen in kleinere Dienste aufgeteilt (Tendenz zu den lebhaften "Mikrodiensten"). Eine bestimmte API-Komponente basiert auf mehr externen Teilen (Infrastruktur oder andere APIs). Wenn die Services kleiner werden, wird die Integration in die Infrastruktur zu einem größeren Teil der Arbeit.

Sie sollten eine kleine Lücke zwischen Ihrer Produktion und Ihren Entwicklungsumgebungen einhalten. Andernfalls treten Probleme bei der Bereitstellung in der Produktion auf. Per Definition treten diese Probleme im schlimmsten Moment auf. Sie werden zu überstürzten Korrekturen, Qualitätsverlusten und Frustration für das Team führen. Das will niemand.

Möglicherweise fragen Sie sich, ob End-to-End-Tests mit Docker Compose länger dauern als herkömmliche Komponententests. Nicht wirklich. Im folgenden Beispiel sehen Sie, dass wir die Tests problemlos unter 1 Minute halten können, was von großem Vorteil ist: Die Tests spiegeln das Anwendungsverhalten in der realen Welt wider. Dies ist wertvoller als zu wissen, ob Ihre Klasse irgendwo in der Mitte der App in Ordnung ist oder nicht.

Wenn Sie gerade keine Tests haben, bietet Ihnen das Beginnen von Ende zu Ende große Vorteile bei geringem Aufwand. Sie wissen, dass alle Stapel der Anwendung für die gängigsten Szenarien zusammenarbeiten. Das ist schon was! Von dort aus können Sie jederzeit eine Strategie verfeinern, um kritische Teile Ihrer Anwendung zu testen.

Unser erster Test

Beginnen wir mit dem einfachsten Teil: unserer API und der Postgres-Datenbank. Und lassen Sie uns einen einfachen CRUD-Test durchführen. Sobald wir dieses Framework eingerichtet haben, können wir unserer Komponente und dem Test weitere Funktionen hinzufügen.

Hier ist unsere minimale API mit einem GET / POST zum Erstellen und Auflisten von Benutzern:

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); }

Hier sind unsere Tests mit Chai geschrieben. Die Tests erstellen einen neuen Benutzer und holen ihn zurück. Sie können sehen, dass die Tests in keiner Weise mit dem Code unserer API gekoppelt sind. Die SERVER_URLVariable gibt den zu testenden Endpunkt an. Dies kann eine lokale oder eine entfernte Umgebung sein.

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(); }); }); });

Gut. Um unsere API zu testen, definieren wir nun eine Docker-Compose-Umgebung. Eine aufgerufene Datei docker-compose.ymlbeschreibt die Container, die Docker ausführen muss.

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

Was haben wir hier? Es gibt 3 Container:

  • db startet eine neue Instanz von PostgreSQL. Wir verwenden das öffentliche Postgres-Image von Docker Hub. Wir legen den Datenbank-Benutzernamen und das Passwort fest. Wir weisen Docker an, den Port 5432 bereitzustellen, den die Datenbank abhört, damit andere Container eine Verbindung herstellen können
  • myapp ist der Container, in dem unsere API ausgeführt wird. Der buildBefehl weist Docker an, das Container-Image tatsächlich aus unserer Quelle zu erstellen. Der Rest ist wie der Datenbankcontainer: Umgebungsvariablen und Ports
  • myapp-tests ist der Container, der unsere Tests ausführt. Es wird dasselbe Image wie myapp verwendet, da der Code bereits vorhanden ist und nicht erneut erstellt werden muss. Der node db/init.js && yarn testauf dem Container ausgeführte Befehl initialisiert die Datenbank (erstellt Tabellen usw.) und führt die Tests aus. Wir verwenden Dockerize, um darauf zu warten, dass alle erforderlichen Server betriebsbereit sind. Die depends_onOptionen stellen sicher, dass Container in einer bestimmten Reihenfolge gestartet werden. Es wird nicht sichergestellt, dass die Datenbank im Datenbankcontainer tatsächlich bereit ist, Verbindungen zu akzeptieren. Auch dass unser API-Server nicht bereits aktiv ist.

Die Definition der Umgebung entspricht 20 Zeilen sehr leicht verständlichen Codes. Der einzige kluge Teil ist die Umgebungsdefinition. Benutzernamen, Kennwörter und URLs müssen konsistent sein, damit Container tatsächlich zusammenarbeiten können.

Zu beachten ist, dass Docker Compose den Host der von ihm erstellten Container auf den Namen des Containers setzt. So wird die Datenbank unter nicht verfügbar sein , localhost:5432aber db:5432. Ebenso wird unsere API unter bereitgestellt myapp:8000. Hier gibt es keinerlei Lokalhost.

Dies bedeutet, dass Ihre API Umgebungsvariablen unterstützen muss, wenn es um die Umgebungsdefinition geht. Keine fest codierten Sachen. Das hat aber nichts mit Docker oder diesem Artikel zu tun. Eine konfigurierbare Anwendung ist Punkt 3 des 12-Faktor-App-Manifests. Sie sollten dies also bereits tun.

Das allerletzte, was wir Docker mitteilen müssen, ist, wie der Container myapp tatsächlich erstellt wird . Wir verwenden eine Docker-Datei wie unten. Der Inhalt ist spezifisch für Ihren Tech-Stack, aber die Idee ist, Ihre API in einem ausführbaren Server zu bündeln.

Das folgende Beispiel für unsere Knoten-API installiert Dockerize, installiert die API-Abhängigkeiten und kopiert den Code der API in den Container (der Server ist in unformatiertem JS geschrieben, sodass keine Kompilierung erforderlich ist).

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 . .

Normalerweise führen Sie in der Zeile WORKDIR ~/appund darunter Befehle aus, mit denen Ihre Anwendung erstellt wird.

Und hier ist der Befehl, mit dem wir die Tests ausführen:

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

Dieser Befehl weist Docker compose an, die in unserer docker-compose.ymlDatei definierten Komponenten hochzufahren. Das --buildFlag löst den Build des myapp-Containers aus, indem der Inhalt des Dockerfileoben genannten ausgeführt wird. Das --abort-on-container-exitwird Docker compose anweisen, die Umgebung herunterzufahren, sobald ein Container beendet wird.

Das funktioniert gut, da die einzige Komponente, die beendet werden soll, der Testcontainer myapp-tests ist, nachdem die Tests ausgeführt wurden. Cherry on the Cake, der docker-composeBefehl wird mit demselben Exit-Code beendet wie der Container, der den Exit ausgelöst hat. Dies bedeutet, dass wir über die Befehlszeile überprüfen können, ob die Tests erfolgreich waren oder nicht. Dies ist sehr nützlich für automatisierte Builds in einer CI-Umgebung.

Ist das nicht der perfekte Testaufbau?

Das vollständige Beispiel finden Sie hier auf GitHub. Sie können das Repository klonen und den Docker-Kompositionsbefehl ausführen:

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

Natürlich muss Docker installiert sein. Docker hat die lästige Tendenz, Sie zu zwingen, sich für ein Konto anzumelden, nur um das Ding herunterzuladen. Aber das musst du eigentlich nicht. Gehen Sie zu den Versionshinweisen (Link für Windows und Link für Mac) und laden Sie nicht die neueste Version herunter, sondern die direkt davor. Dies ist ein direkter Download-Link.

Der allererste Testlauf wird länger als gewöhnlich dauern. Dies liegt daran, dass Docker die Basisimages für Ihre Container herunterladen und einige Dinge zwischenspeichern muss. Die nächsten Läufe werden viel schneller sein.

Die Protokolle des Laufs sehen wie folgt aus. Sie können sehen, dass Docker cool genug ist, um Protokolle aller Komponenten auf derselben Zeitachse abzulegen. Dies ist sehr praktisch, wenn Sie nach Fehlern suchen.

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

Wir können sehen, dass db der Container ist, der am längsten initialisiert. Macht Sinn. Sobald es fertig ist, beginnen die Tests. Die Gesamtlaufzeit auf meinem Laptop beträgt 16 Sekunden. Im Vergleich zu den 880 ms, mit denen die Tests tatsächlich ausgeführt wurden, ist dies eine Menge. In der Praxis sind Tests, die weniger als 1 Minute dauern, Gold wert, da es sich um eine fast sofortige Rückmeldung handelt. Der Overhead von 15 Sekunden ist eine Buy-In-Zeit, die konstant bleibt, wenn Sie weitere Tests hinzufügen. Sie können Hunderte von Tests hinzufügen und trotzdem die Ausführungszeit unter 1 Minute halten.

Voilà! Wir haben unser Test-Framework eingerichtet. In einem realen Projekt besteht der nächste Schritt darin, die Funktionsabdeckung Ihrer API durch weitere Tests zu verbessern. Betrachten wir die behandelten CRUD-Operationen. Es ist Zeit, unserer Testumgebung weitere Elemente hinzuzufügen.

Hinzufügen eines Redis-Clusters

Fügen wir unserer API-Umgebung ein weiteres Element hinzu, um zu verstehen, was erforderlich ist. Spoiler Alarm: Es ist nicht viel.

Stellen wir uns vor, unsere API hält Benutzersitzungen in einem Redis-Cluster. Wenn Sie sich fragen, warum wir das tun würden, stellen Sie sich 100 Instanzen Ihrer API in der Produktion vor. Benutzer treffen den einen oder anderen Server basierend auf dem Round-Robin-Lastausgleich. Jede Anfrage muss authentifiziert werden.

Dies erfordert Benutzerprofildaten, um nach Berechtigungen und anderer anwendungsspezifischer Geschäftslogik zu suchen. Eine Möglichkeit besteht darin, einen Rundgang zur Datenbank zu machen, um die Daten jedes Mal abzurufen, wenn Sie sie benötigen. Dies ist jedoch nicht sehr effizient. Durch die Verwendung eines In-Memory-Datenbankclusters werden die Daten für die Kosten einer gelesenen lokalen Variablen auf allen Servern verfügbar.

Auf diese Weise erweitern Sie Ihre Docker Compose-Testumgebung um einen zusätzlichen Service. Fügen wir einen Redis-Cluster aus dem offiziellen Docker-Image hinzu (ich habe nur die neuen Teile der Datei beibehalten):

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 ... ...

Sie können sehen, dass es nicht viel ist. Wir haben einen neuen Container namens redis hinzugefügt . Es wird das offizielle Minimal-Redis-Bild verwendet redis:alpine. Wir haben unserem API-Container die Redis-Host- und Portkonfiguration hinzugefügt. Und wir haben Tests darauf warten lassen, ebenso wie die anderen Container, bevor wir die Tests ausführen.

Lassen Sie uns unsere Anwendung so ändern, dass sie tatsächlich den Redis-Cluster verwendet:

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); } });

Lassen Sie uns nun unsere Tests ändern, um zu überprüfen, ob der Redis-Cluster mit den richtigen Daten gefüllt ist. Aus diesem Grund erhält der myapp-tests- Container auch die Redis-Host- und Portkonfiguration 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(); }); }); });

Sehen Sie, wie einfach das war. Sie können eine komplexe Umgebung für Ihre Tests erstellen, indem Sie Legosteine ​​zusammenbauen.

Wir können einen weiteren Vorteil dieser Art von vollständigen Umgebungstests in Containern sehen. Die Tests können tatsächlich die Komponenten der Umgebung untersuchen. Unsere Tests können nicht nur überprüfen, ob unsere API die richtigen Antwortcodes und Daten zurückgibt. Wir können auch überprüfen, ob die Daten im Redis-Cluster die richtigen Werte haben. Wir könnten auch den Datenbankinhalt überprüfen.

API-Mocks hinzufügen

Ein häufiges Element für API-Komponenten ist das Aufrufen anderer API-Komponenten.

Angenommen, unsere API muss beim Erstellen eines Benutzers nach E-Mails von Spam-Benutzern suchen. Die Überprüfung erfolgt über einen Drittanbieter:

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); } });

Jetzt haben wir ein Problem beim Testen. Wir können keine Benutzer erstellen, wenn die API zum Erkennen von Spam-E-Mails nicht verfügbar ist. Das Ändern unserer API, um diesen Schritt im Testmodus zu umgehen, ist eine gefährliche Unordnung des Codes.

Selbst wenn wir den echten Drittanbieter-Service nutzen könnten, möchten wir das nicht tun. In der Regel sollten unsere Tests nicht von der externen Infrastruktur abhängen. Zuallererst, weil Sie Ihre Tests wahrscheinlich viel als Teil Ihres CI-Prozesses ausführen werden. Es ist nicht so cool, eine andere Produktions-API für diesen Zweck zu verwenden. Zweitens ist die API möglicherweise vorübergehend nicht verfügbar und schlägt Ihre Tests aus den falschen Gründen fehl.

Die richtige Lösung besteht darin, die externen APIs in unseren Tests zu verspotten.

Kein ausgefallener Rahmen erforderlich. Wir werden ein generisches Modell in Vanilla JS in ~ 20 Codezeilen erstellen. Dies gibt uns die Möglichkeit zu steuern, was die API an unsere Komponente zurückgibt. Es ermöglicht das Testen von Fehlerszenarien.

Lassen Sie uns nun unsere Tests verbessern.

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(); }); }); });

Die Tests überprüfen nun, ob die externe API während des Aufrufs unserer API mit den richtigen Daten getroffen wurde.

Wir können auch andere Tests hinzufügen, die das Verhalten unserer API basierend auf den externen API-Antwortcodes überprüfen:

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(); }); }); });

Wie Sie mit Fehlern von APIs von Drittanbietern in Ihrer Anwendung umgehen, liegt natürlich bei Ihnen. Aber du verstehst, worum es geht.

Um diese Tests auszuführen, müssen wir dem Container myapp mitteilen, wie die Basis-URL des Drittanbieter-Dienstes lautet:

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

Fazit und ein paar andere Gedanken

Hoffentlich gab Ihnen dieser Artikel einen Vorgeschmack darauf, was Docker Compose beim API-Testen für Sie tun kann. Das vollständige Beispiel finden Sie hier auf GitHub.

Mit Docker Compose können Tests in einer produktionsnahen Umgebung schnell ausgeführt werden. Es sind keine Anpassungen an Ihrem Komponentencode erforderlich. Die einzige Voraussetzung ist die Unterstützung der umgebungsvariablengesteuerten Konfiguration.

Die Komponentenlogik in diesem Beispiel ist sehr einfach, aber die Prinzipien gelten für jede API. Ihre Tests werden nur länger oder komplexer. Sie gelten auch für jeden Tech-Stack, der in einen Container gelegt werden kann (das sind alles). Und sobald Sie dort sind, sind Sie nur noch einen Schritt davon entfernt, Ihre Container bei Bedarf für die Produktion bereitzustellen.

Wenn Sie derzeit keine Tests haben, empfehlen wir Ihnen, die Tests mit Docker Compose zu starten. Es ist so einfach, dass Sie Ihren ersten Test in wenigen Stunden durchführen können. Sie können sich gerne an mich wenden, wenn Sie Fragen haben oder Rat benötigen. Ich würde gerne helfen.

Ich hoffe, Ihnen hat dieser Artikel gefallen und Sie werden Ihre APIs mit Docker Compose testen. Sobald Sie die Tests fertig haben, können Sie sie sofort auf unserer kontinuierlichen Integrationsplattform Fire CI ausführen.

Eine letzte Idee, um mit automatisierten Tests erfolgreich zu sein.

Wenn es um die Wartung großer Testsuiten geht, ist das wichtigste Merkmal, dass Tests leicht zu lesen und zu verstehen sind. Dies ist der Schlüssel, um Ihr Team zu motivieren, die Tests auf dem neuesten Stand zu halten. Es ist unwahrscheinlich, dass komplexe Test-Frameworks auf lange Sicht ordnungsgemäß verwendet werden.

Unabhängig vom Stapel für Ihre API möchten Sie möglicherweise Chai / Mokka verwenden, um Tests dafür zu schreiben. Es mag ungewöhnlich erscheinen, unterschiedliche Stapel für Laufzeitcode und Testcode zu haben, aber wenn dies erledigt ist ... Wie Sie den Beispielen in diesem Artikel entnehmen können, ist das Testen einer REST-API mit Chai / Mokka so einfach wie möglich . Die Lernkurve liegt nahe bei Null.

Wenn Sie also überhaupt keine Tests haben und eine REST-API zum Testen in Java, Python, RoR, .NET oder einem anderen Stapel geschrieben haben, sollten Sie Chai / Mocha ausprobieren.

Wenn Sie sich fragen, wie Sie überhaupt mit einer kontinuierlichen Integration beginnen können, habe ich einen breiteren Leitfaden dazu geschrieben. Hier ist es: Wie Sie mit Continuous Integration beginnen

Ursprünglich im Fire CI Blog veröffentlicht.