Integrazione e test end-to-end semplificati con Node.js e MongoDB

Pubblicato: 2022-03-11

I test sono una parte essenziale della creazione di una solida applicazione Node.js. Test adeguati possono facilmente superare molte carenze che gli sviluppatori potrebbero evidenziare sulle soluzioni di sviluppo di Node.js.

Sebbene molti sviluppatori si concentrino sulla copertura del 100% con gli unit test, è importante che il codice che scrivi non venga testato solo in isolamento. L'integrazione e i test end-to-end ti danno quella sicurezza in più testando insieme parti della tua applicazione. Queste parti potrebbero funzionare bene da sole, ma in un sistema di grandi dimensioni, le unità di codice raramente funzionano separatamente.

Node.js e MongoDB insieme formano uno dei duetti più popolari degli ultimi tempi. Se ti capita di essere una delle tante persone che li usano, sei fortunato.

In questo articolo imparerai come scrivere facilmente test di integrazione e end-to-end per la tua applicazione Node.js e MongoDB che viene eseguita su istanze reali del database, il tutto senza la necessità di impostare un ambiente elaborato o un complicato codice di installazione/smontaggio .

Vedrai come il pacchetto mongo-unit aiuta con l'integrazione e il test end-to-end in Node.js. Per una panoramica più completa dei test di integrazione di Node.js, vedere questo articolo.

Gestire un database reale

In genere, per l'integrazione o i test end-to-end, i tuoi script dovranno connettersi a un vero database dedicato a scopo di test. Ciò comporta la scrittura di codice che viene eseguito all'inizio e alla fine di ogni test case/suite per garantire che il database sia in uno stato pulito e prevedibile.

Questo può funzionare bene per alcuni progetti, ma presenta alcune limitazioni:

  • L'ambiente di test può essere piuttosto complesso. Dovrai mantenere il database in esecuzione da qualche parte. Ciò spesso richiede uno sforzo aggiuntivo per la configurazione con i server CI.
  • Il database e le operazioni possono essere relativamente lenti. Poiché il database utilizzerà le connessioni di rete e le operazioni richiederanno l'attività del file system, potrebbe non essere facile eseguire rapidamente migliaia di test.
  • Il database mantiene lo stato e non è molto conveniente per i test. I test dovrebbero essere indipendenti l'uno dall'altro, ma l'utilizzo di un DB comune potrebbe far sì che un test influisca sugli altri.

D'altra parte, l'utilizzo di un database reale rende l'ambiente di test il più vicino possibile alla produzione. Questo può essere considerato un vantaggio particolare di questo approccio.

Utilizzo di un database in memoria reale

L'utilizzo di un database reale per i test sembra presentare alcune sfide. Ma il vantaggio dell'utilizzo di un database reale è troppo buono per essere ignorato. Come possiamo aggirare le sfide e mantenere il vantaggio?

Riutilizzare una buona soluzione da un'altra piattaforma e applicarla al mondo Node.js può essere la strada da percorrere qui.

I progetti Java utilizzano ampiamente DBUnit con un database in memoria (ad esempio, H2) per questo scopo.

DBUnit è integrato con JUnit (il Java test runner) e ti consente di definire lo stato del database per ogni test/suite di test, ecc. Rimuove i vincoli discussi sopra:

  • DBUnit e H2 sono librerie Java, quindi non è necessario configurare un ambiente aggiuntivo. Funziona tutto nella JVM.
  • Il database in memoria rende questa gestione dello stato molto veloce.
  • DBUnit rende la configurazione del database molto semplice e consente di mantenere uno stato del database chiaro per ogni caso.
  • H2 è un database SQL ed è parzialmente compatibile con MySQL, quindi, nei casi principali, l'applicazione può funzionare con esso come con un database di produzione.

Partendo da questi concetti, ho deciso di creare qualcosa di simile per Node.js e MongoDB: Mongo-unit.

Mongo-unit è un pacchetto Node.js che può essere installato utilizzando NPM o Yarn. Funziona in memoria MongoDB. Semplifica i test di integrazione integrandosi bene con Mocha e fornendo una semplice API per gestire lo stato del database.

