Testing RESTful API on NodeJS with Mocha and Chai


Translation of Samuele Zaza Guide . The text of the original article can be found here .


I still remember the excitement of finally writing the backend of a large project on node, and I am sure that many people share my feelings.


What's next? We must be sure that our application behaves as we expect it to. One of the most common ways to achieve this is through tests. Testing is an insanely useful thing when we add a new feature to the application: the presence of an already installed and configured test environment that can be launched by one team helps to understand where the new fig will generate new bugs.
We previously discussed the development of a RESTful Node API and the authentication of a Node API . In this tutorial, we will write a simple RESTful API and use Mocha and Chai to test it. We will test CRUD for the book storage application.


As always, you can do everything step by step by reading the manual, or download the source code on github .


Mocha: Test environment


Mocha is a javascript framework for Node.js that allows for asynchronous testing. Let's just say this: it creates an environment in which we can use our favorite assert libraries.



Mocha comes with tons of features. The site has a huge list of them. Most of all I like the following:


  • simple async support, including Promise
  • support for asynchronous execution timeouts
  • before, after, before each, after eachHooks (very useful for cleaning the environment before the test)
  • using any assertion of the library that you are visiting (in our case, Chai)

Chai: assertion library


So, with Mocha, we got an environment to run our tests, but how will we test HTTP requests, for example? Moreover, how to verify that a GET request returned the expected JSON in response, depending on the parameters passed? We need an assertion library because mocha is clearly not enough.


For this guide, I chose Chai:



Chai gives us a set of interface choices: "should", "expect", "assert". I personally use should, but you can choose any. In addition, Chai has a Chai HTTP plugin that allows you to easily test HTTP requests.


PREREQUISITES


  • Node.js : a basic understanding of node.js and a basic understanding of the RESTful API is recommended (I won’t go deep into implementation details).
  • POSTMAN to execute API requests.
  • ES6 syntax : I decided to use the latest version of Node (6 .. ), Which integrates ES6 features well for better readability. If you are still not very friendly with ES6, you can read some great articles ( Pt.1 , Pt.2 and Pt.3 ). But don’t worry, I will explain when any special syntax is encountered.

It is time to set up our book depository.


Project setup


Folder structure


The project structure will be as follows:


-- controllers 
---- models
------ book.js
---- routes
------ book.js
-- config
---- default.json
---- dev.json
---- test.json
-- test
---- book.js
package.json
server.json

Please note that the folder /configcontains 3 JSON files: as the name implies, they contain settings for various environments.


In this guide, we will switch between two databases — one for development and one for testing. Thus, the files will contain the mongodb URI in JSON format:


dev.jsonand default.json:


{
    "DBHost": "YOUR_DB_URI"
}

test.json:


{
    "DBHost": "YOUR_TEST_DB_URI"
}

More about configuration files (config folder, file order, file format) can be read [here] ( https://github.com/lorenwest/node-config/wiki/Configuration-Files ).


Pay attention to the file /test/book.jsin which all our tests will be.


package.json


Create a file package.jsonand paste the following:


{
  "name": "bookstore",
  "version": "1.0.0",
  "description": "A bookstore API",
  "main": "server.js",
  "author": "Sam",
  "license": "ISC",
  "dependencies": {
    "body-parser": "^1.15.1",
    "config": "^1.20.1",
    "express": "^4.13.4",
    "mongoose": "^4.4.15",
    "morgan": "^1.7.0"
  },
  "devDependencies": {
    "chai": "^3.5.0",
    "chai-http": "^2.0.1",
    "mocha": "^2.4.5"
  },
  "scripts": {
    "start": "SET NODE_ENV=dev && node server.js",
    "test": "mocha --timeout 10000"
  }
}

Again, nothing new for someone who has written at least one server on node.js. Packages mocha, chai, chai-httpneeded for testing, are installed in the unit dev-dependencies(a flag --save-devfrom the command line).
The block scriptscontains two ways to start the server.


For mocha, I added a flag --timeout 10000, because I can take the data from the database located on mongolab and two seconds left by default may not be enough.


Hooray! We finished the boring part of the manual and it was time to write a server and test it.


Server


Let's create a file server.jsand paste the following code:


let express = require('express');
let app = express();
let mongoose = require('mongoose');
let morgan = require('morgan');
let bodyParser = require('body-parser');
let port = 8080;
let book = require('./app/routes/book');
let config = require('config'); // загружаем адрес базы из конфигов//настройки базыlet options = { 
                server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, 
                replset: { socketOptions: { keepAlive: 1, connectTimeoutMS : 30000 } } 
              }; 
