Ένας πλήρης οδηγός για δοκιμές API από άκρο σε άκρο με το Docker

Η εξέταση είναι γενικά ένας πόνος. Μερικοί δεν βλέπουν το νόημα. Μερικοί το βλέπουν, αλλά το θεωρούν ως ένα επιπλέον βήμα που τους επιβραδύνει. Μερικές φορές υπάρχουν δοκιμές αλλά είναι πολύ μεγάλες για να τρέξουν ή να είναι ασταθείς. Σε αυτό το άρθρο θα δείτε πώς μπορείτε να δημιουργήσετε δοκιμές για τον εαυτό σας με το Docker.

Θέλουμε γρήγορες, ουσιαστικές και αξιόπιστες δοκιμές γραμμένες και συντηρημένες με ελάχιστη προσπάθεια. Σημαίνει δοκιμές που είναι χρήσιμες για εσάς ως προγραμματιστές σε καθημερινή βάση. Πρέπει να ενισχύσουν την παραγωγικότητά σας και να βελτιώσουν την ποιότητα του λογισμικού σας. Η διεξαγωγή δοκιμών επειδή όλοι λένε ότι "πρέπει να κάνετε εξετάσεις" δεν είναι καλό εάν σας επιβραδύνει.

Ας δούμε πώς να το επιτύχουμε με όχι τόσο μεγάλη προσπάθεια.

Το παράδειγμα που πρόκειται να δοκιμάσουμε

Σε αυτό το άρθρο πρόκειται να δοκιμάσουμε ένα API που έχει δημιουργηθεί με Node / express και θα χρησιμοποιήσουμε το chai / mocha για δοκιμή. Έχω επιλέξει μια στοίβα JS επειδή ο κώδικας είναι εξαιρετικά σύντομος και ευανάγνωστος. Οι αρχές που εφαρμόζονται ισχύουν για κάθε τεχνολογική στοίβα. Συνεχίστε να διαβάζετε ακόμα κι αν το Javascript σας κάνει να αρρωσταίνετε.

Το παράδειγμα θα καλύψει ένα απλό σύνολο τελικών σημείων CRUD για τους χρήστες. Είναι περισσότερο από αρκετό για να κατανοήσουμε την ιδέα και να εφαρμόσουμε την πιο περίπλοκη επιχειρηματική λογική του API σας.

Θα χρησιμοποιήσουμε ένα αρκετά τυπικό περιβάλλον για το API:

  • Μια βάση δεδομένων Postgres
  • Ένα σύμπλεγμα Redis
  • Το API μας θα χρησιμοποιήσει άλλα εξωτερικά API για να κάνει τη δουλειά του

Το API σας μπορεί να χρειαστεί διαφορετικό περιβάλλον. Οι αρχές που εφαρμόζονται σε αυτό το άρθρο θα παραμείνουν οι ίδιες. Θα χρησιμοποιήσετε διαφορετικές εικόνες βάσης Docker για να εκτελέσετε οποιοδήποτε στοιχείο που χρειάζεστε.

Γιατί Docker; Και στην πραγματικότητα Docker Compose

Αυτή η ενότητα περιέχει πολλά επιχειρήματα υπέρ της χρήσης του Docker για δοκιμές. Μπορείτε να το παραλείψετε εάν θέλετε να φτάσετε αμέσως στο τεχνικό τμήμα.

Οι οδυνηρές εναλλακτικές λύσεις

Για να δοκιμάσετε το API σας σε ένα κοντινό περιβάλλον παραγωγής, έχετε δύο επιλογές. Μπορείτε να χλευτείτε το περιβάλλον σε επίπεδο κώδικα ή να εκτελέσετε τις δοκιμές σε πραγματικό διακομιστή με εγκατεστημένη τη βάση δεδομένων κ.λπ.

Το χλευασμό όλων σε επίπεδο κώδικα παρακωλύει τον κώδικα και τη διαμόρφωση του API μας. Επίσης, συχνά δεν είναι πολύ αντιπροσωπευτικό του τρόπου συμπεριφοράς του API στην παραγωγή. Η εκτέλεση του πράγματος σε έναν πραγματικό διακομιστή είναι βαριά υποδομή. Είναι πολλή εγκατάσταση και συντήρηση και δεν κλιμακώνεται. Έχοντας μια κοινόχρηστη βάση δεδομένων, μπορείτε να εκτελέσετε μόνο 1 δοκιμή κάθε φορά για να διασφαλίσετε ότι οι δοκιμές δεν παρεμβαίνουν μεταξύ τους.

