Monday, May 21, 2012

Getting jQuery into your Automated tests

If you use Javascript in your app you want to be testing the crap out of it. But rather than relying on Selenium to implicitly assert that this jQuery method must have worked because that div is visible, for example, I prefer to invite jQuery in the front door, into the drawing room, stick a sherry in its hand and allow it to get stuck in like a man.

The problem

I have a bunch of Javascript/jQuery (I'll just say JS from now on) services that I wrote to get data from Flickr, Twitter, Google+, etc. I use them on this very blog, for example, to embed photos (like the Frankenstein picture below), tweets, even Stack Overflow questions. They work brilliantly, except in Google Reader which doesn't allow extra (to Reader's own) JS, but that's a whole 'nuther blog post.

And being composed of software, sometimes they break or stop working. The APIs from those huge web brands themselves are unlikely to be the cause of the problem. Rather, it's usually that I've got a bug in my JS. Yep. The scripts themselves go to work by finding a tag in the blog's HTML - like the tweet example below - using the tweet's id to fetch it via the Twitter API. Tweet received, the JS pushes the tweet's text into that [Tweet goes here] space. To paraphrase Cody Lindley's 'jQuery Enlightenment', jQuery is all about "Find stuff do stuff".
<span data-tweet-id="179313185281675264">[Tweet goes here]</span>
So obviously I should test this HTML/JS/API interface. The problem is that since my JS services are spread unevenly among my blog posts (some posts only need tweets embedded, some need photos and Google Books, and so on) writing a test to hit the actual posts themselves would be tricky to say the least.

Flickr photo
FlickrFrankenstein, by twm1340. 'Can you help me write some automated BDD tests, little girl?'

The test

So, like Victor Frankenstein, I decided to make something from disparate components as a sort of experiment in serving mankind. His effort was called "the monster", but mine can be called "an aggregation of my JS services into one master page". Another distinction between the two is that mine is unlikely to harm children. The idea is by loading all my scripts on this page, and reproducing my repertoire of HTML placeholders for photos, tweets, SO questions, etc. there, I can get a lot of testing bang for my buck.

That page has a custom JS file attached as part of the test harness, which is kind of the brains of the whole operation. Much like the "Frankenpage" itself, it gathers together the various JS methods required to convert the HTML to tweets, photos, etc. So, once you script up a simple test using Selenium to browse to the test page, the custom JS page executes too.

BDD - why not?

For the purposes of clarity, I've chosen to use the excellent bddify to run the actual test. I was unsure as to whether taking a behaviour driven design approach made any sense if I'm the only one working on some code: BDD is probably considered over-the-top for a one-man scenario. But as Mehdi Khalili, the creator of bddify, explained when I asked him about this, even just for your own sake BDD can be a help keeping tabs on what the purpose of your test is.

So here's my test so far: I create a session id (Guid) at the start of the test which gets passed via the URL of the test web page to the JS once the Selenium FireFox driver cranks up the browser. That JS file then does most of the actual testing - that's the crux of this blog post. If there's an error with any of the jQuery attempts to resolve a tweet, photo, whatnot, the jQuery writes the session id to an ELMAH error log to enable the assertion in ThenThereShouldNotBeAnyErrorsFromTheJavascript() via some Web API calls.
namespace ConnemaraComTests.Services
{
    [TestFixture]
    [Story(
    AsA = "As an blogger",
    IWant = "I want to embed tweets, photos, and other social media stuff",
    SoThat = "So that my blog posts come alive!")]
    public class BlogJavascriptBddify : Base
    {
        string _sessionId;

        [Test]
        public void MyJavascriptServicesShouldBeWorkingOK()
        {
            this.Given(_ => GivenIHaveAFrankenpageWithAllMyJavascriptServices())
                .When(_ => WhenIBrowseToThatPage())
                .Then(_ => ThenThereShouldNotBeAnyErrorsFromTheJavascript())
                .Bddify();
        }

        void GivenIHaveAFrankenpageWithAllMyJavascriptServices()
        {
            _sessionId = Guid.NewGuid().ToString();
        }

        void WhenIBrowseToThatPage()
        {
            var driver = new FirefoxDriver();
            var _servicesTestUrl = string.Format("http://localhost/Services/Test?sessionId={0}", _sessionId);
            driver.Navigate().GoToUrl(_servicesTestUrl);

            // wait 5 seconds for any tweets, photos, async stuff to load
            Thread.Sleep(5000);
        }

        void ThenThereShouldNotBeAnyErrorsFromTheJavascript()
        {
            using (var context = new ConnemaraComContext())
            {
                Assert.IsFalse(context.ELMAH_Errors.Any(x => x.Message.StartsWith(_sessionId)));
            }
        }
    }
}
If you're unfamiliar with BDD-style tests, the "Given" method is like the "Arrange" in a regular test, the "When" like the "Act", and the "Then" like "Assert".

As an aside, speaking of Selenium, the docs at Selenium HQ are actually written with a dose of wit. Read this, from 'Brief History of The Selenium Project':
The Beijing Olympics mark China’s arrival as a global power, massive mortgage default in the United States triggers the worst international recession since the Great Depression, The Dark Knight is viewed by every human (twice), still reeling from the untimely loss of Heath Ledger. But the most important story of that year was the merging of Selenium and WebDriver...

Pros and cons

There are definitely pros and cons in this Frankenpage approach I'm outlining here, I'm aware of that. And doubtless it's nothing new. I'm not so experienced in automated testing, but I can't see anything glaringly wrong with it so far. In any case, I'm going to continue this in a second post because there's still a bit of detail to talk about, and no-one wants to read a crazy long post.