A big cost area in testing is manual tests of the user interface. Therefore, a wide range of tools have been developed to automate running tests at the HTTP level. Selenium is a popular tool implemented in Java, for example. In the Node.js world, we have a few interesting choices. The chai-http
plugin to Chai would let us interact at the HTTP level with the Notes application, while staying within the now-familiar Chai environment. ZombieJS is a popular tool that implements a simulated browser which we can direct to visit web pages and perform simulated clicks on simulated buttons, verifying application behavior by inspecting the resulting behavior.
However, for this section, we'll use CasperJS (http://casperjs.org). This tool runs on top of PhantomJS, which is a headless version of Webkit. Webkit is a real browser engine that's at the heart of the Safari browser. PhantomJS encapsulates Webkit so it runs completely headless, meaning that there is no GUI window popping up on the screen during test execution. It otherwise behaves exactly as a web browser, and CasperJS can even generate screenshots if its desired to verify visual elements.
While CasperJS is installed using npm, and CasperJS scripts are written in JavaScript that looks like Node.js, the CasperJS website repeatedly says that this is not Node.js. ES-2015 constructs do not work, and while there's a require
statement it doesn't access Node.js packages. Fortunately, it comes with a fairly rich API that supports a wide range of tasks including screen scraping and UI test automation.
CasperJS scripts are run using the casperjs
command. When run as casperjs test
, additional methods are made available that are useful for functional testing.
Let's first set up the directory and other configurations:
$ mkdir test-compose/notesui
Then in test-compose/docker-compose.yml
, update the volumes as follows:
notesapp-test: .. volumes: - ./reports-notes:/reports - ./notesui:/usr/src/app/notesui-test - ./notesmodel:/usr/src/app/notesmodel-test
This ensures that the directory just created appears as notesui-test
in the application directory.
In run.sh
, add this line to install CasperJS and PhantomJS:
docker exec -it notesapp-test npm install -g [email protected] [email protected]
In the script we're about to write, we need a user account that we can use to log in and perform some actions. Fortunately, we already have a script to set up a test account. In users/package.json
, add this line to the scripts
section:
"setupuser": "PORT=3333 node users-add",
Then in notes/package.json
, add this line to the scripts
section:
"test-docker-ui": "cd notesui-test && casperjs test uitest.js"
We're about to write this test script, but let's finish the setup, the final bit of which is adding these lines to run.sh
:
docker exec -it userauth-test npm run setupuser docker exec -it notesapp-test npm run test-docker-ui
When executed, these two lines ensure that the test user is set up, and it then runs the user interface tests.
Adding a few id
or class
attributes to HTML elements can improve testability. In the test we're about to write, we'll inspect the HTML to ensure that the browser has gone to the right page, and that the application is in the expected state. We'll also be clicking on buttons. Both of these tasks are made easier if we can refer to certain HTML elements by an id
.
In notes/views/pageHeader.ejs
, change these lines:
<% if (user && typeof hideAddNote === 'undefined') { %> <a class="btn btn-primary" id="btnaddnote" href='/notes/add'> ADD Note</a> <% } %> <% if (user) { %> <a class="btn btn-primary" id="btnlogout" href="/users/logout"> Log Out <span class="badge"><%= user.username %></span></a> <% } else { %> <a class="btn btn-primary" id="btnloginlocal" href="/users/login">Log in</a> <a class="btn btn-primary" id="btnlogintwitter" href="/users/auth/twitter"><img width="15px" src="/images/twitter-brand-logos/TwitterLogo_white.png"/>Log in with Twitter</a> <% } %>
In notes/views/noteview.ejs
, make these changes:
<a class="btn btn-default" id="btndestroynote" href="/notes/destroy?key=<%= notekey %>" role="button">Delete</a> <a class="btn btn-default" id="btneditnote" href="/notes/edit?key=<%= notekey %>" role="button">Edit</a>
In both cases, we added the id
attributes. In writing the test code, the id
attribute made it easier to check for or click on these buttons.
In test-compose/notesui
, create a file named uitest.js
containing the following:
var notes = 'http://localhost:3000'; casper.test.begin('Can login to Notes application', function suite(test) { casper.start(notes, function() { test.assertTitle("Notes"); test.assertExists('a#btnloginlocal', "Login button is found"); this.click("a#btnloginlocal"); }); .. casper.run(function() { test.done(); }); });
The casper.test.begin
function contains a block of tests. You see that the callback is given a test
object that has methods useful for testing. Inside this block, certain casper
methods are called, the result of which is to create a queue of test instructions to perform. That queue is executed when casper.run
is called.
The casper.start
function must be called, well, at the start of the script. You give it a URL, as shown here, and you can optionally pass in a callback function.
The casper.run
function must be called at the end of the script. In between these two, you'll write steps of the test scenario.
In callback functions, you can make the test
assertions or you can call other casper
functions that are available on this
. In this case, the browser is on the Notes home page and we're checking a couple things to ensure that this is where the browser is located and that the browser is not logged in.
For example, because of the change we made to notes/views/pageHeader.ejs
, the presence of #btnloginlocal
is a sure indication that Notes is not logged in. If it were logged in, that button would not be visible and #btnlogout
would be present instead.
When you take an action that causes an asynchronous request and response, it's necessary to start a new navigation step, that is the following:
casper.then(function() { test.assertHttpStatus(200); test.assertUrlMatch(/users/login/, 'should be on /users/login'); this.fill('form', { username: "me", password: "w0rd" }); this.click('button[type="submit"]'); });
We have just told the browser to click on the Login button. We should then end up on the /users/login
page. We then put login parameters into the form inputs, and click on the Submit button.
This step is why we needed the setupuser
script to be run:
casper.waitForSelector('#btnlogout', function() { test.assertHttpStatus(200); test.assertTitle("Notes"); test.assertExists('a#btnlogout', "logout button is found"); test.assertExists('a#btnaddnote', "Add Note button is found"); this.click("#btnaddnote"); });
Once we click on the login form, we need to wait for the screen to refresh. It should, of course, navigate to the Notes home page. The waitForSelector
method waits until an element with that selector is available on the page.
In CasperJS, many functions take selector parameters. These are used to select elements in the DOM of the current page. By default, it takes CSS3 selector strings, but you can also use XPath selectors.
The final step is to click on the Add Note button.
casper.waitForUrl(/notes/add/, function() { test.assertHttpStatus(200); test.assertTitle("Add a Note"); test.assertField("docreate", "create"); this.fill('form', { notekey: 'testkey', title: 'Test Note Title', body: 'Test Note Body with various textual delights' }); this.click('button[type="submit"]'); });
And, of course, the response to that is to go to /notes/add
. We again check a few things to make sure that the browser has gone to the correct page. We then fill in the entry form with a dummy note and click on the Submit button:
casper.waitForUrl(/notes/view/, function() { test.assertHttpStatus(200); test.assertTitle("Test Note Title"); test.assertSelectorHasText("p#notebody", 'Test Note Body with various textual delights'); this.click('#btndestroynote'); });
The browser should of course go to /notes/view
. We're checking a few parameters on the screen to verify this. We then click on the Destroy button:
casper.waitForUrl(/notes/destroy/, function() { test.assertHttpStatus(200); test.assertTitle("Test Note Title"); test.assertField("notekey", "testkey"); this.click('input[type="submit"]'); });
Once the browser gets to /notes/destroy
, we check that the page indeed has all the right elements. We can click on the Submit button to verify that the note should be deleted:
casper.waitForUrl(notes, function() { test.assertHttpStatus(200); test.assertTitle("Notes"); test.assertExists('a#btnlogout', "logout button is found"); this.click("#btnlogout"); });
The browser should again be on the home page. Let's now verify the ability to log out by clicking on the Logout button:
casper.waitForUrl(notes, function() { test.assertHttpStatus(200); test.assertTitle("Notes"); test.assertExists('a#btnloginlocal', "Login button is found"); });
The browser should again be on the home page, but logged out. We distinguish between being logged-in and logged-out by which buttons are present.
Now that you have the test entered, we can run it. Looking at run.sh
, these steps will run just the UI test:
docker-compose stop docker-compose up --build --force-recreate -d docker exec -it notesapp-test npm install -g [email protected] [email protected] docker exec -it userauth-test npm run setupuser docker exec -it notesapp-test npm run test-docker-ui
The last step of which will look like this:
$ docker exec -it notesapp-test npm run test-docker-ui npm info it worked if it ends with ok npm info using [email protected] npm info using [email protected] npm info lifecycle [email protected]~pretest-docker-ui: [email protected] npm info lifecycle [email protected]~test-docker-ui: [email protected] > [email protected] test-docker-ui /usr/src/app > cd notesui-test && casperjs test uitest.js Test file: uitest.js # Can login to Notes application PASS Can login to Notes application (5 tests) PASS Page title is: "Notes" PASS Login button is found PASS HTTP status code is: 200 PASS should be on /users/login PASS HTTP status code is: 200 PASS Page title is: "Notes" PASS logout button is found PASS Add Note button is found PASS HTTP status code is: 200 PASS Page title is: "Add a Note" PASS "docreate" input field has the value "create" PASS HTTP status code is: 200 PASS Page title is: "Test Note Title" PASS Find "Test Note Body with various textual delights" within the selector "p#notebody" PASS HTTP status code is: 200 PASS Page title is: "Test Note Title" PASS "notekey" input field has the value "testkey" PASS HTTP status code is: 200 PASS Page title is: "Notes" PASS logout button is found PASS HTTP status code is: 200 PASS Page title is: "Notes" PASS Login button is found PASS 23 tests executed in 88.227s, 23 passed, 0 failed, 0 dubious, 0 skipped.
While this report output looks cool, you probably want to generate a test results data file so your continuous build system (Jenkins, et al.) can display a green or red dot on the dashboard as appropriate. CasperJS supports outputting XUnit results as follows:
"test-docker-ui": "cd notesui-test && casperjs test uitest.js --xunit=/reports/notesui.xml"
Earlier we configured Mocha to output JSON that theoretically we can use to generate a test results report. In this case XUnit test results can be used the same way.