Lessons Learned: Integration Testing a Node.js Web Server
Hi, everyone. This is James Shore with my lessons learned about integration testing a Node.js web server. I'm recording this on August 30th, 2012.
We'll start by showing a complete integration test, and then we'll dive into the details. We'll look at what integration testing is, how it's different from unit testing, and why and when it's useful. Next, we'll take a brief look at Node server fundamentals. Finally, we'll tie it all together by integration testing a simple Node web server from scratch.
The Complete Integration Test
Let's look at a complete integration test before we dive into the details. You can find the source code for this episode at the link on your screen.
This integration test confirms that we've written a Node.js web server that responds to GET requests. It's a simple test that serves as the foundation for future work. You can build on this pattern to create more sophisticated tests.
The test starts the server... attempts to retrieve a web page... confirms that the status and page text had the expected results... and stops the server.
On the server side, the code relies on Node's built-in HTTP server to do most of the work. When the server is started, it starts the server... sets up a function to handle requests... and listens on the requested port. When a request comes in, it responds with a simple "Hello World."
When the server is stopped, it just tells Node to close the port.
Now let's go into more detail.
Integration Tests vs. Unit Tests
Although you might have heard otherwise, test-driven development isn't just for unit testing. When using TDD, you'll typically find yourself writing three kinds of tests: unit tests, focused integration tests, and end-to-end integration tests. Each has its place.
Unit tests are for the logic of your program. They're fine-grained and focus on just one or two functions. They run fast and they're low maintenance, which makes them the best type of test to write when doing TDD.
A good unit test runs entirely in memory--without touching the file system, network, or leaving the Node process in any way at all. That's what makes them so fast. You should be able to run over 100 unit tests per second. A fast test suite is important for TDD because you run your tests every minute or so.
Unit tests are low maintenance, too. A good unit test checks the behavior of just one or two functions. It might run more code than that, but it should only break when the function it's actually testing changes. This prevents false failures and makes unit tests very low maintenance.
Fast and low maintenance: that's the perfect test. Nearly all of your code should be driven by unit tests. You'll typically have about the same amount of unit test code as you have production code.
Focused Integration Tests
Unit tests are nearly perfect, but you can't use them for everything. Some of your code will talk to other systems--like web browsers, database servers, or the file system. Those parts of your code are called integration points, and it takes integration tests to check their interactions with other systems.
Focused integration tests are the best way to test integration points. A focused integration test checks the behavior of just one integration point, which keeps them fast. For example, one set of focused integration tests might check the code that connects to your database, and another might check the code that serves a web page.
Focused integration tests run a lot slower than unit tests because they're accessing a file system or network. They also have higher maintenance costs--they require you to configure the system you're integrating with, and that configuration occasionally gets borked and causes your test to fail, even though there's nothing wrong with your code.
Because they're slower and higher maintenance than unit tests, you should only write focused integration tests when you have to. Only use them to test integration points, and use unit tests for everything else.
End-to-End Integration Tests
In theory, if you've written your unit tests and focused integration tests correctly, and they all pass, then your entire system should mesh together and work as a complete, lovely whole. In practice... that's hard to trust.
End-to-end integration tests exercise your entire system to make sure everything works together. For example, an end-to-end test might enter some text into a web form, then check the back-end database to make sure that the change propagated all the way through your system.
End-to-end tests are the slowest and most costly to maintain of all your tests. They typically take multiple seconds to run, and they require a full copy of your production environment. Changes to any of your code can cause end-to-end tests to fail, even if you didn't break anything, which further increases the maintenance burden.
As a result, you should keep end-to-end tests to a minimum. Use them as a safety net, not your main testing strategy. Any time you want to add something to an end-to-end test, ask yourself if you could test the same thing with a unit test or focused integration test instead. Similarly, if your end-to-end tests find a bug that the rests of your tests didn't, that probably means there's gap in your test strategy that needs fixing.
To summarize, nearly all of your tests should be unit tests. The integration points in your system should be covered by focused integration tests, and in a perfect world, you should only need a handful of end-to-end integration tests.
In the rest of this episode, we're going to look at how to write a focused integration test for a Node web server. But first, let's take a look at how Node servers work.
Node.js Server Fundamentals
The Callback Design Pattern
Node has a lot of functions that run asynchronously. That means that the function starts some process--like opening a network port--and then the function returns without waiting for its operation to finish. For example, if you create a web server and tell it to start listening to port 8080, the
listen() function will return before the server is actually ready to start receiving requests. This has a lot of performance benefits for Node, but it takes careful programming to deal with. In this example, the log message would actually be printed before the server was really started.
So Node allows you to pass in a callback function. Once the asynchronous process is complete--for example, once the network port is open and ready to accept requests--Node runs the callback. So in this example, we create the server... and tell Node to listen on port 8080. Node starts to open the port, then returns immediately, and we log that the server is starting. Then, once the port is open and ready, Node will run our callback... and that's when we log that the server has started.
The Event Emitter Pattern
Let's say you're writing a Node program to get a web page. It will start the request, then call a callback with the response. The response object is an
You see, Node doesn't wait for the entire response to come in before calling the callback. It calls the callback as soon as the response headers are received, and then the body trickles in. To get the response body, you have to listen for events on the response. Here's how it works:
The response object is an
EventEmitter. EventEmitters run callbacks when certain events occur. For example, whenever more of the web page is received, the response object fires the
data event. When the server's done, the response object fires the "end" event.
We can respond to those events by passing a callback to the
EventEmitter. We do that by calling the
on() method... passing in the event that we want to listen for... and a callback function.
So in this code, whenever the server sends us more data, the response object fires the
data event, calls our callback... and we log the data we received. When the server's done, the response object fires the
end event, calls our callback... and we log that the response is done.
First-class functions, the callback design pattern, and the event emitter pattern are the fundamentals you need to understand in order to understand how Node servers work. For details, check out Node's excellent online API documentation.
Integration Testing a Node.js Web Server
Now let's tie it all together and use TDD to create a simple Node web server. The web server code is an integration point because it's communicating outside the Node process--it's talking to a web browser--so we need to use a focused integration test to test it.
As we write this test, remember TDD's red-green-refactor loop: first we think about a small test that will force us to write the code we want; then we write a failing test; make it pass; improve the code; and repeat.
So let's think about the code we want to write. We're creating a simple web server. A small first step is to just get nothing, so let's test that. We'll start the server on port 8080... give it a callback for when it's ready... make a
GET request on the server... wait for it to finish... then stop the server in our
tearDown. We add the server boilerplate... and when we run the test, it fails because there's no server.
Now let's make the test pass. We'll create a web server... that responds to requests... with nothing... and listens on our port... and runs the callback when it's ready. We'll also close down the server when
stop is called.
Now for the next bit of code. Let's check that the status code is okay. This should already work because a 200 status code is Node's default. We run the tests... and they pass.
And now let's get some data out. We'll make a variable to collect the data... set the encoding... listen for data... collect it as it comes in... listen for the end of the response... and assert that it's what we expected. The test fails... so we'll send the response we're looking for... and the test passes.
Now we need to clean up our test code. Let's factor out the setup... and abstract the details of calling the web server... and we're done.
So that's what I've learned so far about integration testing a Node.js web server. To summarize, focused integration tests are one of three main types of tests to write when using test-driven development. They're a good way to test the integration points in your application.
To write an integration test against a Node web server, start the server; retrieve a page from the server; confirm that you got the results you expected; and stop the server.
This simple example covers all the essential aspects of integration testing. You can build on this pattern to make more sophisticated integration tests for your own code. The source code is available at the link on your screen.
Thanks for watching, and I'll see you next time!