Many articles describe the interaction between Node.js and Elasticsearch, but they often do not clearly explain how this interaction was achieved. To fill this gap, this article describes test-driven, step-by-step development of a simple RESTful API into an Elasticsearch in Node.js.
My main intention is to show Node.js developers how a RESTful API might be written using a TDD approach. Applying TDD practices makes the process much faster and results in a less error-prone API. Moreover, the whole application architecture becomes testable and therefore simpler and cleaner.
This article is divided into three main sections, with subsections and numbered steps to facilitate ease of use:
The Proposed Architecture section covers setup, installing the test framework, creating the first test, and defining the server.
The Develop and Test Server section describes preparing the server for content testing, as well as testing and adding support to the server.
The Develop and Test Controller section provides detail on developing the initial controller so that it supports standard REST actions (index, show, update, and destroy), and on refactoring the controller after this development.
Proposed Architecture
Let’s start with the general API architecture. Some elements of MVC patterns can be successfully reused here. Strictly speaking, I am going to focus primarily on one element of that pattern: the Controller. The architecture I am going to develop is represented by the gray rectangle in the schema below:
Set Up Architecture
1. Create an application directory:
mkdir npm_api cd npm_api
2. Initialize the git repository:
git init
3. Initialize the npm application:
npm init
4. Accept all proposed default values.
Install Test Framework, Create First Test, Define Server
Mocha’s framework will work well for this project.
1. Install Mocha:
npm install --save-dev mocha
2. Create an empty directory for tests:
mkdir test
3. Configure Mocha as your default test framework in the package.json:
"scripts": { "test": "node_modules/.bin/mocha" }
The above steps enable you to run tests with the default npm command:
npm test
At this point, you should see something like No test files found
in the output. This is expected. We have not created any tests so far. That is the next step.
4. Install the supertest library for request testing:
npm install --save-dev supertest
5. Create the first test:
test/server.js:
describe('server', () => { const request = supertest(server); //checks that server returns success response when 'GET /posts' is performed //response content is not verified here yet. It will be done in the next //iteration describe('GET /posts', () => it('responds with OK', () => request .get('/posts') .expect(200) ) ); });
6. Run the first test and see this expected error message:
Error: Cannot find module '../app/server'
7. Configure server instance:
I will use the Restify package for building the web API:
npm install --save-dev restify
Here is the basic trivial server just to make our only test green:
const restify = require('restify'); const server = restify.createServer(); //Server always responds with the empty object for now. Content //is not tested yet. Just server availability is tested. server.get('/posts', (req, res, next) => res.send({}) ); module.exports = server;
You can see that this is a very trivial server definition. It has only one action defined, GET /posts
, which always returns an empty object. If all steps have been done properly, then npm test
will output:
server GET /posts ✓ responds with OK 1 passing (32ms)
Now we have a server that responds to GET /posts
properly. It is the perfect time to make our first manual integration test to check that all the pieces we have created fit together properly.
8. Create script that runs the server:
./start.js:
const server = require('./app/server'); server.listen(8080, () => console.log('%s listening at %s', server.name, server.url) );
9. Update package.json to define it as an application start script:
package.json:
"script": { "test": "node_modules/.bin/mocha", "start": "node start.js" }
10. Run the server itself:
npm start
The server should be running and ready to accept requests on the port 8080:
> node_api@1.0.0 start /projects/node_api > node start.js restify listening at https://[::]:8080
It will always respond with an empty object as expected:
curl -XGET https://localhost:8080/posts {}%
Now we have an API server that is ready to respond to our requests.
Develop and Test Server
According to our proposed architecture, the server should redirect all requests to the controller, PostsController
in our case. It will be responsible for handling requests, gathering all necessary data, and rendering results.
Prepare Server for Content Testing
1. Extract the controller from the current server implementation:
app/controllers/posts.js:
module.exports = class { index() { //even at this early phase we can assume that controller will return //some kind of promise because it will make a request to ES that //are asynchronous return new Promise((resolve, reject) => resolve({}) ); } };
This is the simplest possible controller version.
2. Modify our server to use the controller:
const restify = require('restify'); const server = restify.createServer(); const PostsController = require('./controllers/posts.js'); const posts = new PostsController(); server.get('/posts', (req, res, next) => posts.index().then((result) => res.send(200, result) ) ); module.exports = server;
Now we have a controller instance that has been created INSIDE the server. But to be able to write isolated unit tests of the server, we need some way to pass a fake controller to the server and then ensure that all methods on that fake controller are within proper parameters.
3. Modify the server to be able to accept controller instance:
app/server.js:
const restify = require('restify'); //PostsController intance must be created and passed from outside module.exports = (posts) => { const server = restify.createServer(); server.get('/posts', (req, res, next) => posts.index().then((result) => //we are not testing content here just server availability res.send(200) ) ); return server; };
As a result of this step, we have defined the server factory rather than the server definition. This server factory created a server instance based on the controller parameters that it accepts.
Test Server
1. Modify the server test and specify fake controller instance:
test/server.js:
const supertest = require('supertest'); const server = require('../app/server'); describe('server', () => { //PostsController stub const posts = {}; const request = supertest(server(posts)); describe('GET /posts', () => { //test function that is called by the server instance before(() => { posts.index = () => new Promise((resolve, reject) => resolve({}) ); }); it('responds with OK', () => request .get('/posts') .expect(200) ); }); });
You can see above that posts is a simple, plain object used as a controller stub object that has only one method, index
, defined on it. That makes it possible to control both the results that are returned to the server from the controller, and the params that are passed from the server to the controller. (More detail on this below.) If the steps have been done properly, all tests will be green now.
2. Run test:
npm test
3. Modify our start script and pass real PostsController instance to the server instance:
./start.js:
const serverFactory = require('./app/server'); const PostsController = require('./app/controllers/posts'); const posts = new PostsController(); const server = serverFactory(posts); server.listen(8080, () => console.log('%s listening at %s', server.name, server.url) );
4. Make a simple integration test to check that nothing is broken:
Run server:
npm start
Send a test request to the server:
curl -XGET https://localhost:8080/posts {}%
An empty object is returned, which is exactly what was expected.
Now we have everything prepared for content testing, specifically making sure that the server properly serializes data that is returned from the controller and responds with that data.
5. Update test so it becomes red:
test/server.js:
const _ = require('lodash'); const supertest = require('supertest'); const server = require('../app/server'); describe('server', () => { const posts = {}; const request = supertest(server(posts)); describe('GET /posts', () => { //test data that is returned by the posts controller stub const data = [{id: 1, author: 'Mr. Smith', content: 'Now GET /posts works'}]; //test method now returns test data before(() => { posts.index = () => new Promise((resolve, reject) => resolve(data) ); }); //checks that server responds with the proper HTTP code and exactly with the //same data it received from the controller it('responds with OK', () => request .get('/posts') .expect(data) .expect(200) ); }); });
6. Run npm test and see an error:
Error: expected [ { id: 1, author: 'Mr. Smith', content: 'Now GET /posts works' } ] response body, got {}
It looks like the server does not return post data. The server needs to be modified.
7. Update server:
const restify = require('restify'); module.exports = (posts) => { const server = restify.createServer(); server.get('/posts', (req, res, next) => posts.index().then((result) => //now we returns not only code but content also res.send(200, result) ) ); return server; };
If you run npm test
now, you should see that all tests are green. Nice!
Add Support to Create New Post Instance
Now it’s time to add support for one more action to our server, POST /posts
, that will create a new post instance.
1. Create test:
test/server.js:
describe('POST /posts', () => { //data that is sent to the server const data = [{ author: 'Mr. Rogers', content: 'Now POST /posts works' }]; before(() => { //so we expect server to return attributes fo the new post posts.create = (attrs) => new Promise((resolve, reject) => resolve(_.merge({ id: 2 }, attrs)) ); }); it('responds with Created and returns content of the newly create post', () => request .post('/posts') .send({ post: data }) .expect(_.merge({ id: 2 }, data)) .expect(201) ); });
2. Run npm test and see the following error:
Error: expected { '0': { author: 'Mr. Rogers', content: 'Now POST /posts works' }, id: 2 } response body, got { code: 'MethodNotAllowedError', message: 'POST is not allowed' }
This result is actually expected. POST /posts
must be defined on the server to fix the test.
3. Define POST /posts on the server:
app/server.js:
//So here we just pass post attributes to the controller and returns back //its result server.post('/posts', (req, res, next) => posts.create(req.params.post).then((result) => res.send(201, result) ) );
If we run npm test at this point, we will still see an error:
Error: expected { '0': { author: 'Mr. Rogers', content: 'Now POST /posts works' }, id: 2 } response body, got { id: 2 }
It looks like the params we sent to the server were not parsed properly.
4. Plug body parser into the server:
app/server.js:
const restify = require('restify'); module.exports = (posts) => { const server = restify.createServer(); //we need that parser to work with params that are defined in //the request body server.use(restify.bodyParser()); server.get('/posts', (req, res, next) => posts.index().then((result) => res.send(200, result) ) ); server.post('/posts', (req, res, next) => posts.create(req.params.post).then((result) => res.send(201, result) ) ); return server; };
5. Run npm test again. Everything should be fine.
The next action I am going to add is GET /posts/:id
. This action is different from the previous two. It’s tricky in that the API consumer might specify nonexistent post identities that the server must handle gracefully.
For now, let’s implement a simple action version that does not handle situations when posts do not exist.
6. Create test as usual:
test/server.js
describe('GET /posts/:id', () => { //data that is returned from the controller stub const data = [{ author: 'Mr. Williams', content: 'Now GET /posts/:id works' }]; //show action stub. it merges specified id with the predefined data //to imitate real controller behaviour at one hand and //check that proper id was passed to the controller at another one before(() => { posts.show = (id) => new Promise((resolve, reject) => resolve(_.merge({ id: id }, data)) ); }); //checks that server just pass id to the controller and //returns its result. it('responds with OK and returns content of the post', () => request .get('/posts/3') .send(data) .expect(_.merge({ id: 3 }, data)) .expect(200) ); });
7. Run npm test and get an error:
Error: expected { '0': { author: 'Mr. Williams', content: 'Now GET /posts/:id works' }, id: 3 } response body, got { code: 'ResourceNotFound', message: '/posts/3 does not exist' }
The resource is not found. We need to define the action on the server.
8. Define action on the server:
app/server.js:
server.get('/posts/:id', (req, res, next) => posts.show(req.params.id).then((result) => res.send(200, result) ) );
9. Run test. Now everything should be green!
But what about cases when there is no post with the specified ID
? The server should obviously return NotFound (404) HTTP status in this case. Let’s continue by addressing this situation.
10. Add test:
test/server.js:
context('when there is no post with the specified id', () => { //here its assumed that controller will return rejected promice //when post with the specified id is not found before(() => { posts.show = (id) => new Promise((resolve, reject) => reject(id) ); }); //test that server responds with 404 code if post was not found it('responds with NotFound', () => request .get('/posts/3') .send(data) .expect(404) ); });
11. Run npm test again and get an error:
Error: timeout of 2000ms exceeded. Ensure the done() callback is being called in this test.
This error occurred because the promise in the controller stub was rejected. We need to modify the server to handle this circumstance.
12. Correct error:
app/server.js:
server.post('/posts', (req, res, next) => posts.show(req.params.id).then((result) => res.send(200, result) ).catch(() => res.send(404)) );
13: Run tests again. They should all be green.
The next action in line involves update: POST /posts/:id
. Similar to the previous action, we must develop a clear path first, assuming that the correct post ID
is specified. We will consider situations when an invalid ID
is specified later.
14. Create test first as usual:
describe('POST /posts/:id', () => { //data that is sent to the server var data = [{ author: 'Mr. Williams', content: 'Now POST /posts/:id works' }]; //test actions returns specified attributes merged with the //specified identified so it's possible to control correctness //of the parameters that were passed to the controller stub before(() => { posts.update = (id, attrs) => new Promise((resolve, reject) => resolve(_.merge({ id: id }, attrs)) ); }); //and in the test below response data and status are verified it('responds with Created and returns content of the updated post', () => request .post('/posts/4') .send({ post: data }) .expect(_.merge({ id: 4 }, data)) .expect(200) ); });
15. Run test and see an expected error:
Error: expected { '0': { author: 'Mr. Williams', content: 'Now POST /posts/:id works' }, id: 4 } response body, got { code: 'MethodNotAllowedError', message: 'POST is not allowed' }
16. Define missing method:
app/server.js:
server.post('/posts/:id', (req, res, next) => posts.update(req.params.id, req.params.post).then((result) => res.send(200, result) ) );
Now we have an updated server action that is capable of handling existing resources. It’s time to address cases when the identifier of nonexistent posts is specified.
17. Create the test:
test/server.js:
context('when there is no post with the specified id', () => { before(() => { posts.update = (id) => new Promise((resolve, reject) => reject(id) ); }); it('responds with 404 HTTP response', () => request .post('/posts/3') .send({ post: data }) .expect(404) ); });
18. Run npm test and get an error:
Error: timeout of 2000ms exceeded. Ensure the done() callback is being called in this test.
This result is expected. Rejected promises have not been addressed yet.
19. Add error handling to the server action:
server.post('/posts/:id', (req, res, next) => posts.update(req.params.id, req.params.post).then((result) => res.send(200, result) ).catch(() => res.send(404)) );
20. Run tests again. They are green!
At this point, only one action has not been implemented: DELETE /posts/:id
. Now it’s time to fill this gap.
Here is the code for the action test:
describe('DELETE /posts/:id', () => { //imitate action that always returns id of the deleted post before(() => posts.destroy = (id) => new Promise((resolve, reject) => resolve({ id: id }) ) ); //checks that server returns deleted post identified it('responds with the id of the deleted post', () => request .delete('/posts/5') .expect({ id: 5 }) ); });
21. Run action test. Get an error.
22. Define action on the server:
server.del('/posts/:id', (req, res, next) => posts.destroy(req.params.id).then((result) => res.send(200, { id: req.params.id }) ) );
23. Run tests again. Green!
Now let’s handle cases when there is no post with the specified ID
.
Here is the test code:
test/server.js:
context('when there is no post with the specified id', () => { before(() => posts.destroy = (id) => new Promise((resolve, reject) => reject(id) ) ); it('responds with NotFound', () => request .delete('/posts/5') .expect(404) ); });
24. Run the test. Get timeout error.
25. Update server:
app/server.js:
server.del('/posts/:id', (req, res, next) => posts.destroy(req.params.id).then((result) => res.send(200, { id: req.params.id }) ).catch(() => res.send(404)) );
Everything is green.
Now we have a fully workable server. It properly redirects request data to the controller and writes serialized results to the response. It does not yet access ES instances and returns dummy data. This function is addressed below.
Develop and Test Controller
This section concerns specifically the PostsController
. It will work with an ES client. We can assume that the ES client is well-tested so we do not have to test it ourselves. Only the methods of the controller in isolation should be tested. To do so, we need to pass the client stub to the controller instance to be able to verify that the correct methods were called on the stub, and the returned data was properly handled.
Prepare Controller for Testing
1. Update PostsController definition so it accepts client instance from outside:
app/controllers/posts.js:
module.exports = class { constructor(client) { this.client = client; } index() { //controller still always returns empty object return new Promise((resolve, reject) => resolve({}) ); } };
2. Install another test library:
npm install should --save-dev
3. Introduce the ES client stub into the posts controller test:
test/controllers/posts.js:
var PostsController = require('../../app/controllers/posts'); describe('PostsController', function() { var client = {}; var posts = new PostsController(client); });
4. Run tests. Everything should be green.
Now we have prepared the basis for PostsController
development.
Test Controller
1. Write our first test – index action.
Here is the test code:
describe('index', () => { before(() => client.search = () => new Promise((resolve, reject) => resolve({ "took": 27, "timed_out": false, "_shards": { "total": 5, "successful": 5, "failed": 0 }, "hits": { "total": 1, "max_score": 1, "hits": [ { "_index": 'index', "_type": 'type', "_id": "AVhMJLOujQMgnw8euuFI", "_score": 1, "_source": { "text": "Now PostController index works!", "author": "Mr.Smith" } } ] } }) ) ); it('parses and returns post data', () => posts.index().then((result) => result.should.deepEqual([{ id: "AVhMJLOujQMgnw8euuFI", author: "Mr.Smith", text: "Now PostController index works!" }]) ) ); });
We see that the possible ES response is imitated here.
2. Run the first test and see a failure. Our current PostsController always returns an empty object.
3. Correct failure:
const _ = require('lodash'); module.exports = class { constructor(client) { this.client = client; } //index returns list of posts attributes merged with corresponding //identifiers index() { return this.client .search() .then((res) => _.map(res.hits.hits, (hit) => _.merge(hit._source, { id: hit._id }) ) ); } };
4. Run test again. Green!
Test Parameters
We just tested that PostsController
parsed the ES result properly, but we also need to test that it passes correct params to the client. Two params of the ES client method need to be specified: search: index
and type
.
1. Add params to the PostsController constructor:
app/controllers/posts.js:
//index and type names should be specified outside now constructor(client, indexName, type) { this.client = client; this.indexName = indexName; this.type = type; }
2. Specify test params in the PostsController specs:
test/controller/posts.js:
describe('PostsController', () => { const client = {}; //'index' and 'type' are some virtual index and type names const posts = new PostsController(client, 'index', 'type'); ... }
3. Run test again. Green! Nothing is broken.
4. Verify that we specified those params properly in test when client.search method is called.
I am going to use sinon for spying on method calls:
npm install sinon --save-dev
I am using should-sinon for should – like asserts:
npm install should-sinon --save-dev
Specify these in the controller test:
test/controllers/posts.js:
var sinon = require('sinon'); require('should-sinon');
Now we can write a params verification test:
test/controllers/posts.js:
it('specifies proper index and type while searching', () => { const spy = sinon.spy(client, 'search'); //It's expected below that method search() is called once with //proper index name and object type as paramters. return posts.index().then(() => { spy.should.be.calledOnce(); spy.should.be.calledWith({ index: 'index', type: 'type' }); }); });
5. Run npm test. See the following failure:
expected 'search' to be called with arguments { index: "index", type: "type" } search() => [Promise] { } at PostsController.index (/projects/node_api/app/controllers/posts.js:14:22) expected false to be true
6. Update the controller:
app/controllers/posts.js:
index() { //pass index name and object type to the controller return this.client.search({ index: this.indexName, type: this.type }) .then((res) => _.map(res.hits.hits, (hit) => _.merge(hit._source, { id: hit._id }) ) ); }
The tests should be green.
Create and Run Manual Integration Tests
Now is a good time to run simple manual integration tests to be sure that all the pieces we have created correspond properly. Each piece is covered by its own unit test. We cannot be sure they all interact correctly without integration testing. It would be best to create manual integration tests for this purpose, but that is beyond the scope of this article.
To complete the following steps, you should have ES service installed locally and be running on default 9200 port.
1. Create index ( ‘node_api’ ):
curl -XPOST localhost:9200/node_api
Expected output:
{"acknowledged":true}%
2. Create post example:
curl -XPOST localhost:9200/node_api/posts -d '{ "author": "Mr. Smith", "content": "Now GET /posts works!" }'
Expected output:
{"_index":"node_api","_type":"posts","_id":"AViW9F1lhQ3AxSLOwi2k","_version":1,"created":true}%
3. Install elasticsearch npm package:
npm install elasticsearch --save-dev
4. Create ES client instance:
./app/client.js:
const elasticsearch = require('elasticsearch'); module.exports = new elasticsearch.Client({ host: 'localhost:9200' });
5. Update start script with the real index and type names:
./start.js:
const client = require('./app/client'); const serverFactory = require('./app/server'); const PostsController = require('./app/controllers/posts'); const posts = new PostsController(client, 'node_api', 'posts'); const server = serverFactory(posts); server.listen(8080, () => console.log('%s listening at %s', server.name, server.url) );
6. Run server:
npm start
7. Make test request:
curl -XGET https://localhost:8080/posts/1
If all steps have been done properly, you should see something like this in the output:
[{"author":"Mr. Smith","content":"Now GET /posts works!","id":"AViW9F1lhQ3AxSLOwi2k"}]%
Our integration test was successful. We can continue adding methods to the controller, knowing that the server has been configured properly and calls proper controller methods.
Implement Post Indexing in ES
1. Create test code:
describe('create', () => { const attrs = { author: 'Mr. Rogers', text: "Now PostController create works!" }; before(() => { client.index = () => new Promise((resolve, reject) => resolve({ "_index": 'index', "_type": "type", "_id": "AViXYdnZxmF-_Ui11JAF", "_version": 1, "created": true }) ); }); it('parses and returns post data', () => posts.create(attrs).then((result) => result.should.deepEqual(_.merge({ id: "AViXYdnZxmF-_Ui11JAF" }, attrs)) ) ); it('specifies proper index, type and body', () => { const spy = sinon.spy(client, 'index'); return posts.create(attrs).then(() => { spy.should.be.calledOnce(); spy.should.be.calledWith({ index: 'index', type: 'type', body: attrs }); }); }); });
Two tests are defined above. In real situations, these tests would consist of two interactions. I joined them into one interaction here for simplicity’s sake.
2. Run tests and see errors
3. Add indexing support to the controller:
create(attrs) { return this.client.index({ index: this.indexName, type: this.type, body: attrs }) .then((res) => _.merge({ id: res._id }, attrs) ); }
The tests will be green if steps have been done properly.
Implement “Show” Action
The next controller action is show. Similar to GET /show/:id
, this controller action should handle situations when a post with a specified identifier does not exist. We will take care of that later. Now, let’s start from the simplified action version, assuming that only a correct identifier can be specified.
1. Create test first as usual:
test/controllers/posts.js:
describe('show', () => { const id = "AVhMJLOujQMgnw8euuFI"; const attrs = [{ author: 'Mr. Williams', content: 'Now PostsController show works!' }]; before(() => client.get = () => new Promise((resolve, reject) => resolve({ "_index": 'index', "_type": 'post', "_id": id, "_version": 1, "found": true, "_source": attrs }) ) ); it('parses int returns post data', () => posts.show(id).then((result) => result.should.deepEqual(_.merge({ id: id }, attrs)) ) ); it('specifies proper index, type and id', () => { const spy = sinon.spy(client, 'get'); return posts.show(1).then(() => { spy.should.be.calledOnce(); spy.should.be.calledWith({ index: 'index', type: 'type', id: 1 }); }); }); });
2. Run npm test, get error.
If you run npm test
now, you will see an error because method show
has not been defined on the PostsController
.
3. Correct error:
app/controllers/posts.js:
show(id) { return this.client.get({ index: this.indexName, type: this.type, id: id }) .then((res) => _.merge({ id: res._id }, res._source) ); }
4. Run npm test again. All tests should be green.
The PostsController
is now able to find the post and return its content.
But if the identifier of a nonexistent post was specified, the controller will fail. We need to handle this exceptional situation properly.
5. Create test for nonexistent post identifier:
test/controllers/posts.js:
context('when there is no post with the specified id', () => { before(() => client.get = () => { return new Promise((resolve, reject) => resolve({ "_index": 'index', "_type": 'post', "_id": id, "found": false }) ); } ); it('returns rejected promise with the non existing post id', () => posts.show(id).catch((result) => result.should.equal(id) ) ); });
6. Run test and get an error.
7. Update controller:
app/controllers/posts.js:
show(id) { return this.client.get({ index: this.indexName, type: this.type, id: id }) .then((res) => new Promise((resolve, reject) => { if (res.found) { return resolve(_.merge({ id: res._id }, res._source)); } reject(id); }) ); }
Now we have fully implemented show action.
Enable “Update” Function
The next action concerns update
. As in the case of show
action, we need to address situations when a nonexistent post identifier is passed to the action. Also similar to the show
, we will handle that later and start from a simplified version.
1. Create the test code:
describe('update', () => { const id = "AVhMJLOujQMgnw8euuFI"; const attrs = [{ author: 'Mr. Williams', content: 'Now PostsController show works!' }]; before(() => client.update = () => new Promise((resolve, reject) => resolve({ "_index": "index", "_type": "type", "_id": id, "_version": 4 }) ) ); it('parses and returns post data', () => posts.update(id, attrs).then((result) => result.should.deepEqual(_.merge({ id: id }, attrs)) ) ); it('specifies proper index, type, id and attrs', () => { const spy = sinon.spy(client, 'update'); return posts.update(id, attrs).then(() => { spy.should.be.calledOnce(); spy.should.be.calledWith({ index: 'index', type: 'type', id: id, doc: attrs }); }); }); });
2. Run npm test and get these errors:
1) PostsController update parses and returns post data: TypeError: posts.update is not a function at Context.it (test/controllers/posts.js:178:13) 2) PostsController update specifies proper index, type, id and attrs: TypeError: posts.update is not a function at Context.it (test/controllers/posts.js:186:20)
So, update
is not yet a function. Let’s fix this.
3. Define update method:
app/controllers/posts.js:
update(id, attrs) { return this.client.update({ index: this.indexName, type: this.type, id: id, doc: attrs }) .then((res) => _.merge({ id: res._id }, attrs) ); }
The errors should be corrected.
Now it’s time to address situations when an identifier of a nonexistent resource is specified.
4. Create test for nonexistent ID specification:
context('when there is no post with the specified id', () => { before(() => client.update = () => { return new Promise((resolve, reject) => resolve({ "error": "DocumentMissingException[[node_api][3] [posts][AVhMJLOujQMgnw8euuFI]: document missing]", "status": 404 }) ); } ); it('returns rejected promise with the non existing post id', () => posts.update(id, attrs).catch((result) => result.should.equal(id) ) ); });
5. Run test and see a failure.
6. Update the definition of the method update:
update(id, attrs) { return this.client.update({ index: this.indexName, type: this.type, id: id, doc: attrs }) .then((res) => new Promise((resolve, reject) => { if (res._id) { return resolve(_.merge({ id: res._id }, attrs)); } reject(id); }) ); }
7. Run tests. They should be green.
Enable “Destroy” Function
This is the final REST
action to be implemented. As with previous actions, we need to handle situations when the ID
of the non-existing resource is specified as a parameter of the action. Also following our previous methods, we will implement a simple action version first and handle non-existing sources second.
1. Create the test code:
test/controllers/posts.js:
describe('destroy', () => { const id = "AVhMJLOujQMgnw8euuFI"; before(() => client.delete = () => new Promise((resolve, reject) => resolve({ "found": true, "_index": "index", "_type": "type", "_id": id, "_version": 6 }) ) ); it('parses and returns post data', () => posts.destroy(id).then((result) => result.should.equal(id) ) ); it('specifies proper index, type and id', () => { const spy = sinon.spy(client, 'delete'); return posts.destroy(id).then(() => { spy.should.be.calledOnce(); spy.should.be.calledWith({ index: 'index', type: 'type', id: id }); }); }); });
2. Run npm test. See these errors:
1) PostsController destroy parses and returns post data: TypeError: posts.destroy is not a function at Context.it (test/controllers/posts.js:234:13) 2) PostsController destroy specifies proper index, type and id: TypeError: posts.destroy is not a function at Context.it (test/controllers/posts.js:242:20)
3. Define destroy action:
destroy(id) { return this.client.delete({ index: this.indexName, type: this.type, id: id }) .then((res) => id); }
4. Run test; It should be green.
We are now able to destroy the post with the specified identifier.
5. Create test code for nonexistent resource:
context('when there is no post with the specified id', () => { //ES returns "found" equals false if is not able to find resource //with the specified identifier. before(() => client.delete = () => new Promise((resolve, reject) => resolve({ "found": false, "_index": "index", "_type": "type", "_id": id, "_version": 6 }) ) ); //checks that promise is rejected it('returns rejected promise with the non existing post id', () => posts.destroy(id).catch((result) => result.should.equal(id) ) ); });
6. Check functionality:
destroy(id) { return this.client.delete({ index: this.indexName, type: this.type, id: id }) .then((res) => new Promise((resolve, reject) => { if (res.found) { return resolve(id); } //reject with the post identifier. reject(id); }) ); }
7. Run tests. They should be green.
Now we have completed all API functionality. Posts can be created, deleted, updated, and listed. The next section covers clean up.
Refactor Controller
Refactoring is a safe and easy operation in our case because the tests cover it.
Right now, we have a repetitive pattern in our app/controllers/posts.js:
{ index: this.indexName, type: this.type ... }
Let’s try to DRY it and clean it up.
1. Extract all such patterns into a dedicated class:
app/lib/resource.js:
const _ = require('lodash'); module.exports = class { constructor(client, indexName, type) { this.client = client; this.baseParams = { index: indexName, type: type }; } search() { return this.client.search(this.baseParams); } create(attrs) { return this.client.index(_.merge({ body: attrs }, this.baseParams)); } get(id) { return this.client.get(_.merge({ id: id }, this.baseParams)); } update(id, attrs) { return this.client.update(_.merge({ id: id, doc: attrs }, this.baseParams)); } delete(id) { return this.client.delete(_.merge({ id: id }, this.baseParams)); } };
Our controller has now been simplified a bit:
const _ = require('lodash'); const Resource = require('../lib/resource'); module.exports = class { constructor(client, indexName, type) { this.resource = new Resource(client, indexName, type); } index() { return this.resource.search() .then((res) => _.map(res.hits.hits, (hit) => _.merge(hit._source, { id: hit._id }) ) ); } create(attrs) { return this.resource.create(attrs) .then((res) => _.merge({ id: res._id }, attrs) ); } show(id) { return this.resource.get(id) .then((res) => new Promise((resolve, reject) => { if (res.found) { return resolve(_.merge({ id: res._id }, res._source)); } reject(id); }) ); } update(id, attrs) { return this.resource.update(id, attrs) .then((res) => new Promise((resolve, reject) => { if (res._id) { return resolve(_.merge({ id: res._id }, attrs)); } reject(id); }) ); } destroy(id) { return this.resource.delete(id) .then((res) => new Promise((resolve, reject) => { if (res.found) { return resolve(id); } reject(id); }) ); } };
We have extracted all our interactions into a special, dedicated Resource class. But results parsing is still in the controller. Let’s address this.
2. Extract results parsing into a special Parser class:
app/lib/parser.js:
const _ = require('lodash'); module.exports = class { parseSearchResult(res) { return _.map(res.hits.hits, (hit) => _.merge(hit._source, { id: hit._id }) ); } parseCreateResult(attrs) { return (res) => _.merge({ id: res._id }, attrs); } parseGetResult(res) { return new Promise((resolve, reject) => { if (res.found) { return resolve(_.merge({ id: res._id }, res._source)); } reject(res._id); }); } parseUpdateResult(id, attrs) { return (res) => new Promise((resolve, reject) => { if (res._id) { return resolve(_.merge({ id: res._id }, attrs)); } reject(id); }); } parseDeleteResult(id) { return (res) => new Promise((resolve, reject) => { if (res.found) { return resolve(id); } reject(id); }); } };
Here is the result:
const Resource = require('../lib/resource'); const Parser = require('../lib/parser'); module.exports = class { constructor(client, indexName, type) { this.resource = new Resource(client, indexName, type); this.parser = new Parser(); } index() { return this.resource.search().then(this.parser.parseSearchResult); } create(attrs) { return this.resource.create(attrs).then(this.parser.parseCreateResult(attrs)); } show(id) { return this.resource.get(id).then(this.parser.parseGetResult); } update(id, attrs) { return this.resource.update(id, attrs).then(this.parser.parseUpdateResult(id, attrs)); } destroy(id) { return this.resource.delete(id).then(this.parser.parseDeleteResult(id)); } };
Our controller looks much better now!
Summary
We have now completed a Node.js API for an ES by following step-by-step instructions led by testing. The result is a relatively simple API with reliable test coverage. Each of the steps described is trivial and might be done quite easily without any debugging efforts. The TDD approach I have detailed results in better, cleaner code when developing Node.js applications. Once you try it, you will see it is also faster than creating code before the test.