Nicholas Shanks
Nicholas Shanks

Reputation: 10981

Abstraction of functional test code in Node

I am having difficulty getting my head around functions that return functions that take an argument and apply functions to it that return more functions. Or something like that. My head hurts.

I have the following code:

const app = require('../app'); // an express.js instance
const request = require('supertest');
const cheerio = require('cheerio'); // html parser and jquery selector engine

const html = assertion => response => assertion(cheerio.load(response.text));
const node = selector => html($ => {
  const nodeset = $(selector);
  if (nodeset.length === 0)
    throw new Error('Expected "' + selector + '" to match at least 1 node, but it matched 0.');
});
const anyTextNode = (selector, value) => html($ => {
    const nodeset = $(selector);
    if (nodeset.filter((index, element) => element.children[0].type === 'text' && element.children[0].data === value).length === 0)
        throw new Error('Expected "' + selector + '" to match at least 1 node containing "' + value + '", but found 0.');
});

describe('index route', () => {
  describe('html representation', () => {
    it('should display a search form', (done) => {
      request(app).get('/')
        .expect(node('form>input[type=search][name=q]'))
        .expect(node('form>button[type=submit]'))
        .end(done);
    });
    it('should display a list of links', (done) => {
      request(app).get('/')
        .expect(anyTextNode('body>h2', 'Links'))
        .expect(node('ul>li>a[rel="http..."][href][href!=""]:not(:empty)'))
        .end(done);
    });

The first two expectations independently test that there is an input field and a button, and that each are children of a form, but it cannot check that the two elements are children of the same form. It would still pass if the DOM was of the form <form><input/></form></form><button/></form>.
The second two expectations check that there is a header and a list containing non-empty anchor elements with non-empty hrefs. It does not check that the list immediately follows the header, and if I put in a check for 'h2+ul' there is no way to prove that that is the same UL.

So, I want to add a new function that allows me to build compound tests: first obtain a nodelist from a given selector, then perform other jQuery-ish actions, so in the first example it would take a 'form' selector and then check that it had those two children. In the second example it would test for the existence of a H2 with given text node child, followed by a UL, and that the children of that UL are valid links.

The difficulty comes when I try to abstract the functions like node and anyTextNode (there are a quite few of them) to reduce duplication. They all call html() and pass to that a function that performs the checks. The function that the calls to html() return is then passed to the supertest expect() call, which invokes it with the response of the server I am testing. I can't see a good design pattern to use.

Upvotes: 3

Views: 166

Answers (2)

Teneff
Teneff

Reputation: 32148

Let's say you have an app

const app = express();

and it has a get method

app.get('/', method );

the responsibility of the controller should be to call (for example) template.render with some data

const template = require('../path/to/templateEngine');
const method = (req, res) => {
    const data = { a: 1 };
    res.send(template.render('path/to/desided/template.ext', data));
}

it's responsibility is not to return specific markup so in your test you can mock the template (since it's a dependency)

const template = require('../path/to/template');
template.render = // some spy jest.fn() or sinon.mock() or chai.spy()

describe('method', () => {
    it('should call the templateEngine.render to render the desired template', () => {
        request(app).get('/');
        // the template might even be an external dependency
        expect(template.render)
            .toBeCalledWith('path/to/desired/template.ext', { a: 1 });
    })
})

and then test the template separately;

const template = require('../path/to/templateEngine');
const cheerio = require('cheerio')

describe('template' => {
  describe('given some mock data', () => {
     const mockData = { /* some mock data */ }
     it('should render the template with mocked data', () => {
        expect(template.render('path/to/desired/template.ext', mockData))
     });
  });
  // and here in the template specification it will be much cleaner 
  // to use cheerio
  describe('form>button', () => {
    const $ = cheerio.load(template.render('path/to/desired/template.ext', mockData));
    it('to have exactly one submit button', () => {
      expect($('form>button[type=submit]')).toHaveLength(1);
    });
  });
});

Edit:

considering it's a template test you can write it something like this (not tested)

const cheerio = require('cheerio');
const request = require('supertest');
// template will be Promise<response>
const template = request(app).get('/')

describe('template', () => {
  // if $ is the result from cheerio.load
  // cherrioPromise will be Promise<$>
  // and you can use $.find('selector') to write your assertions
  const cheerioPromise = template.then(response => cheerio.load(response.text()))
  it("should display a search form", () => {
    // you should be able to return promise instead of calling done
    return cheerioPromise.then($ => {
      expect($.find('form>input[type=search][name=q]')).toHaveLength(1);
      expect($.find('form>button[type=submit]')).toHaveLength(1)
    })
  });
  it('should display a list of links', () => {
    return cheerioPromise.then($ => {
      expect($.find('ul>li>a[rel="http..."][href][href!=""]:not(:empty)')) /// some expectation
    });
  });
  it('should have h2 with text "Links"', () => {
    return cheerioPromise.then($ => {
      expect($.find('body.h2').text()).toEqual('Links');
    })
  })
});

Upvotes: 1

user3297291
user3297291

Reputation: 23372

Not sure if it solves all of your problems, but it might help find the right track:

By making node accept both its search context and a query, and return its result, it can be chained. This makes your error messages easier to work with, and you can split up your queries. E.g.:

const node = (parent, query) => {
  const child = parent.querySelector(query);

  if (!child) 
    throw `Node "${query}" does not exist in ${parent}`;

  return child;
}

node(node(document, "form"), "input"); // Instead of `node("form input")`

This calls the query for <form> first, and throws if there isn't any (logging that the actual "form" element is missing, giving you an idea where to look for). Only after the <form> has been found, the form is searched for an input.

The use-case for looking for multiple elements can be added by creating a nodes function:

const nodes = (parent, queries) => queries
    .map(q => node(parent, q));

Example

In the example below I've made three documents. Our test wants to assert that:

  • There's a form
  • The form has all of these:
    • A text input
    • A password input
    • A submit input

The first two divs contain faulty HTML. The last one passes the test.

const node = (parent, query) => {
  const child = parent.querySelector(query);
  
  if (!child) 
    throw `Node "${query}" does not exist in ${parent.id || parent}`;
    
  return child;
}

const nodes = (parent, queries) => {
  return queries
    .map(q => node(parent, q));
};



// Test containers:
[ "twoForms", "noForm", "correctForm" ]
  .map(id => document.getElementById(id))
  .forEach(parent => {
    try {
      nodes(
        node(parent, "form"),
        [
          "input[type=text]",
          "input[type=password]",
          "input[type=submit]"
        ]
      );
      console.log(`Wrapper ${parent.id} has a correct form`);
    } catch(err) { console.log(err); }
  });
div[id] { padding: .5em; border: 1px solid black; }
<div id="twoForms">
  Two forms:
  <form>
    <input type="text" placeholder="username">
    <input type="password" placeholder="password">
  </form>
  <form>
    <input type="submit" value="sign in">
  </form>
</div>

<div id="noForm">
  No forms:
  <input type="text" placeholder="username">
  <input type="password" placeholder="password">
  <input type="submit" value="sign in">
</div>

<div id="correctForm">
  Passing form:
  <form>
    <input type="text" placeholder="username">
    <input type="password" placeholder="password">
    <input type="submit" value="sign in">
  </form>
</div>

Upvotes: 1

Related Questions