La libreria utilizza il pacchetto NPM precompilato da mongodb, che contiene i binari MongoDB precompilati per i più diffusi sistemi operativi. Queste istanze MongoDB possono essere eseguite in modalità in memoria.

Installazione dell'unità Mongo

Per aggiungere mongo-unit al tuo progetto, puoi eseguire:

 npm install -D mongo-unit

o

 yarn add mongo-unit

E questo è tutto. Non hai nemmeno bisogno di MongoDB installato sul tuo computer per usare questo pacchetto.

Utilizzo dell'unità Mongo per i test di integrazione

Immaginiamo di avere una semplice applicazione Node.js per gestire le attività:

 // service.js const mongoose = require('mongoose') const mongoUrl = process.env.MONGO_URL || 'mongodb://localhost:27017/example' mongoose.connect(mongoUrl) const TaskSchema = new mongoose.Schema({ name: String, started: Date, completed: Boolean, }) const Task = mongoose.model('tasks', TaskSchema) module.exports = { getTasks: () => Task.find(), addTask: data => new Task(data).save(), deleteTask: taskId => Task.findByIdAndRemove(taskId) }

L'URL di connessione MongoDB non è codificato qui. Come con la maggior parte dei back-end di applicazioni Web, lo prendiamo dalla variabile di ambiente. Questo ci consentirà di sostituirlo con qualsiasi URL durante i test.

 const express = require('express') const bodyParser = require('body-parser') const service = require('./service') const app = express() app.use(bodyParser.json()) app.use(express.static(`${__dirname}/static`)) app.get('/example', (req, res) => { service.getTasks().then(tasks => res.json(tasks)) }) app.post('/example', (req, res) => { service.addTask(req.body).then(data => res.json(data)) }) app.delete('/example/:taskId', (req, res) => { service.deleteTask(req.params.taskId).then(data => res.json(data)) }) app.listen(3000, () => console.log('started on port 3000'))

Questo è un frammento di un'applicazione di esempio che dispone di un'interfaccia utente. Il codice per l'interfaccia utente è stato omesso per brevità. Puoi controllare l'esempio completo su GitHub.

Integrazione con Moka

Per fare in modo che Mocha esegua test di integrazione su mongo-unit, è necessario eseguire l'istanza del database mongo-unit prima che il codice dell'applicazione venga caricato nel contesto Node.js. Per fare ciò, possiamo utilizzare il parametro mocha --require e la libreria Mocha-prepare, che consente di eseguire operazioni asincrone negli script require.

 // it-helper.js const prepare = require('mocha-prepare') const mongoUnit = require('mongo-unit') prepare(done => mongoUnit.start() .then(testMongoUrl => { process.env.MONGO_URL = testMongoUrl done() }))

Scrivere test di integrazione

Il primo passaggio consiste nell'aggiungere un test al database di test ( testData.json ):

 { "tasks": [ { "name": "test", "started": "2017-08-28T16:07:38.268Z", "completed": false } ] }

Il prossimo passo è aggiungere i test stessi:

 const expect = require('chai').expect const mongoose = require('mongoose') const mongoUnit = require('../index') const service = require('./app/service') const testMongoUrl = process.env.MONGO_URL describe('service', () => { const testData = require('./fixtures/testData.json') beforeEach(() => mongoUnit.initDb(testMongoUrl, testData)) afterEach(() => mongoUnit.drop()) it('should find all tasks', () => { return service.getTasks() .then(tasks => { expect(tasks.length).to.equal(1) expect(tasks[0].name).to.equal('test') }) }) it('should create new task', () => { return service.addTask({ name: 'next', completed: false }) .then(task => { expect(task.name).to.equal('next') expect(task.completed).to.equal(false) }) .then(() => service.getTasks()) .then(tasks => { expect(tasks.length).to.equal(2) expect(tasks[1].name).to.equal('next') }) }) it('should remove task', () => { return service.getTasks() .then(tasks => tasks[0]._id) .then(taskId => service.deleteTask(taskId)) .then(() => service.getTasks()) .then(tasks => { expect(tasks.length).to.equal(0) }) }) })

E voilà!

Nota come ci sono solo un paio di righe di codice che si occupano di configurazione e smontaggio.

