Cucumber, Chai, and JSDOM

What Is This About?

The Earlier Problem

In my code from my spiral post I used document.getElementById as a check to make sure that I was passing in a valid div ID to p5.js and I decided to add some testing to make sure my validator was working as I expected. The problem was that when you run tests in node.js the document object doesn't exist. One way to get around this might be to use Selenium or some other browser-tester but that seemed like overkill.

My first idea was that I would just mock the document object using sinon since this is a fairly easy thing to do in python (maybe not mocking document, specifically, since that is a javascript thing, but mocking objects in a module is fairly easy) but I simply couldn't find anything that seemed to indicate that this was possible, only posts saying that sinon doesn't work that way.

So then I stumbled upon jsdom, which appeared to be what I wanted to mock the document. The problem was that it's more of a browser simulator not a means to replace objects so I still had to figure out a way to replace the document that my class was expecting with the jsdom document. In the other post where I first used jsdom I followed the pattern shown in this github repository where you stick the jsdom document into the global variable which makes it appear to be a global variable just like it is when you run the code in a browser.

global.document = dom.window.document;

Where dom is an instance of jsdom setup with some HTML to use for the testing. Amazingly this did seem to work…

The Newer Problem

Then I got to this post where I wanted to document getting jsdom to work and, even though I was using a simpler version of the previous post, I found that the tests were failing mysteriously (pretty much everything with javascript is mysterious to me). With a lot of troubleshooting, I managed to find out that instead of using the dom I was creating here it was still using the same one from the other testing. Except sometimes it was using the dom I defined here and these tests passed but then the other tests failed. I guess global means global in the truer sense, not just for the scope of a single set of tests, and there's some kind of race going on between the different pieces of code that are trying to set and use this global document. Oy.

So, then I went looking at the jsdom documentation (which is pretty much just a readme and all the "issues" people have posted), but there was one page on their wiki titled Don't Stuff jsdom Globals Onto the Node Global, which, I guess, means that I shouldn't have done what I did and all those Stack Overflow answers say to do. The page scolding all us silly people for using global did have a few examples of how they thought you should do it instead, so then I tried their solution of adding the javascript for my class to the jsdom object instead of using it in the cucumber.js test code. That way my class would have access to the document in their global space.

