Automatic testing of web forms with CapserJS (PhantomJS)
How many times have you realized your web forms stopped working some time ago? You lost your sales, maybe you’re spending some marketing budget to bring those customers to your website and all their inquiries ended up nowhere. You’re angry, and if it was for your client you have even more problems. Maybe it was a bug in your script, maybe server was down or maybe even everything was ok but mail was not delivered because mail server had an issue.
I had this many times and once I reached hundreds of web contact forms for 20 websites of a single client, it was time to solve it. I decided to build a tool which will behave as a regular browser, go through each form, submit (possibly with different scenarios) and monitor email of the client to make sure email is delivered. Tool should be executed automatically over night and send errors (if any) in email in the morning.
This is what I’ll cover in this post:
- Node.js an CasperJS/PhantomJS to the rescue
- Running CasperJS in Nodejs
- First CasperJS test script
- Running CasperJS tests from Nodejs.
- Making it dynamic (and configurable) using MongoDB
- Last but not the least - is email delivered to inbox?
- Wrapping it up
- Hosting
Node.js an CasperJS/PhantomJS to the rescue
I checked Selenium browser automation but wanted something simple which works through Nodejs. That’s how I found PhantomJS which is “headless WebKit scriptable with a JavaScript API. It has fast and native support for various web standards: DOM handling, CSS selector, JSON, Canvas, and SVG.”. That’s what I was looking for, a browser I can control with JavaScript to execute my test scripts.
To make it simpler, a bit more abstracted, CasperJS was the next tool: “CasperJS allows you to build full navigation scenarios using high-level functions and a straight forward interface to accomplish all sizes of tasks”. Another bonus of CasperJS is that besides PhantomJS (which is WebKit) it also supports SlimerJS which runs on top of Gecko, the browser engine of Mozzila Firefox. This enabled me to test different browsers using same test scripts.
Running CasperJS in Nodejs
This is not as easy as it might seem at start. WebKit and Node don’t run in the same environment so communication between the two is not trivial. I checked SpookyJS which makes it easier to drive CasperJS from Nodejs but at the end decided not to use it because running CasperJS as a subprocess call from Node (exec()
) was simple enough for what I need.
So, at the end my package.json
dependencies section was looking like this:
"dependencies": {
"basic-auth-connect": "^1.0.0",
"body-parser": "^1.15.2",
"casperjs": "^1.1.3",
"express": "^4.14.0",
"express-handlebars": "^3.0.0",
"mongodb": "^2.2.12",
"phantomjs-prebuilt": "^2.1.13"
}
It also has some other stuff like mongodb, express, etc. That’s for later.
First CasperJS test script
Since I wanted many test scenarios to be executed, I decide to put them in ./tests/
folder of my project. CasperJS comes with CLI so casperjs <testfile.js>
will run your test. This makes it easy to test your script before even wrapping Nodejs around it. My first test scenario was fairly simple, what I want to do is:
- open contact form page
- fill in the form with test data
- click submit button
- make sure resulting page reports success
For this, CasperJS test script looks similar to:
// open form page
casper.start("http://myweb.com/formurl", function(response) {
if (response === undefined || response.status === null || response.status >= 400) {
this.die('ERROR loading initial url')
}
})
// wait for form to be loaded and fill it
casper.waitForSelector(
'form.contactform', // CSS3 (or Xpath!) selector to find form on the page
function() {
try {
// pass an object with filed names and values to fill
this.fill('form.contactform', {name: "Formtester", email: "fomtester.myweb.com"}, false)
} catch (e) {
this.die('ERROR filling form fields')
}
},
function() {
this.die('TIMEOUT waiting for form to load')
}
)
// find and click submit button
casper.then(function() {
if (!casper.exists("input.submit")) { // CSS3 (or Xpath!) selector to find submit button on the page
this.die('ERROR, no submit button found.')
}
this.click("input.submit")
})
// wait for thankyou page
casper.waitForUrl(
new RegExp("thankyou.html"),
function() {},
function() {
console.log('current url:', this.getCurrentUrl())
this.capture('screenshot.png') //you can even capture screentshot of the errored page!
this.die('TIMEOUT waiting for thankyou page')
}
)
// check if thank you page reported succes
casper.then(function() {
const pageText = this.evaluate(function() {
return document.body.innerText
})
if (!new RegExp("thank you").test(pageText)) {
this.die('ERROR submitting form. Unexpected thankyou page message')
}
})
// return OK if all passed and exit
casper.run(function() {
this.echo('OK').exit()
})
Script is commented so should be pretty self-explanatory. It also reveals some cool stuff you can do with CasperJS and PhantomJS, like capture('screenshot.png')
which enables you to capture a screenshot of the page (or only part of it!). Basically, complete DOM is accessible, together with events you can monitor or emit (like mouse clicks). It’s obvious you can do much more with this setup than simple form testing!
Running CasperJS tests from Nodejs.
As mentioned earlier, I decided to use Nodejs subprocess. There are other options but this one was simple enough for what I needed. It’s just a matter of doing an exec()
call to casperjs
. I’ve created a testRunner.js
Nodejs module to make it reusable for more complex scenarios later. Core of this will be something like:
console.log('executing casper test')
exec('casperjs ./tests/myFirsttest.js',
{ env: { 'PATH': '/usr/bin:./node_modules/.bin/:/app/.heroku/node/bin' } },
function(err, stdout, stderr) {
if (err || stdout.trim() !== 'OK') {
console.log('ERROR. stdout was:' + stdout)
} else {
console.log('success')
}
}
)
Please note that I’m also passing env
to exec()
. This makes sure Nodejs finds CasperJS and PhantomJS executables, in my local environment but also in future Heroku environment where I can run this for free because it’s really simple app which runs only once per day, spending almost no resources.
Another thing to note here is that I’m reading stdout
from casperjs
at the end of execution. It is simple enough for what I need, but you could also subscribe to stdout/stderr of Nodejs subprocess and make CasperJS more verbose. This way you can achieve more complex scenarios.
Making it dynamic (and configurable) using MongoDB
Well, it’s not much of a work to execute single scenario for a single form, but I needed several scenarios for many forms. I’m not going to write all boilerplate here (will share complete project to GitHub soon), but I’ll mention most important parts in next steps on my list:
1. extract all scenario-specific data (like form url, form selector, form filed names with values, etc.) from first CasperJS script into JSON object. Example of the result:
{
"type": "myFirstTest.js",
"url" : "http://myweb.com",
"formSelector" : "form.contactform",
"formFill" : {
"name" : "FormTester TEST",
"phone" : "123456789",
"emailaddress" : "formtester@myweb.com",
"comment" : "TEST TEST"
},
"submitButtonSelector" : "input.submit",
"thankyouPageUrlRegex" : "/thankyou.html$",
"thankyouPageTextRegex" : "Thank you"
}
2. refactor test script to receive this JSON as input (to reconfigure test accordingly). This included passing some more options to Nodejs exec()
in my testRunner.js
:
exec(`casperjs ./tests/${test.type}.js --options='${encodeURIComponent(JSON.stringify(test.testOptions))}'`...
Note encodeURIComponent()
usage which is necessary if you have weird characters in your config (quite usual for multi-language sites).
3. store test(s) JSON to MongoDB for easy management of tests (and later test execution logs).
Here, I added some more properties for each test. In example _id
field which is MongoDB’s unique identifier. Nice thing about it is that it contains a timestamp too which I’ll use later to know when each test is executed, so I get both timestamp and unique identifier in the same field.
4. make runAllActiveTests()
Nodejs script to go through each test in MongoDB and execute it through testRunner
.
Apart from simple looping through MongoDB query result of all active tests, important thing to do here was to implement multiple scenarios. So note "type": "myFirstTest"
in JSON above. Each type
of test (or scenario) will have a corresponding CasperJS script in ./tests/
folder. This way, we can reuse same scripts for many tests/websites (changing only test config in DB) but can also make variations. In practice this is often the case with multi-language sites where single scenario (CasperJS test) works easily on different languages, but different sites (or completely different things we want to test on the same site) require some different steps in the test script.
Also, here we log execution status of each test to MongoDB, we will need it later for reporting.
Last but not the least - is email delivered to inbox?
It is very important to know if email is delivered. Often, website reports success but for many possible reasons email is not delivered. What I did is a simple API to Nodejs app which receives TestID, searches for it in DB and marks it’s status as “email received” or “success”. Now all we need to do is somehow instruct mail server to trigger that API call when email is received.
I simply love Google Apps and this is one of the reasons. It’s very simple to do it using Google Apps Script. Even if you/your client don’t use Gmail, you can simply tell their mail administrator to forward emails from formtester@myweb.com to your Gmail inbox of choice and deal with mail there. To make filtering on mail server easier, and to pass Test ID to mail server, we will append string [FormTester:<TestID>]
to any of the form fields during execution of CasperJS test (i.e. “comment” field). This text should be included in email sent by the form, thus visible to mail server to process it. Simple! And does not require any changes to the code in form submitting procedures of the website (a very important requirement)!
So, after you configured email forward to Gmail formtester account, remaining steps to make it work are:
- Create a label
formTester
in Gmail which will store form emails. - In Gmail, create filter for incoming mail that will move all mails having string
[FormTester:
into folder/label above. -
Create GApps script which will go through all mails labeled
formTester
, extractFormID
from each and call our API with it. Part of GApps script to call API:/**
- call FormTester web API and trigger confirmation action with passed testId */ function triggerApiConfirmation(testId) { var url = apiUrl + ‘/confirm-email-receive?id=’ + testId; var response = UrlFetchApp.fetch(url, {“method”: “GET”, “headers”: headers, “muteHttpExceptions”: true}); if (response == ‘OK’) { Logger.log(‘triggered API received confirmation with success, got: ‘ + response); } else { Logger.log(‘triggered API received confirmation but got error: ‘ + response); } return response; }
And loop to call it on each mail with formTester
label:
// GLOBAL SETTINGS (for all scripts)
var inboxLabel = GmailApp.getUserLabelByName('[FormTester]'); //label where formtester email comes to
var apiUrl = 'http://mywebapi.com'; //API root URL
var adminEmail = 'me@myweb.com'; //where to send system errors
var selfEmail = 'formtester@myweb.com'; //own email (where we send failed email)
var headers = {
"Authorization" : "Basic " + Utilities.base64Encode('apilogin:apipass')
};
/**
* go through FormTester label threads and detect emails with testId
*/
function monitorEmails() {
var threads = inboxLabel.getThreads();
//loop through threads
for (var t = 0; t < threads.length; t++) {
var emails = threads[t].getMessages();
//loop through messages
for (var i = 0; i < emails.length; i++) {
var email = emails[i];
if (email.isInTrash()) continue;
var regexResult = email.getPlainBody().match(/\[FormTester:([0-9a-f]{24})\]/);
if (regexResult && regexResult[1]) {
var testId = regexResult[1];
//trigger API call if TestId detected
Logger.log('FormTesterEmailRecognized containing ID=' + testId);
var response=triggerApiConfirmation(testId);
if (response == 'OK') {
email.moveToTrash();
} else {
email.forward(selfEmail, {"subject": "[FormTesterError:"+testId+"] - response:"+response});
email.moveToTrash();
}
} else {
email.forward(selfEmail, {"subject": "[FormTesterError:noIdFound]"});
email.moveToTrash();
Logger.log('email detected but TestId not found in it! Forwarding email to self.');
}
}
}
}
Wrapping it up
Again, no boilerplate in this article. GitHub project will soon be available where you’ll be able to see the complete code. Here is just a simple list of tasks:
- Create 2 more scripts in GApps (and corresponding API calls in our Nodejs app): 1) script to start execution of all active tests, 2) script to get statuses of all tests and email reports
- Put all GApps scripts into scheduler so they’re executed over night, in this order: 1) execute all tests, 2) execute email monitor/parser, 3) get statuses and email report
- Build some frontend to manually see test statuses.
Example of mine:
Hosting
As mentioned above I used Heroku to host this project. Since service is mostly needed only couple of times over night you will spend almost none Dyno hours there which makes it a free hosting for smaller projects. It’s easy to upgrade later for more serious tests. Of course like usual with Heroku, server management is none and deployment is a breeze, just link GitHub branch with Heroku and whatever you push there gets published. Or you can use one of many other equally simple deployment methods.
Feel free to leave me a comment or suggestion.
Leave a Comment