Testing our Node Applications
Test driving our applications is important (which, hopefully, you know at this point). Testing web applications is even more important as there are more moving pieces and connections being used in the application life-cycle. So having efficient tests helps to ensure that your application is working as expected and also alerts you to specifically what the problem is if something were to go wrong. Let's get started with how we would go about that.
Follow Along
Getting Started
Now that we have an application up and running, let's start from square one. What do we even bother testing? Everything?
Certainly not. There are all sorts of things we don't need to worry about testing. The first thing we can skip testing are dependencies. Any library we are going to bring into our application is something that has been developed by a team that has thoroughly tested their library/framework. Next, we don't need to test data mapping. Our models, for example, are just going to hold properties of the entities in our application. This means we can pretty much forgo testing inside of them unless they contain some sort of logic. Our views are generating HTML and this is happening through a dependency, so no tests there. (We will be reaching into them to test other pieces of our app though) So what do we test? Well, everything else.
Essentially our MVC structure is what we need to write tests for. This is where the logic of our application exists. So, therefore, this is where we need to have confidence in our application. Let's dig into exactly what this is going to mean for us.
MVC Testing
Testing your MVC applications is a different process from testing a simple command line environment application. In this environment we are testing the relationships between the pieces more than the pieces themselves. This is called...
Integration Testing
Which is the next step in our testing pyramid:
Our integration tests will focus on whether or not our applications pieces respond the way that they are supposed to when we make requests from our client environment. So we will be using our integration tests to make requests to our application and then making assertions on what methods our routers call, what our controller methods do once called, and whether our views render the correct data from our models.
How???
I'm glad you asked! For this process, we're going to need a way to hook into our application from our testing environment and actually hit endpoints (i.e. http://localhost:3000/games/2
) and then be able to measure the result in a way that we can make test assertions on it. For that we'll need a new tool in our testing arsenal.
Supertest
Supertest is a library that we can install with npm (npm i supertest
from project root). It allows us to make fake requests to our application as if it were in a server environment and then make test assertions on the response we get back from our server. In this way we can measure that our MVC environment behaves exactly as we intend and also, if we accidentally break something during development, we know as soon as we do and how we did. Let's see it in action.
First Test
Our application is pretty bare right now. Let's start with our index route. This one will be pretty simple as all we want to do for our index route is redirect to our first model collection page. We'll be working with games in this example so we want to redirect to /games
. Let's see what that would look like:
// index-router.test.js
const request = require('supertest')
const app = require('../../../app')
describe('index', () => {
test('redirects', (done) => {
request(app)
.get('/')
.end((err, res) => {
expect(res.status).toBe(302)
expect(res.headers.location).toMatch('/games')
done()
})
})
})
Let's talk about what's going on here and then look at how to make this test pass.
- First we're importing the code we'll need to write tests. We're naming
supertest
request since that's what it will be doing for us. We're importing our application file so that we can simulate requests to it. - Inside of our test block we are passing our
app
tosupertest
(orrequest
) so it has our context. - We're then making a get request to our application at the root (
.get('/')
) - We're then ending our request and getting the response from our application
- We're then making an assertion that
res.status
is302
(proper HTTP status code) - We're then also asserting that
res.headers.location
is/games
(where we're redirecting) - We're calling the done method which we get from Jest to signal that any async behavior is finished
Essentially we're asserting that a get request to the root of our application results in a redirect to /games
. Currently this fails...
Red => Green
Let's make it pass!
// index-router.js
const express = require('express');
const router = express.Router();
class IndexController {
redirect (req, res) {
res.redirect('/games')
}
}
const indexController = new IndexController()
router.get('/', indexController.redirect)
module.exports = router;
This code builds out the proper router and controller method to make our test pass.
Refactor
The most obvious refactor I see at this point would be to move the controller to it's own file. Let's move that to index-controller.js
in the controllers
directory.
// index-router.js
const express = require('express');
const router = express.Router();
const IndexController = require('../../controllers/index-controller')
const indexController = new IndexController()
router.get('/', indexController.redirect)
module.exports = router;
// index-controller.js
class IndexController {
redirect (req, res) {
res.redirect('/games')
}
}
module.exports = IndexController
This way we are encapsulating each piece of functionality so that it's easier to debug or add to later.
Our /games
Route
So now we have an application that redirects to /games
but doesn't do much else. Ultimately we want our /games
endpoint to render a template that will display a list of all of our games. So let's test drive the beginning of that!
// games-router.test.js
const request = require('supertest')
const app = require('../../../app')
describe('games', () => {
test('successfully renders "games" template', (done) => {
request(app)
.get('/games')
.end((err, res) => {
expect(res.status).toBe(200)
expect(res.headers['content-type']).toContain('text/html')
expect(res.text).toContain('<title>Games</title>')
done()
})
})
})
Let's break down our assertions so we can better understand how to go about making them pass.
expect(res.status).toBe(200)
Here, as in the previous test, we're just checking to see that we get the proper HTTP status returned. This time we want 200
because we're hoping for a successful connection.
expect(res.headers['content-type']).toContain('text/html')
With this assertion we're checking to see that the content we're receiving is html
once we can make this pass, we know that we're returning an html
page being rendered from one SOME template in our application.
expect(res.text).toContain('<title>Games</title>')
With this assertion we're asserting specifics about our rendered html
. We can make assertions about the dynamic pieces of our application based on how this html
rendered. In this case, we're saying that we expect there to be a title
element and that it should have the text content Games
. When this passes, we know that we're rendering the expected template as well as using the dynamic info we pass to it!
So, with all of that in mind, let's make it pass!
Red => Green
We'll need to make a few new files to make this one pass:
// games-router.js
const express = require('express')
const router = express.Router()
const GamesController = require('../../controllers/games-controller')
const gamesController = new GamesController()
router.get('/', gamesController.readAllGames)
module.exports = router
This router handles any requests to our /games
endpoint.
To use this, we'll have to make an update to our app.js
file. We need to import and use this router just like the index-router
const createError = require('http-errors');
const express = require('express');
const path = require('path');
const cookieParser = require('cookie-parser');
const logger = require('morgan');
const indexRouter = require('./src/routes/index/index-router');
/**
* import our new router
*/
const gamesRouter = require('./src/routes/games/games-router');
const app = express();
// view engine setup
app.set('views', path.join(__dirname, 'src/views'));
app.set('view engine', 'hbs');
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'src/public')));
app.use('/', indexRouter);
/**
* tell out app to use our router
*/
app.use('/games', gamesRouter);
// catch 404 and forward to error handler
app.use(function (req, res, next) {
next(createError(404));
});
// error handler
app.use(function (err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error');
});
module.exports = app;
Next we need to add the logic for the router in the controller.
// games-controller.js
class GamesController {
readAllGames (req, res) {
res.render('games')
}
}
module.exports = GamesController
All of this will get our first assertion passing since we now have instructions for what to do with a GET request to /games
. Let's get our second assertion passing.
Make the Second Assertion Pass
To get the second assertion passing, we need to actually get some html
rendering. We can do that as cimply as making the corresponding template that we reference in our controller (games
). We will put this in the views
directory. We aren't going to add any content to this template. You will see some html
rendered. Remember, this is because Handlebars by default passes every template through the layout.hbs
file if one exists.
Creating this file is enough to make our second assertion pass because it changes the content-type of our response. Let's move on to the third.
Make the Third Assertion Pass
Our third assertion technically makes an assertion on the model that we're passing to our view. This is because we are asserting some specific html
that we are expecting to be rendered based on model data we provide in the controller. For this we need to add some model data to our controller:
// games-controller.js
class GamesController {
readAllGames (req, res) {
res.render('games', { title: 'Games' })
}
}
module.exports = GamesController
Note that we're passing an object to the .render()
method now. This is where we can put any model data that we may want to use in our views. We will be adding much more complex data to this later, but for the sake of this demo, we'll keep it simple.
With all of this in place, all of our tests should be passing!
Conclusion
Congratulations! You just wrote and passed your first integration tests! Integration tests are extremely important especially as you continue to scale your web applications. Make sure to continue to explore and see just what kinds of assertions you can make about your application!
Challenge
Continue to test drive and build this application out to the point that you can print a list of games that you create in our newly created template. Once you've successfully done that, create a single game template that will render each individual game!