Το Docker Compose μας επιτρέπει να έχουμε το καλύτερο και των δύο κόσμων. Δημιουργεί "κοντέινερ" εκδόσεις όλων των εξωτερικών μερών που χρησιμοποιούμε. Είναι γελοίο αλλά στο εξωτερικό του κώδικα μας. Το API μας πιστεύει ότι βρίσκεται σε πραγματικό φυσικό περιβάλλον. Το Docker compose θα δημιουργήσει επίσης ένα απομονωμένο δίκτυο για όλα τα κοντέινερ για μια δεδομένη δοκιμαστική εκτέλεση. Αυτό σας επιτρέπει να εκτελείτε πολλά από αυτά παράλληλα στον τοπικό υπολογιστή σας ή σε κεντρικό υπολογιστή CI.

Υπερβολή;

Ίσως αναρωτιέστε αν δεν είναι υπερβολικό να εκτελείτε δοκιμές από άκρο σε άκρο με το Docker compose. Τι γίνεται με την εκτέλεση δοκιμών μονάδας;

Τα τελευταία 10 χρόνια, οι μεγάλες εφαρμογές μονόλιθου χωρίστηκαν σε μικρότερες υπηρεσίες (τείνουν προς τις πολυσύχναστες «μικροϋπηρεσίες»). Ένα δεδομένο στοιχείο API βασίζεται σε περισσότερα εξωτερικά μέρη (υποδομή ή άλλα API). Καθώς οι υπηρεσίες γίνονται μικρότερες, η ενσωμάτωση με την υποδομή γίνεται μεγαλύτερο μέρος της εργασίας.

Θα πρέπει να διατηρήσετε ένα μικρό κενό μεταξύ της παραγωγής σας και των αναπτυξιακών σας περιβαλλόντων. Διαφορετικά θα προκύψουν προβλήματα κατά τη χρήση της παραγωγής. Εξ ορισμού, αυτά τα προβλήματα εμφανίζονται στη χειρότερη δυνατή στιγμή. Θα οδηγήσουν σε βιαστικές διορθώσεις, πτώσεις στην ποιότητα και απογοήτευση για την ομάδα. Κανείς δεν το θέλει αυτό.

Ίσως αναρωτιέστε εάν οι δοκιμές από άκρο σε άκρο με σύνθετα Docker διαρκούν περισσότερο από τις παραδοσιακές δοκιμές μονάδας. Όχι πραγματικά. Θα δείτε στο παρακάτω παράδειγμα ότι μπορούμε εύκολα να κρατήσουμε τις δοκιμές κάτω από 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εντολή λέει στο Docker να δημιουργήσει πραγματικά την εικόνα του κοντέινερ από την πηγή μας. Τα υπόλοιπα είναι σαν το κοντέινερ db: μεταβλητές περιβάλλοντος και θύρες
  • Το myapp-tes είναι το κοντέινερ που θα εκτελέσει τις δοκιμές μας. Θα χρησιμοποιήσει την ίδια εικόνα με το 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 container . Χρησιμοποιούμε ένα Dockerfile όπως παρακάτω. Το περιεχόμενο είναι συγκεκριμένο για την τεχνολογική σας στοίβα, αλλά η ιδέα είναι να ομαδοποιήσετε το 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-tes μετά την εκτέλεση των δοκιμών. Κεράσι στην τούρτα, η docker-composeεντολή θα βγεί με τον ίδιο κωδικό εξόδου με το κοντέινερ που ενεργοποίησε την έξοδο. Αυτό σημαίνει ότι μπορούμε να ελέγξουμε εάν οι δοκιμές πέτυχαν ή όχι από τη γραμμή εντολών. Αυτό είναι πολύ χρήσιμο για αυτοματοποιημένες κατασκευές σε περιβάλλον CI.

Δεν είναι αυτή η τέλεια ρύθμιση δοκιμής;

Το πλήρες παράδειγμα είναι εδώ στο GitHub. Μπορείτε να κλωνοποιήσετε το αποθετήριο και να εκτελέσετε την εντολή σύνθεσης του docker:

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