//соединение с базой  
mongoose.connect(config.DBHost, options);
let db = mongoose.connection;
db.on('error', console.error.bind(console, 'connection error:'));
//не показывать логи в тестовом окруженииif(config.util.getEnv('NODE_ENV') !== 'test') {
    //morgan для вывода логов в консоль
    app.use(morgan('combined')); //'combined' выводит логи в стиле apache
}
//парсинг application/json                                        
app.use(bodyParser.json());                                     
app.use(bodyParser.urlencoded({extended: true}));               
app.use(bodyParser.text());                                    
app.use(bodyParser.json({ type: 'application/json'}));  
app.get("/", (req, res) => res.json({message: "Welcome to our Bookstore!"}));
app.route("/book")
    .get(book.getBooks)
    .post(book.postBook);
app.route("/book/:id")
    .get(book.getBook)
    .delete(book.deleteBook)
    .put(book.updateBook);
app.listen(port);
console.log("Listening on port " + port);
module.exports = app; // для тестирования

Highlights:


  • We need a module configto access the configuration file in accordance with the NODE_ENV environment variable. From it we get the mongo db URI to connect to the database. This will allow us to keep the main base clean, and conduct tests on a separate base, hidden from users.
  • The NODE_ENV environment variable is checked for the value "test" to disable morgan logs on the command line, otherwise they will appear in the output when the tests are run.
  • The last line exports the server for tests.
  • Note the declaration of variables through let. It makes the variable visible only within the trailing block or globally if it is outside the block.

The rest is nothing new: we just connect the necessary modules, determine the settings for interacting with the server, create entry points and launch the server on a specific port.


Models and Routing


It is time to describe a model book. Create a file book.jsin a folder /app/model/with the following contents:


let mongoose = require('mongoose');
let Schema = mongoose.Schema;
//определение схемы книгиlet BookSchema = new Schema(
  {
    title: { type: String, required: true },
    author: { type: String, required: true },
    year: { type: Number, required: true },
    pages: { type: Number, required: true, min: 1 },
    createdAt: { type: Date, default: Date.now },    
  }, 
  { 
    versionKey: false
  }
);
// установить параметр createdAt равным текущему времени
BookSchema.pre('save', next => {
  now = newDate();
  if(!this.createdAt) {
    this.createdAt = now;
  }
  next();
});
//Экспорт модели для последующего использования.module.exports = mongoose.model('book', BookSchema);

Our book has a title, author, number of pages, year of publication and date of creation in the database. I set the options to versionKeyvalue false, as it is not needed in this manual.


An unusual callback in .pre () is an arrow function, a function with shorter syntax. According to the definition MDN repeatedly : "tied to the current value this(it does not own this, arguments, super, or new.target) Functions arrow always anonymous.".


Well, now we know everything we need about the model and move on to the routes.


In the folder, /app/routes/create a file with the book.jsfollowing contents:


let mongoose = require('mongoose');
let Book = require('../models/book');
/*
 * GET /book маршрут для получения списка всех книг.
 */functiongetBooks(req, res) {
    //Сделать запрос в базу и, если не ошибок, отдать весь список книгlet query = Book.find({});
    query.exec((err, books) => {
        if(err) res.send(err);
        //если нет ошибок, отправить клиенту
        res.json(books);
    });
}
/*
 * POST /book для создания новой книги.
 */functionpostBook(req, res) {
    //Создать новую книгуvar newBook = new Book(req.body);
    //Сохранить в базу.
    newBook.save((err,book) => {
        if(err) {
            res.send(err);
        }
        else { //Если нет ошибок, отправить ответ клиенту
            res.json({message: "Book successfully added!", book });
        }
    });
}
/*
 * GET /book/:id маршрут для получения книги по ID.
 */functiongetBook(req, res) {
    Book.findById(req.params.id, (err, book) => {
        if(err) res.send(err);
        //Если нет ошибок, отправить ответ клиенту
        res.json(book);
    });     
}
/*
 * DELETE /book/:id маршрут для удаления книги по ID.
 */functiondeleteBook(req, res) {
    Book.remove({_id : req.params.id}, (err, result) => {
        res.json({ message: "Book successfully deleted!", result });
    });
}
/*
 * PUT /book/:id маршрут для редактирования книги по ID
 */functionupdateBook(req, res) {
    Book.findById({_id: req.params.id}, (err, book) => {
        if(err) res.send(err);
        Object.assign(book, req.body).save((err, book) => {
            if(err) res.send(err);
            res.json({ message: 'Book updated!', book });
        }); 
    });
}
//экспортируем все функцииmodule.exports = { getBooks, postBook, getBook, deleteBook, updateBook };