Come puoi vedere, è molto facile scrivere test di integrazione usando la libreria mongo-unit. Non prendiamo in giro MongoDB stesso e possiamo usare gli stessi modelli Mongoose. Abbiamo il pieno controllo dei dati del database e non perdiamo molto sulle prestazioni dei test poiché il falso MongoDB è in esecuzione in memoria.

Questo ci consente anche di applicare le migliori pratiche di unit test per i test di integrazione:

  • Rendi ogni test indipendente dagli altri test. Carichiamo nuovi dati prima di ogni test, fornendoci uno stato totalmente indipendente per ogni test.
  • Utilizzare lo stato minimo richiesto per ogni test. Non è necessario popolare l'intero database. Abbiamo solo bisogno di impostare i dati minimi richiesti per ogni particolare test.
  • Possiamo riutilizzare una connessione per il database. Aumenta le prestazioni del test.

Come bonus, possiamo persino eseguire l'applicazione stessa su mongo-unit. Ci consente di eseguire test end-to-end per la nostra applicazione su un database simulato.

Test end-to-end con selenio

Per i test end-to-end, utilizzeremo Selenium WebDriver e Hermione E2E test runner.

Per prima cosa, eseguiremo il bootstrap del pilota e del collaudatore:

 const mongoUnit = require('mongo-unit') const selenium = require('selenium-standalone') const Hermione = require('hermione') const hermione = new Hermione('./e2e/hermione.conf.js') //hermione config seleniumInstall() //make sure selenium is installed .then(seleniumStart) //start selenium web driver .then(mongoUnit.start) // start mongo unit .then(testMongoUrl => { process.env.MONGO_URL = testMongoUrl //store mongo url }) .then(() => { require('./index.js') //start application }) .then(delay(1000)) // wait a second till application is started .then(() => hermione.run('', hermioneOpts)) // run hermiona e2e tests .then(() => process.exit(0)) .catch(() => process.exit(1))

Avremo anche bisogno di alcune funzioni di supporto (gestione degli errori rimossa per brevità):

 function seleniumInstall() { return new Promise(resolve => selenium.install({}, resolve)) } function seleniumStart() { return new Promise(resolve => selenium.start(resolve)) } function delay(timeout) { return new Promise(resolve => setTimeout(resolve, timeout)) }

Dopo aver riempito il database con alcuni dati e averlo pulito una volta terminati i test, possiamo eseguire i nostri primi test:

 const expect = require('chai').expect const co = require('co') const mongoUnit = require('../index') const testMongoUrl = process.env.MONGO_URL const DATA = require('./fixtures/testData.json') const ui = { task: '.task', remove: '.task .remove', name: '#name', date: '#date', addTask: '#addTask' } describe('Tasks', () => { beforeEach(function () { return mongoUnit.initDb(testMongoUrl, DATA) .then(() => this.browser.url('http://localhost:3000')) }) afterEach(() => mongoUnit.dropDb(testMongoUrl)) it('should display list of tasks', function () { const browser = this.browser return co(function* () { const tasks = yield browser.elements(ui.task) expect(tasks.length, 1) }) }) it('should create task', function () { const browser = this.browser return co(function* () { yield browser.element(ui.name).setValue('test') yield browser.element(ui.addTask).click() const tasks = yield browser.elements(ui.task) expect(tasks.length, 2) }) }) it('should remove task', function () { const browser = this.browser return co(function* () { yield browser.element(ui.remove).click() const tasks = yield browser.elements(ui.task) expect(tasks.length, 0) }) }) })

Come puoi vedere, i test end-to-end sembrano molto simili ai test di integrazione.

Incartare

L'integrazione e il test end-to-end sono importanti per qualsiasi applicazione su larga scala. Le applicazioni Node.js, in particolare, possono trarre enormi vantaggi dai test automatizzati. Con mongo-unit, puoi scrivere integrazioni e test end-to-end senza preoccuparti di tutte le sfide che derivano da tali test.

Puoi trovare esempi completi di come utilizzare mongo-unit su GitHub.

Ulteriori letture sul blog di Toptal Engineering:

  • Creazione di un'API REST Node.js/TypeScript, parte 3: MongoDB, autenticazione e test automatici