Φυσικά πρέπει να έχετε εγκαταστήσει το Docker. Το 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 δευτερόλεπτα. Σε σύγκριση με τα 880ms που χρησιμοποιούνται για την πραγματική εκτέλεση των δοκιμών, είναι πολλά. Στην πράξη, οι δοκιμές που διαρκούν λιγότερο από 1 λεπτό είναι χρυσές, καθώς είναι σχεδόν άμεση ανατροφοδότηση. Τα γενικά 15 δευτερόλεπτα είναι χρόνος αγοράς που θα είναι σταθερός καθώς προσθέτετε περισσότερες δοκιμές. Θα μπορούσατε να προσθέσετε εκατοντάδες δοκιμές και να διατηρήσετε τον χρόνο εκτέλεσης κάτω από 1 λεπτό.

Βόλα! Έχουμε λειτουργήσει το πλαίσιο δοκιμών μας. Σε ένα έργο πραγματικού κόσμου τα επόμενα βήματα θα ήταν να βελτιωθεί η λειτουργική κάλυψη του API σας με περισσότερες δοκιμές. Ας εξετάσουμε τις λειτουργίες CRUD που καλύπτονται. Ήρθε η ώρα να προσθέσετε περισσότερα στοιχεία στο περιβάλλον δοκιμών μας.

Προσθήκη συμπλέγματος Redis

Ας προσθέσουμε ένα άλλο στοιχείο στο περιβάλλον API για να κατανοήσουμε τι χρειάζεται. Ειδοποίηση Spoiler: δεν είναι πολύ.

Ας φανταστούμε ότι το API μας διατηρεί τις περιόδους σύνδεσης χρήστη σε ένα σύμπλεγμα Redis. Αν αναρωτιέστε γιατί θα το κάναμε αυτό, φανταστείτε 100 παρουσίες του API σας σε παραγωγή. Οι χρήστες χτυπούν έναν ή άλλο διακομιστή με βάση την εξισορρόπηση φορτίου round robin. Κάθε αίτημα πρέπει να επικυρωθεί.

Αυτό απαιτεί δεδομένα προφίλ χρήστη για τον έλεγχο προνομίων και άλλης επιχειρηματικής λογικής για συγκεκριμένες εφαρμογές. Ένας τρόπος να πάτε είναι να κάνετε ένα ταξίδι μετ 'επιστροφής στη βάση δεδομένων για τη λήψη των δεδομένων κάθε φορά που τα χρειάζεστε, αλλά αυτό δεν είναι πολύ αποτελεσματικό. Η χρήση ενός συμπλέγματος βάσης δεδομένων μνήμης καθιστά τα δεδομένα διαθέσιμα σε όλους τους διακομιστές για το κόστος μιας τοπικής μεταβλητής ανάγνωσης.

Έτσι βελτιώνετε το περιβάλλον δοκιμών σύνθεσης 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: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-testing αποκτά επίσης τη διαμόρφωση του κεντρικού υπολογιστή και της θύρας 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 τρίτων στην εφαρμογή σας εξαρτάται φυσικά από εσάς. Αλλά έχετε το νόημα.

Για να εκτελέσουμε αυτές τις δοκιμές, πρέπει να πούμε στο container myapp ποια είναι η βασική διεύθυνση URL της υπηρεσίας τρίτων:

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

Συμπέρασμα και μερικές άλλες σκέψεις

Ας ελπίσουμε ότι αυτό το άρθρο σας έδωσε μια γεύση από το τι μπορεί να κάνει η σύνταξη του Docker για σας όταν πρόκειται για δοκιμές API. Το πλήρες παράδειγμα είναι εδώ στο GitHub.

Η χρήση σύνθεσης Docker κάνει τις δοκιμές να γίνονται γρήγορα σε περιβάλλον κοντά στην παραγωγή. Δεν απαιτεί προσαρμογές στον κωδικό του στοιχείου σας. Η μόνη απαίτηση είναι η υποστήριξη διαμόρφωσης βάσει μεταβλητών περιβάλλοντος.

Η λογική των συστατικών σε αυτό το παράδειγμα είναι πολύ απλή, αλλά οι αρχές ισχύουν για οποιοδήποτε 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 Blog.