Highlights:


  • All routes are standard GET, POST, DELETE, PUT for CRUD execution.
  • In updatedBook () function we use Object.assign, the new ES6 function that overwrites the general properties bookand req.bodyand ostavlyaet.ostalnye intact
  • At the end, we export the object using the syntax "short property" (in Russian you can read here , approx. Translator) so as not to make repetitions.

We finished this part and got the finished application!


Naive testing


Let's run our application, open POSTMAN to send HTTP requests to the server and verify that everything works as expected.


At the command prompt


npm start

Get / book


In POSTMAN, we execute a GET request and, assuming there are books in the database, we get the answer
::


The server returned the books from the database without errors.


POST / BOOK


Let's add a new book:



It seems that the book has been added. The server returned the book and a message confirming that it was added. Is it so? Let's execute one more GET request and look at the result:



Works!


PUT / BOOK /: ID


Let's change the number of pages in the book and look at the result:



Fine! PUT also works, so you can run another GET request to verify



Everything is working...


GET / BOOK /: ID


Now we get one book by ID in the GET request and then delete it:



We got the correct answer and now delete this book:


DELETE / BOOK /: ID


Let's look at the result of the removal:



Even the last request works as intended and we don’t even need to make another GET request for verification, since we sent the client a response from mongo (the result property), which indicates that the book was really deleted.


When running a test through POSTMAN, the application behaves as expected, right? So it can be used on the client?


Let me answer you: NO !!


I call our actions naive testing, because we performed only a few operations without taking into account controversial cases: a POST request without the expected data, a DELETE with an invalid id or no id at all.


Obviously this is a simple application and, if we were lucky, we made no mistakes, but what about real applications? Moreover, we spent time launching some test HTTP requests in POSTMAN. And what happens if one day we decide to change the code of one of them? Check everything again in POSTMAN?


These are just a few situations that you may encounter or have already encountered as a developer. Fortunately, we have tools to create tests that are always available; they can be started by one command from the console.


Let's do something better to test our application.


Good testing


First, let's create a file books.jsin the folder /test:


//During the test the env variable is set to test
process.env.NODE_ENV = 'test';
let mongoose = require("mongoose");
let Book = require('../app/models/book');
//Подключаем dev-dependencieslet chai = require('chai');
let chaiHttp = require('chai-http');
let server = require('../server');
let should = chai.should();
chai.use(chaiHttp);
//Наш основной блок
describe('Books', () => {
    beforeEach((done) => { //Перед каждым тестом чистим базу
        Book.remove({}, (err) => { 
           done();         
        });     
    });
/*
  * Тест для /GET 
  */
  describe('/GET book', () => {
      it('it should GET all the books', (done) => {
        chai.request(server)
            .get('/book')
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('array');
                res.body.length.should.be.eql(0);
              done();
            });
      });
  });
});

So many new pieces! Let's figure it out:


  • Be sure to pay attention to the variable NODE_ENV which we have assigned the value test. This will allow the server to load the config for the test database and not display logs in the console morgan.
  • We connected dev-dependencies and the server itself (we exported it through module.exports).
  • We connected chaiHttpto chai.

It all starts with a block describethat is used to improve the structuring of our claims. This will affect the output, as we will see later.


beforeEachIs a block that will be executed for each block described in this describeblock. Why are we doing this? We remove all books from the database so that the database is empty at the beginning of each test.


Testing / GET


So, we have the first test. Chai makes a GET request and checks that the variable ressatisfies the first parameter (statement) of the it"it should GET all the books" block . Namely, for a given empty book depository, the answer should be as follows:


  • Status 200.
  • The result should be an array.
  • Since the base is empty, we expect the size of the array to be 0.

Note that the syntax should be intuitive and very similar to the spoken language.


On the command line, we’ll get Terrier:


npm test

and get:


The test passed and the conclusion reflects the structure that we described with the help of blocks describe.


Testing / POST


Now let's check how good our API is. Suppose we are trying to add a book without the `pages: field: the server should not return the corresponding error.


Add this code to the end of the block describe('Books'):


describe('/POST book', () => {
      it('it should not POST a book without pages field', (done) => {
        let book = {
            title: "The Lord of the Rings",
            author: "J.R.R. Tolkien",
            year: 1954
        }
        chai.request(server)
            .post('/book')
            .send(book)
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('object');
                res.body.should.have.property('errors');
                res.body.errors.should.have.property('pages');
                res.body.errors.pages.should.have.property('kind').eql('required');
              done();
            });
      });
  });

Here we added a test for an incomplete / POST request. Let's look at the checks:


  • Status should be 200.
  • The response body must be an object.
  • One of the properties of the response body should be errors.
  • The field errorsmust have a property missing in the request pages.
  • pagesmust have an kindequal property requiredto show the reason why we received a negative response from the server.