Easy-peasy. Well… this produced more mysterious failures except now it was in a different place. After running their examples in the Node REPL unsuccessfully I found this "issue" where it's explained that they don't support the <script type="module"> parameter (so you can't use ES6 imports like I do). Okay, so then I dumped the class definition to a string and added it directly, but no matter what I couldn't get jsdom to interpret any class I put in, although functions did work, so I came to the conclusion that they don't support javascript classes either.

I couldn't find anything specific about not supporting classes, but trying to search using terms like class and document brings up so much irrelevant stuff that it's maddening to even look for anything so there might have been some skimming fatigue that blinded me to any documents about it, if they existed.

But then, while fiddling with the Node REPL I found that defining the document before instantiating my class made it work, so I thought, okay, why am I trying so hard to do all this patching when I can just create the document object in my tests and then create the object under test? Well, the answer to that is - "because it doesn't work". For some reason the document object existed before and after creating my test object but it was always undefined within the class method I was testing. Mysterious.

In the end I ended up doing an ugly workaround which didn't really even require using jsdom, although I guess using it at least validates that I'm using the right method name… Anyway, here it is.

The Feature File

This is a Cucumber.js test so I'll create a simple feature file for a class that retrieves a document element and test the case where the ID is correct and the one where it is incorrect.

Feature: An Element Getter

Scenario: A Valid ID

Given an ElementGetter with the correct ID
When the element is retrieved
Then it is the expected element.

Scenario: The wrong ID.

Given an ElementGetter with the wrong ID
When the element is retrieved using the wrong ID
Then it throws an error.

The Software Under Test

Now the implementation. This is kind of a useless class, but this was supposed to be about how to get jsdom working so I can test code expecting a document.

The Class Declaration

This is the class I'm going to test.

nil

class ElementGetter {
  #element;

  constructor(element_id, document) {
    this.element_id = element_id;
    this.document = document;
  };

The constructor shows the main change I made to get it working - instead of using a global document I added it as an argument. I went through all that rigamarole trying to avoid this since it seemed like I was changing code just to test it, but now that I think about it, it's what I'd've done in python anyway, since I kind of don't like these "magic" objects that show up without being created or imported like they do in javascript.

The Get Element Method

I made a getter for the retrieved element. It probably would have been easier in this case if I didn't store it, since I could test both when the ID is correct and when it isn't with the same object, just by changing the this.element_id value, but it's a pattern I often use and it gave me the chance to test out javascript's Private Class Fields syntax. To be quite honest, I think using the pound sign (#) is kind of ugly - I prefer the underscore, and Pygments draws red boxes around the #- but at least I know about it.

The main value in using a getter here is that it can check that the element exists, since an invalid ID passed to getElementById will just return a null object rather thhan throw in error.

get element() {
  if (!this.#element) {
    this.#element = this.document.getElementById(this.element_id);
    if (!this.#element) {
      throw Error(`Unable to pull the element with ID '${this.element_id}'`);
    };
  };
  return this.#element;
};

The Step File

And now the test code.

Setting Up

Import the Test Libraries

import { expect } from "chai";
import { Given, When, Then } from "@cucumber/cucumber";
import { JSDOM } from "jsdom";

These are the libraries that I installed to support testing. Using jsdom instead of creating a mock was convenient, but I might have to watch what the overhead is if I make a lot of tests that use it.

Import the Software Under Test

Now I'll import the ElementGetter class that I defined above. It occurs to me that for a case like this where I don't actually use the code for anything other than testing I could have put it next to the tests, but I guess this is a better dress rehearsal for really using code in a post.

Note the extra step up the path (../) because this time I followed the cucumber example and put the steps in a folder named steps instead of in a file named steps.js, which might make it easier to organize in the future if I have more to test, but makes relative paths that much more painful.

import { ElementGetter } from "../../../../files/posts/cucumber-chai-and-jsdom/puller.js";

Setup JSDOM

Here's where I create the jsdom object with a div that the ElementGetter can get. I'm passing in the whole HTML string but in the documentation they sometimes just pass in the body.

const EXPECTED_ID = "expected-div";
const document = (new JSDOM(`<html>
<head></head>
<body>

  <div id='${EXPECTED_ID}'></div>

</body></html>`)).window.document;

The Tests

The Right ID

This is the first scenario where I expect the ElementGetter to successfully find the element. There's not a lot to test here, other than it doesn't crash.

Given("an ElementGetter with the correct ID", function() {
  this.puller = new ElementGetter(EXPECTED_ID, document);
});
When("the element is retrieved", function() {
  this.actual_element = this.puller.element;
});
Then("it is the expected element.", function() {
  expect(this.actual_element.id).to.equal(EXPECTED_ID);
});

The Wrong ID

This is the more interesting case where we give the ElementGetter an ID that doesn't match any element in the page.

Given("an ElementGetter with the wrong ID", function() {
  this.puller = new ElementGetter(EXPECTED_ID + "abc", document);
  });

Since I made a getter to retrieve the element, you can't pass it directly to chai to test - trying to pass this.puller.element to chai will trigger the error before chai gets it - so instead I'm using something I learned working with pytest. I'm creating a function that will retrieve the element and then passing the function to chai to test that it raises an error.

When("the element is retrieved using the wrong ID", function() {
  this.bad_call = function(){
    this.puller.element
  }
});
Then("it throws an error.", function() {
  expect(this.bad_call).to.throw(Error);
});

What Have We Learned?

I suppose the biggest lesson is that I shouldn't have tried so hard to fake the document object as a magic global object the way it normally is used and instead just gone for an explicit argument that gets passed to the class (or function) that needs it (which sort of follows the Dependency Injection Pattern). I also learned that jsdom is interesting but behind the ECMA standard, as were some of the other libraries I ran into in trying to solve different parts of this problem, so I have to either decide to not use ECMA 6 or not rely so much on these other libraries that don't use the current standards.