Note that we sent the book data using the .send () method.


Let's execute the command again and look at the output:



The test works !!


Before writing the next test, we’ll clarify a couple of things:


  • First, why does the response from the server have such a structure? If you read callbackfor the / POST route, then you saw that in case of an error the server sends an error from mongoose. Try to do this through POSTMAN and look at the answer.
  • In case of an error, we still respond with code 200. This is done for simplicity, since we are only learning to test our API.

However, I would suggest giving back the status of 206 Partial Content instead


Let's send the correct request. Paste the following code at the end of the block describe(''/POST book''):


it('it should POST a book ', (done) => {
        let book = {
            title: "The Lord of the Rings",
            author: "J.R.R. Tolkien",
            year: 1954,
            pages: 1170
        }
        chai.request(server)
            .post('/book')
            .send(book)
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('object');
                res.body.should.have.property('message').eql('Book successfully added!');
                res.body.book.should.have.property('title');
                res.body.book.should.have.property('author');
                res.body.book.should.have.property('pages');
                res.body.book.should.have.property('year');
              done();
            });
      });

This time we expect an object that tells us that the book has been added successfully and the book itself. You should already be familiar with the checks, so there is no need to go into details.


Run the command again and get:



Testing / GET /: ID


Now create a book, save it to the database and use id to execute the GET request. Add the following block:


describe('/GET/:id book', () => {
      it('it should GET a book by the given id', (done) => {
        let book = new Book({ title: "The Lord of the Rings", author: "J.R.R. Tolkien", year: 1954, pages: 1170 });
        book.save((err, book) => {
            chai.request(server)
            .get('/book/' + book.id)
            .send(book)
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('object');
                res.body.should.have.property('title');
                res.body.should.have.property('author');
                res.body.should.have.property('pages');
                res.body.should.have.property('year');
                res.body.should.have.property('_id').eql(book.id);
              done();
            });
        });
      });
  });

Through asserts we made sure that the server returned all the fields and the desired book (the id in the response from the north matches the requested one):



Did you notice that in testing individual routes inside independent blocks we got a very clean conclusion? To the same volume, it’s effective: we wrote several tests that can be repeated with a single command


Testing / PUT /: ID


It is time to check out the editing of one of our books. First, we will save the book to the database, and then we will issue a request to change the year of its publication.


describe('/PUT/:id book', () => {
      it('it should UPDATE a book given the id', (done) => {
        let book = new Book({title: "The Chronicles of Narnia", author: "C.S. Lewis", year: 1948, pages: 778})
        book.save((err, book) => {
                chai.request(server)
                .put('/book/' + book.id)
                .send({title: "The Chronicles of Narnia", author: "C.S. Lewis", year: 1950, pages: 778})
                .end((err, res) => {
                    res.should.have.status(200);
                    res.body.should.be.a('object');
                    res.body.should.have.property('message').eql('Book updated!');
                    res.body.book.should.have.property('year').eql(1950);
                  done();
                });
          });
      });
  });

We want to make sure that the field messageis equal Book updated!and the field has yearreally changed.



We are almost done.


Testing / DELETE /: ID.


The template is very similar to the previous test: first we create the book, then we delete it with the help of the request and check the answer:


describe('/DELETE/:id book', () => {
      it('it should DELETE a book given the id', (done) => {
        let book = new Book({title: "The Chronicles of Narnia", author: "C.S. Lewis", year: 1948, pages: 778})
        book.save((err, book) => {
                chai.request(server)
                .delete('/book/' + book.id)
                .end((err, res) => {
                    res.should.have.status(200);
                    res.body.should.be.a('object');
                    res.body.should.have.property('message').eql('Book successfully deleted!');
                    res.body.result.should.have.property('ok').eql(1);
                    res.body.result.should.have.property('n').eql(1);
                  done();
                });
          });
      });
  });

Again, the server will return a response from us mongoose, which we check. The console will have the following:



Amazing! Our tests pass and we have an excellent base for testing our API with more sophisticated tests.


Conclusion


In this tutorial, we encountered the problem of testing our routes to provide our users with a stable API.


We went through all the stages of creating a RESTful API, doing naive tests with POSTMAN, and then offered the best way to test, was our main goal.


Writing tests is a good habit to ensure server stability. Unfortunately, this is often underestimated.


Bonus: Mockgoose


There will always be someone who says that two bases is not the best solution, but the other is not given. And what to do? There is an alternative: Mockgoose.


In essence, Mockgoose creates a wrapper for Mongoose that intercepts database calls and uses in memory storage instead. In addition, it integrates seamlessly with mocha


Note: Mockgoose requires that mongodb be installed on the machine where the tests are running.


Also popular now: