Thursday 21 January 2021

Listening to the console log of a page loaded with Puppeteer

G'day:

This is a follow-up from something I touched on yesterday ("Polishing my Vue / Puppeteer / Mocha / Chai testing some more"). In that exercise I was using Puppeteer to load a web page I was testing, and then pulling some DOM element values out and checking they matched expectations. The relevant bits of code are thus:

describe.only("Tests of githubProfiles page using github data", function () {
    let browser;
    let page;
    let expectedUserData;

    before("Load the page and test data", async function () {
        await loadTestPage();
        expectedUserData = await loadTestUserFromGithub();
    });

    let loadTestPage = async function () {
        browser = await puppeteer.launch( {args: ["--no-sandbox"]});
        page = await browser.newPage();

        await Promise.all([
            page.goto("http://webserver.backend/githubProfiles.html"),
            page.waitForNavigation()
        ]);
    }

    it("should have the expected person's name", async function () {
        let name = await page.$eval("#app>.card>.content>a.header", headerElement => headerElement.innerText);
        name.should.equal(expectedUserData.name);
    });

  • Load the page with Puppeteer
  • Example test checking the page's DOM

This code seemed to be running fine, and the tests were passing. As I was adding more code ot my Vue component on the client end, I suddenly found the tests started to fail. Sometimes. If I ran them ten times, they'd fail maybe three times. At the same time, if I was just hitting the page in the browser, it was working 100% of the time. Odd. I mean clearly I was doing something wrong, and I'm new to all this async code I'm using, so figured I was using values before they were available or something. But it seemed odd that this was only manifesting sometimes. The way the tests were failing was telling though:

1) Tests of githubProfiles page using github data
       should have the expected person's name:

      AssertionError: expected '' to equal 'Alex Kyriakidis'
      + expected - actual

      +Alex Kyriakidis

The values coming from the DOM were blank. And note that it's not a case of the DOM being wrong, because if that was the case, the tests would barf all the time, with something like this:

Error: Error: failed to find element matching selector "#app>.card>.content>a.header"

The relevant mark-up here is:

<a class="header" :href="pageUrl">{{name}}</a>

So {{name}} isn't getting its value sometimes.

I faffed around for a bit reading up on Vue components, and their lifecycle handlers in case created was not the right place to load the data or something like that, but the code seemed legit.

My JS debugging is not very sophisticated, and it's basically a matter of console.logging stuff and see what happens. I chucked a bunch of log calls in to see what happens:

created () {
    console.debug(`before get call [${this.username}]`);
    axios.get(
        `https://api.github.com/users/${this.username}`,
        {
            auth: {
                username: this.$route.query.GITHUB_PERSONAL_ACCESS_TOKEN
            }
        }
    )
    .then(response => {
        console.debug(`beginning of then [${response.data.name}]`);
        this.name = response.data.name;
		// [etc...]
        console.debug("end of then");
    });
    console.debug("after get call");
}

Along with some other ones around the place, these all did what I expected when I hit the page in the browser:

beginning of js
githubProfiles.js:46 before VueRouter
githubProfiles.js:52 before Vue
githubProfiles.js:23 before get call [hootlex]
githubProfiles.js:43 after get call
githubProfiles.js:63 end of js
githubProfiles.js:33 beginning of then [Alex Kyriakidis]
githubProfiles.js:41 end of then

I noted that the then call was being fulfilled after the mainline code had finished, but in my test I was waiting for the page to fully load, so I'd catered for this. Repeated from above:

await Promise.all([
    page.goto("http://webserver.backend/githubProfiles.html"),
    page.waitForNavigation()
]);

I ran my tests, and was not seeing anything in the console which momentarily bemused me. But then I was just "errr… duh, Cameron. That stuff is logging in the web page's console. Not Node's console from the test run". I'm really thick sometimes.

This flumoxed me for a bit as I wondered how the hell I was going to get telemetry out of the page that I was calling in the Puppeteer headless browser. Then it occurred to me that I would not be the first person to wonder this, so just RTFMed.

It's really easy! The Puppeteer Page object exposes event listeners one can hook into, and one of the events is console. Perfect. All I needed to do is put this into my test code:

page = await browser.newPage();

page.on("console", (log) => console.debug(`Log from client: [${log.text()}] `));

await Promise.all([
    page.goto("http://webserver.backend/githubProfiles.html"),
    page.waitForNavigation()
]);

Then when I ran my tests, I was console-logging the log entries made in the headless browser as they occurred. What I was seeing is:

  Tests of githubProfiles page using github data
Log from client: [beginning of js]
Log from client: [before VueRouter]
Log from client: [before Vue]
Log from client: [before get call [hootlex]]
Log from client: [after get call]
Log from client: [end of js]
    1) should have the expected person's name
    2) should have the expected person's github page URL
    3) should have the expected person's avatar
    4) should have the expected person's joining year
    5) should have the expected person's description
Log from client: [beginning of xxxxxx then [Alex Kyriakidis]]
Log from client: [end of then]
    6) should have the expected person's number of friends
     should have the expected person's friends URL


  1 passing (4s)
  6 failing

Note how the tests get underway before the then call takes place. And shortly after that, the tests start passing because by then the dynamic values have actually been loaded into the DOM. This is my problem! that page.waitForNavigation() is not waiting long enough! My first reaction was to blame Puppeteer, but I quickly realised that's daft and defensive of me, given this is the first time I've messed with this stuff, almost certainly I'm doing something wrong. Then it occurred to me that a page is navigable once the various asset files are loaded, but not necessarily when any code in them has run. Duh. I figured Puppeteer would have thought of this, so there'd be something else I could make it wait for. I googled around and found the docs for page.waitForNavigation, and indeed I needed to be doing this:

page.waitForNavigation({waitUntil: "networkidle0"})

After I did that, I found the tests still failing sometimes, but now due to a time out:

  Tests of githubProfiles page using github data
Log from client: [beginning of js]
Log from client: [before VueRouter]
Log from client: [before Vue]
Log from client: [before get call [hootlex]]
Log from client: [after get call]
Log from client: [end of js]
Log from client: [beginning of then [Alex Kyriakidis]]
Log from client: [end of then]
    1) "before all" hook: Load the page for "should have the expected person's name"


  0 passing (4s)
  1 failing

  1) Tests of githubProfiles page using github data
       "before all" hook: Load the page for "should have the expected person's name":
     Error: Timeout of 5000ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves. (/usr/share/fullstackExercise/tests/functional/public/GithubProfilesTest.js)

I had the time out set for five seconds, but now the tests are waiting for the client to finish its async call as well, I was just edging over that five second mark sometimes. So I just bumped it to 10 seconds, and thenceforth the tests all passed all the time. I've left the telemetry in for one last successful run here:

  Tests of githubProfiles page using github data
Log from client: [beginning of js]
Log from client: [before VueRouter]
Log from client: [before Vue]
Log from client: [before get call [hootlex]]
Log from client: [after get call]
Log from client: [end of js]
Log from client: [beginning of then [Alex Kyriakidis]]
Log from client: [end of then]
     should have the expected person's name
     should have the expected person's github page URL
     should have the expected person's avatar
     should have the expected person's joining year
     should have the expected person's description
     should have the expected person's number of friends
     should have the expected person's friends URL


  7 passing (5s)

OK so that was a bit of a newbie exercise, but I'm a noob so yer gonna get that. It was actually pretty fun working through it though. I'm really liking all this tooling I'm checking out ATM, so yer likely get a few more of these basic articles from me.

Righto.

--
Adam

Wednesday 20 January 2021

Polishing my Vue / Puppeteer / Mocha / Chai testing some more

G'day:
I should be writing the article to finish off that series about Docker / … / Vue.js etc, but whilst I was reading the docs and doing some online course (read: watching some videos) on Vue.js, I decided to TDD one of the exercises they were suggesting. The testing requirements were a bit tougher than I'd previously done with Mocha, and it took me longer to nail the testing of this than I would have liked. I figured it's worth working through it again, and jotting down some notes this time. Also I've read a bunch of blog articles instructing on Vue.js, and none of them say stuff like "implement your expectations as tests before you begin", so this is breaking that mould: if you have a dev task… always start by implementing tests for your expectations.

The exercise is part of the video Build a GitHub User Profile Component at vueschool.io. I've found their videos pretty handy, btw, and have been practising my testing whilst working through them in parallel to watching them.

The exercise is to take some flat example HTML and convert it into a Vue-implemented / data-driven solution. Here's the HTML:

<html>

<head>
  <link rel="stylesheet"
        href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css">
</head>

<body>

  <div id="app"
       class="ui container">
    <h1>GitHub Profiles</h1>
    <github-user-card username="hootlex"></github-user-card>

    <!-- Template for GitHub card -->
    <div class="ui card">
      <div class="image">
        <img src="https://semantic-ui.com/images/avatar2/large/kristy.png">
      </div>
      <div class="content">
        <a class="header">Kristy</a>
        <div class="meta">
          <span class="date">Joined in 2013</span>
        </div>
        <div class="description">
          Kristy is an art director living in New York.
        </div>
      </div>
      <div class="extra content">
        <a>
          <i class="user icon"></i>
          22 Friends
        </a>
      </div>
    </div>
  </div>

  <!-- Import Vue.js and axios -->
  <script src="https://unpkg.com/vue"></script>
  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>

  <!-- Your JavaScript Code :) -->
  <script>
    new Vue({
      el: '#app'
    })
  </script>
</body>

</html>

And that displays this sort of thing:

IE: some details from a Github user profile. NB: no idea whether Kirsty is a real person - I hope not - but it's the exampe vueschool gave. The ultimate object of the exercise is to provide the Github user name to the code and it will display that user's deets.

One could see this as a refactoring exercise. Before we can refactor our code, we need to have some green tests to guard that we don't break anything while we refactor. In the real world we'd already have these, but I don't in this case so I'll need to knock some together now. The initial "green" state of the code is that it displays placeholder information at specific places in the DOM. This is all we will test for to start with.

Having reviewed the mark-up, there's seven things we need to test for, best described by the test definitions themselves:

root@ed4374d9ac6a:/usr/share/fullstackExercise# cat tests/functional/public/GithubProfilesTest.js
let chai = require("chai");
let fail = chai.assert.fail;

describe.only("For now, just identify the tests we need to make green", function () {
    it("should have the expected person's name", function () {
        fail("not implemented");
    });

    it("should have the expected person's github page URL", function () {
        fail("not implemented");
    });

    it("should have the expected person's avatar", function () {
        fail("not implemented");
    });

    it("should have the expected person's joining year", function () {
        fail("not implemented");
    });

    it("should have the expected person's description", function () {
        fail("not implemented");
    });

    it("should have the expected person's number of friends", function () {
        fail("not implemented");
    });

    it("should have the expected person's friends URL", function () {
        fail("not implemented");
    });
});
root@ed4374d9ac6a:/usr/share/fullstackExercise# npm test

> fullstackexercise@2.6.0 test
> mocha tests/**/*.js



  For now, just identify the tests we need to make green
    1) should have the expected person's name
    2) should have the expected person's github page URL
    3) should have the expected person's avatar
    4) should have the expected person's joining year
    5) should have the expected person's description
    6) should have the expected person's number of friends
    7) should have the expected person's friends URL


  0 passing (5ms)
  7 failing

  1) For now, just identify the tests we need to make green
       should have the expected person's name:
     AssertionError: not implemented
      at Context.<anonymous> (tests/functional/public/GithubProfilesTest.js:6:9)
      at processImmediate (node:internal/timers:463:21)

[etc. elided for brevity]


npm ERR! code 7
npm ERR! path /usr/share/fullstackExercise
npm ERR! command failed
npm ERR! command sh -c mocha tests/**/*.js

npm ERR! A complete log of this run can be found in:
npm ERR!     /root/.npm/_logs/2021-01-18T18_11_48_274Z-debug.log
root@ed4374d9ac6a:/usr/share/fullstackExercise#

The tests run, they fail, but I have a list of requirements to fulfil, even if I have not yet defined what it is to fulfil them. I'll start doing that now. For the next iteration I am going to do these things:

  • make the actual HTTP request with Puppeteer;
  • identify where in the DOM I need to check values;
  • for now, test against a bad value. The failure message will help is check if we're checking the DOM correctly;
let puppeteer = require("puppeteer");

let chai = require("chai");
let should = chai.should();

describe.only("Tests of githubProfiles page using placeholder data", function () {

    let browser;
    let page;

    before("Load the page", async function () {
        this.timeout(5000);

        browser = await puppeteer.launch( {args: ["--no-sandbox"]});
        page = await browser.newPage();

        await Promise.all([
            page.goto("http://webserver.backend/githubProfiles.html"),
            page.waitForNavigation()
        ]);
    });

    after("Close down the browser", async function () {
        await page.close();
        await browser.close();
    });

    it("should have the expected person's name", async function () {
        let name = await page.$eval("#app>.card>.content>.header", headerElement => headerElement.innerText)
        name.should.equal("INSERT EXPECTED NAME VALUE HERE");
    });

    it("should have the expected person's github page URL", async function () {
        let linkHref = await page.$eval("#app>.card>.content>a.header", headerElement => headerElement.href);
        linkHref.should.equal("INSERT EXPECTED LINKHREF VALUE HERE");
    });

    it("should have the expected person's avatar", async function () {
        let avatar = await page.$eval("#app>.card>.image>img", avatarElement => avatarElement.src);
        avatar.should.equal("INSERT EXPECTED AVATAR SRC VALUE HERE");
    });

    it("should have the expected person's joining year", async function () {
        let joiningMessage = await page.$eval("#app>.card>.content>.meta>.date", joiningElement => joiningElement.innerText);
        joiningMessage.should.equal("INSERT EXPECTED JOINING MESSAGE VALUE HERE");
    });

    it("should have the expected person's description", async function () {
        let description = await page.$eval("#app>.card>.content>.description", descriptionElement => descriptionElement.innerText);
        description.should.equal("INSERT EXPECTED DESCRIPTION VALUE HERE");
    });

    it("should have the expected person's number of friends", async function () {
        let friendsText = await page.$eval("#app>.card>.extra.content>a", extraContentAnchorElement => extraContentAnchorElement.innerText);
        friendsText.should.equal("INSERT EXPECTED FRIENDS TEXT VALUE HERE");
    });

    it("should have the expected person's friends URL", async function () {
        let linkHref = await page.$eval("#app>.card>.extra.content>a", extraContentAnchorElement => extraContentAnchorElement.href);
        linkHref.should.equal("INSERT EXPECTED FRIENDS LINK HREF VALUE HERE");
    });
});

And the run confirms (mostly) that we're inspecting the correct bit of the DOM. I'll just include the AssertionErrors from each test here, cos the rest of the output is largely the same:

AssertionError: expected 'Kristy' to equal 'INSERT EXPECTED NAME VALUE HERE'
AssertionError: expected '' to equal 'INSERT EXPECTED LINKHREF VALUE HERE'
AssertionError: expected 'https://semantic-ui.com/images/avatar2/large/kristy.png' to equal 'INSERT EXPECTED AVATAR SRC VALUE HERE'
AssertionError: expected 'Joined in 2013' to equal 'INSERT EXPECTED JOINING MESSAGE VALUE HERE'
AssertionError: expected 'Kristy is an art director living in New York.' to equal 'INSERT EXPECTED DESCRIPTION VALUE HERE'
AssertionError: expected ' 22 Friends' to equal 'INSERT EXPECTED FRIENDS TEXT VALUE HERE'
AssertionError: expected '' to equal 'INSERT EXPECTED FRIENDS LINK HREF VALUE HERE'

Cool it looks like I've mostly nailed the DOM selectors though: the correct values are being extracted by the tests. There's two that I'm not sure about though: the two link hrefs aren't actually in the mark-up, so their values are just blank. Well: all going well that's the reason, but I need to check that. A third thing to note is that there's a leading space in the friends text. Initially I thought this was a typo in the mark-up, but if we have a look, it's collapsed leading whitespace from the other content in the element I'm checking:

<div class="extra content">
    <a>
        <i class="user icon"></i>
        22 Friends
    </a>
</div>

That's legit, and we'll need our test to expect that. I've gone ahead and "fixed" the test mark-up to include the anchor tag hrefs (with dummy values):

<div class="content">
    <a class="header" href="GITHUB_PAGE_URL">Kristy</a>
    <div class="meta">
        <span class="date">Joined in 2013</span>
    </div>
    <div class="description">
        Kristy is an art director living in New York.
    </div>
</div>
<div class="extra content">
    <a href="GITHUB_FRIENDS_PAGE_URL">

Now to re-run the tests, and hopefully see those dummy values being compared to the test values. Again, I'll just include the AssertionExceptions:

AssertionError: expected 'Kristy' to equal 'INSERT EXPECTED NAME VALUE HERE'
AssertionError: expected 'http://webserver.backend/GITHUB_PAGE_URL' to equal 'INSERT EXPECTED LINKHREF VALUE HERE'
AssertionError: expected 'https://semantic-ui.com/images/avatar2/large/kristy.png' to equal 'INSERT EXPECTED AVATAR SRC VALUE HERE'
AssertionError: expected 'Joined in 2013' to equal 'INSERT EXPECTED JOINING MESSAGE VALUE HERE'
AssertionError: expected 'Kristy is an art director living in New York.' to equal 'INSERT EXPECTED DESCRIPTION VALUE HERE'
AssertionError: expected ' 22 Friends' to equal 'INSERT EXPECTED FRIENDS TEXT VALUE HERE'
AssertionError: expected 'http://webserver.backend/GITHUB_FRIENDS_PAGE_URL' to equal 'INSERT EXPECTED FRIENDS LINK HREF VALUE HERE'

OK, nice one. The tests are all good now: they are testing the correct elements in the DOM (except the whitespace one, I'll get to that), so I'm gonna update the tests to check for the actual placeholder values now. IE: make the tests pass for the boilerplate HTML.

let puppeteer = require("puppeteer");

let chai = require("chai");
chai.use(require("chai-string"));
let should = chai.should();

describe.only("Tests of githubProfiles page using placeholder data", function () {

    let browser;
    let page;

    let expectedUserData = {
        name : "Kristy",
        pageUrl : "http://webserver.backend/GITHUB_PAGE_URL",
        avatar : "https://semantic-ui.com/images/avatar2/large/kristy.png",
        joinedMessage : "Joined in 2013",
        description : "Kristy is an art director living in New York.",
        friends : "22 Friends",
        friendsPageUrl: "http://webserver.backend/GITHUB_FRIENDS_PAGE_URL"
    };

    // ...

    it("should have the expected person's name", async function () {
        let name = await page.$eval("#app>.card>.content>.header", headerElement => headerElement.innerText)
        name.should.equal(expectedUserData.name);
    });

    // ...
    
    it("should have the expected person's number of friends", async function () {
        let friendsText = await page.$eval("#app>.card>.extra.content>a", extraContentAnchorElement => extraContentAnchorElement.innerText);
        friendsText.should.containIgnoreSpaces(expectedUserData.friends);
    });

    // ...
});

I've elided a lot of code there due to it either being unchanged from before, or just the same as the examples I'm showing, and just focused on a coupla changes:

  • I've moved the expected values out into one object. This is partly to keep it all in one place, partly because I already know I'll be changing how I populate all that very soon.
  • I'm testing the actual values from the mark-up now. So the tests should be green.
  • I'm dealing with the extraneous whitespace.

When I run this:

> fullstackexercise@2.6.0 test
> mocha tests/**/*.js



  Tests of githubProfiles page using placeholder data
     should have the expected person's name
     should have the expected person's github page URL
     should have the expected person's avatar
     should have the expected person's joining year
     should have the expected person's description
     should have the expected person's number of friends
     should have the expected person's friends URL


  7 passing (3s)

root@ed4374d9ac6a:/usr/share/fullstackExercise#

Where are we now then? We have some tests that correctly check the correct part of the DOM of our test page. And all those tests pass. We can now start to do the actual work. The first thing we can do is a slight refactor: we can pull the inline mark-up out into a Vue template, and use the template's data object to furnish the template with the dynamic values. The desired result here is that no adjustments to tests should be necessary. First the mark-up file:

<html>
<head>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css">
</head>
<body>
<div id="app"
     class="ui container">
    <h1>GitHub Profiles</h1>
    <github-user-card username="hootlex"></github-user-card>
        
</div>

<script type="text/x-template" id="github-user-card-template">
    <div class="ui card">
        <div class="image">
            <img :src="avatar">
        </div>
        <div class="content">
            <a class="header" :href="pageUrl">{{name}}</a>
            <div class="meta">
                <span class="date">Joined in {{joinedYear}}</span>
            </div>
            <div class="description">
                {{description}}
            </div>
        </div>
        <div class="extra content">
            <a :href="friendsPageUrl">
                <i class="user icon"></i>
                {{friends}} Friends
            </a>
        </div>
    </div>
</script>

<script src="https://unpkg.com/vue"></script>
<script src="assets/scripts/githubProfiles.js"></script>
</body>
</html>

I've done a few things here:

  • Moved the mark-up for the "Github user card" into its own template.
  • And taken out the hard-coded values, which I will relocate into the JS application code (see below).
  • And I'm actually calling the JS application code.

That's actually the mark-up side of things complete now. Th JS code to support it is thus:

let githubUserCardComponent = {
    template : "#github-user-card-template",
    data : function () {
        return {
            name : "Kristy",
            pageUrl : "http://webserver.backend/GITHUB_PAGE_URL",
            avatar : "https://semantic-ui.com/images/avatar2/large/kristy.png",
            joinedYear : 2013,
            description : "Kristy is an art director living in New York.",
            friends : 22,
            friendsPageUrl: "http://webserver.backend/GITHUB_FRIENDS_PAGE_URL"
        };
    }
};

new Vue({
    el: '#app',
    components: {
        "github-user-card" : githubUserCardComponent
    }
});

Here we're just setting the data values expected by the template. It's still all hard-coded values for now. To check we've not messed anything up, we re-run the tests:

  Tests of githubProfiles page using placeholder data
     should have the expected person's name
     should have the expected person's github page URL
     should have the expected person's avatar
     should have the expected person's joining year
     should have the expected person's description
     should have the expected person's number of friends
     should have the expected person's friends URL

Cool. That all works.

Now we are going to need to do a code change: we need to source the actual data for the user from Github, via its API. But before we do that, we need to update our tests to expect that. We can't have our tests start to break because we've changed the code. We need to update the tests to expect the changes, and then… well… let them break that way instead ;-)

Now in the normal scheme of things, to keep this purely a functional test, I would mock the data provider for the data so when testing I'd not be getting live Github data, I'd be getting "known" values from a mock, and test that the known values are handled correctly. But I don't yet know how to test Vue components separately, so I don't know how to go about mocking the request to Github. What I'm gonna do is turn this into an integration test, and the test will get the correct data from Github, and then check if the app is getting the same (so accordingly correct) data too. Currently the test has this:

let expectedUserData = {
    name : "Kristy",
    pageUrl : "http://webserver.backend/GITHUB_PAGE_URL",
    avatar : "https://semantic-ui.com/images/avatar2/large/kristy.png",
    joinedMessage : "Joined in 2013",
    description : "Kristy is an art director living in New York.",
    friends : "22 Friends",
    friendsPageUrl: "http://webserver.backend/GITHUB_FRIENDS_PAGE_URL"
};

We're gonna get rid of those stubbed values with "live" values from Github. For reference, this is the JSON returned from the API call we're using:

{
  "login": "adamcameron",
  "id": 2041977,
  "node_id": "MDQ6VXNlcjIwNDE5Nzc=",
  "avatar_url": "https://avatars3.githubusercontent.com/u/2041977?v=4",
  "gravatar_id": "",
  "url": "https://api.github.com/users/adamcameron",
  "html_url": "https://github.com/adamcameron",
  "followers_url": "https://api.github.com/users/adamcameron/followers",
  "following_url": "https://api.github.com/users/adamcameron/following{/other_user}",
  "gists_url": "https://api.github.com/users/adamcameron/gists{/gist_id}",
  "starred_url": "https://api.github.com/users/adamcameron/starred{/owner}{/repo}",
  "subscriptions_url": "https://api.github.com/users/adamcameron/subscriptions",
  "organizations_url": "https://api.github.com/users/adamcameron/orgs",
  "repos_url": "https://api.github.com/users/adamcameron/repos",
  "events_url": "https://api.github.com/users/adamcameron/events{/privacy}",
  "received_events_url": "https://api.github.com/users/adamcameron/received_events",
  "type": "User",
  "site_admin": false,
  "name": "Adam Cameron",
  "company": null,
  "blog": "http://blog.adamcameron.me/",
  "location": "London",
  "email": null,
  "hireable": null,
  "bio": null,
  "twitter_username": "adam_cameron",
  "public_repos": 21,
  "public_gists": 211,
  "followers": 27,
  "following": 2,
  "created_at": "2012-07-25T18:02:54Z",
  "updated_at": "2021-01-16T16:57:34Z"
}

We won't need most of that.

Now I'll go ahead and re-jig my test code to make this call, and use it as the basis for the values to test against:

describe.only("Tests of githubProfiles page using github data", function () {
    let browser;
    let page;
    let expectedUserData;

    before("Load the page", async function () {
        this.timeout(5000);

        await loadTestPage();
        expectedUserData = await loadTestUserFromGithub();
    });

Here I have pushed the assignment of expectedUserData into the before handler, and also extracted the implementation into its own function, given there's a lot more code now:

let loadTestUserFromGithub = async function () {
    let githubUserData = await new Promise((resolve, reject) => {
        let request = https.get(
            "https://api.github.com/users/hootlex",
            {
                auth: `username: ${process.env.GITHUB_PERSONAL_ACCESS_TOKEN}`,
                headers: {'user-agent': 'node.js'}
            }, response => {
                let rawResponseData = "";

                response.on("data", data => {
                    rawResponseData += data;
                }).on("end", () => {
                    resolve(JSON.parse(rawResponseData));
                }).on("error", error => {
                    reject(error.message);
                });
            }
        );
        request.end();
    });
    return {
        name : githubUserData.name,
        pageUrl : githubUserData.html_url,
        avatar : githubUserData.avatar_url,
        joinedYear : new Date(githubUserData.created_at).getFullYear(),
        description : githubUserData.bio ?? "",
        friends : githubUserData.followers,
        friendsPageUrl: githubUserData.html_url + "?tab=followers"
    };
}

That's less complicated than it looks. Using the native Node.js https library requires one makes the request easily enough, but then one needs to receive the response data in chunks and assemble it yerself via the data event handler. And then once it's done one can use it in the end handler. To get it back to the calling code where it can be put to use, one needs to wrap all this in a promise, resolving with the data in the end handler. I really do wish there was a OhForGoodnessSakeStopMessingAroundAndJustGiveMeTheResponse handler, which I imagine is what people would use, 99% of the time. Ah well.

It's also worth noting those config params I'm setting in there:

{
    auth: `username: ${process.env.GITHUB_PERSONAL_ACCESS_TOKEN}`,
    headers: {'user-agent': 'node.js'}
}

The Github API has rate-limiting on it, and one is only allowed 60 requests per hour unless one uses some sort of authentication. One can just us a personal access token to work around this restriction. It's secret information so I don't want it anywhere in my code, so I'm just setting an environment variable on my host machine, and passing that through to the Node.js container. From docker-compose.yml:

  node:
    build:
      context: ./node
    environment:
      - GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN}

And I'm passing a user agent there because the Github API requires one to.

That should all now "work", in that the tests will be broken, but it'll be testing the hard-coded Vue template data against the expected correct/live values from Github. AGain, I'll just show the AssertionExceptions here:

should have the expected person's name:
AssertionError: expected 'Kristy' to equal 'Alex Kyriakidis'

should have the expected person's github page URL:
AssertionError: expected 'http://webserver.backend/GITHUB_PAGE_URL' to equal 'https://github.com/hootlex'

should have the expected person's avatar:
AssertionError: expected 'https://semantic-ui.com/images/avatar2/large/kristy.png' to equal 'https://avatars0.githubusercontent.com/u/6147968?v=4'

should have the expected person's joining year:
AssertionError: expected 'Joined in 2013' to equal undefined

should have the expected person's description:
AssertionError: expected 'Kristy is an art director living in New York.' to equal 'Developer - Teacher - Author- Consultant'

should have the expected person's number of friends:
AssertionError: expected  22 Friends to contain 850 ignoring spaces

should have the expected person's friends URL:
AssertionError: expected 'http://webserver.backend/GITHUB_FRIENDS_PAGE_URL' to equal 'https://github.com/hootlex?tab=followers'

Argh. OK so the good news is that most of those are failing in the right way: they are testing the correct placeholder vs live values. But the one testing the joining year and the one testing the number of friends are failing for the wrong reasons:

Firstly the "should have the expected person's joining year" test has two problems:

    // test code
    it("should have the expected person's joining year", async function () {
        let joiningMessage = await page.$eval("#app>.card>.content>.meta>.date", joiningElement => joiningElement.innerText);
        joiningMessage.should.equal(expectedUserData.joinedMessage);
    });

    // test data
        return {
            name : githubUserData.name,
            pageUrl : githubUserData.html_url,
            avatar : githubUserData.avatar_url,
            joinedYear : new Date(githubUserData.created_at).getFullYear(),
            description : githubUserData.bio ?? "",
            friends : githubUserData.followers,
            friendsPageUrl: githubUserData.html_url + "?tab=followers"
        };

The test data is joinedYear (just the year), and the test is still comparing to the entire message: joinedMessage. So I'll update the test, thus:

it("should have the expected person's joining year", async function () {
    const expectedJoiningMessage = `Joined in ${expectedUserData.joinedYear}`;

    let joiningMessage = await page.$eval("#app>.card>.content>.meta>.date", joiningElement => joiningElement.innerText);
    joiningMessage.should.equal(expectedJoiningMessage);
});

And now this test actually passes because - by coincidence - the hard-coded year in the template matches the live one coming from Github. We can't have that so for now I'm changing the value in the template to be a different year. We can't be changing code if its test doesn't fail until before we make the changes to make the test pass.

The "should have the expected person's number of friends" is failing incorrectly for much the same reason as the previous one. Here's the relevant code:

    //test code
    it("should have the expected person's number of friends", async function () {
        let friendsText = await page.$eval("#app>.card>.extra.content>a", extraContentAnchorElement => extraContentAnchorElement.innerText);
        friendsText.should.containIgnoreSpaces(expectedUserData.friends);
    });

        // test data
        return {
            name : githubUserData.name,
            pageUrl : githubUserData.html_url,
            avatar : githubUserData.avatar_url,
            joinedYear : new Date(githubUserData.created_at).getFullYear(),
            description : githubUserData.bio ?? "",
            friends : githubUserData.followers,
            friendsPageUrl: githubUserData.html_url + "?tab=followers"
        };

Again in the test we're expecting the entire message - eg "" - to be in the value we're comparing, but obviously we're only getting the count back from Github. I'll change that in the same way as the previous one (I'll spare you the code, you get the idea). now those tests are failing in the correct way:

AssertionError: expected 'Joined in 2013_BREAK_ME' to equal 'Joined in 2013'
AssertionError: expected 22 Friends to contain 850 Friends ignoring spaces

Now we can update the app code to get the correct data.

Firstly we're gonna need to pay attn to the username value passed to the template in the parent mark-up:

<div id="app"
     class="ui container">
    <h1>GitHub Profiles</h1>
    <github-user-card username="hootlex"></github-user-card>
</div>

This is just a matter of adding a property to the template definition:

let githubUserCardComponent = {
    template : "#github-user-card-template",
    props : {
        username: {
            type: String,
            required: true
        }
    },

Now we can use this.username in the rest of the template code.

Next: a the moment all our data values are hard-coded sample data:

let githubUserCardComponent = {
    template : "#github-user-card-template",
    data : function () {
        return {
            name : "Kristy",
            pageUrl : "http://webserver.backend/GITHUB_PAGE_URL",
            avatar : "https://semantic-ui.com/images/avatar2/large/kristy.png",
            joinedYear : 2013,
            description : "Kristy is an art director living in New York.",
            friends : 22,
            friendsPageUrl: "http://webserver.backend/GITHUB_FRIENDS_PAGE_URL"
        };
    }
};

We're gonna null-out all those as we don't know what they will be when the template is first loaded. However, when the instance of the component is created, we can dart off to Github and get the relevant values.

let githubUserCardComponent = {
    // ...
    data : function () {
        return {
            name : null,
            pageUrl : null,
            avatar : null,
            joinedYear : null,
            description : null,
            friends : null,
            friendsPageUrl: null
        };
    },
    created () {
        axios.get(
            `https://api.github.com/users/${this.username}`,
            {
                auth: {
                    username: this.$route.query.GITHUB_PERSONAL_ACCESS_TOKEN
                }
            }
        )
        .then(response => {
            this.name = response.data.name;
            this.pageUrl = response.data.html_url;
            this.avatar = response.data.avatar_url;
            this.joinedYear = new Date(response.data.created_at).getFullYear();
            this.description = response.data.bio;
            this.friends = response.data.followers;
            this.friendsPageUrl = response.data.html_url + "?tab=followers";
        });
    }
};

There's a coupla things to note here. Firstly, just like in the test, I am passing a personal access token to the Github API so I don't get throttled by them. And, again, I don't want the actual token value to be in the code, so I am passing it on the URL. To get the value from the URL, I need to use the Vue Router (which I know nothing about other than what I found on Stack Overflow when I needed to get something from the query string). To use this I need to initialise the Vue app with a router:

let router = new VueRouter({
    mode: 'history',
    routes: []
});

new Vue({
    router,
    el: '#app',
    components: {
        "github-user-card" : githubUserCardComponent
    }
});

Then I can access my query string param as per the code above.

The other bit to look at in the created handler above is this Axios thing. It's just an HTTP lib that the tutorial suggested. It seems pretty slick and easier to use than the HTTPS library I was messing about with in the test code. It's relevant to note that Axios has an implementation for Node.js too, but I didn't know that when I wrote the tests. The code there works, I'll leave it.

Both the Vue Router and Axios libs need to be loaded in the main mark-up file too, obvs:

<script src="https://unpkg.com/vue"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://unpkg.com/vue-router"></script>
<script src="assets/scripts/githubProfiles.js"></script>
</body>
</html>

OK so in theory now on the client-side we are loading-in the data from Github, and this should match the same data the tests grabbed, and our tests should be green…

AssertionError: expected '' to equal 'Alex Kyriakidis'
AssertionError: expected '' to equal 'https://github.com/hootlex'
AssertionError: expected '' to equal 'https://avatars0.githubusercontent.com/u/6147968?v=4'
AssertionError: expected 'Joined in' to equal 'Joined in 2013'
AssertionError: expected '' to equal 'Developer - Teacher - Author- Consultant'
AssertionError: expected Friends to contain 850 Friends ignoring spaces
AssertionError: expected '' to equal 'https://github.com/hootlex?tab=followers'

Um… where are the client-side values? I checked in the browser, and it all seemed legit:

What's worse is that about 25% of the time, the tests were actually passing! It took me quite a while to work out what was going on, but finally it turned out that I needed a lesson in how to think asynchronously, and what the various processing stages are of a web page. I'll detail the troubleshooting in another article (maybe), but it boiled down to this line of code:

let loadTestPage = async function () {
    browser = await puppeteer.launch( {args: ["--no-sandbox"]});
    page = await browser.newPage();

    await Promise.all([
        page.goto("http://webserver.backend/githubProfiles.html"),
        page.waitForNavigation()
    ]);
}

When a web page loads, it's navigable as soon as the asset files (mark-up, CSS, JS etc) are loaded. But it does not wait for asynchronous data requests to complete before the page is considered ready for navigation. So that promise is fulfilled whilst the call to Github is probably still under way. Sometimes it was completing in time for the test code to check the values; most of the time it had not, so the values weren't there. I did not notice this when I first wrote this code because the call to Github wasn't in there then, so there was no problem. Anyhow, this was easily solved:

page.waitForNavigation({waitUntil: "networkidle0"})

After that the tests still failed sometimes, but this time it was because loading the test page and then the test code hitting Github itself was occasionally taking longer than the 5000ms timeout I currently had, so I upped that to 10000ms:

before("Load the page", async function () {
    this.timeout(10000);

And after those two tweaks:

Tests of githubProfiles page using github data
     should have the expected person's name
     should have the expected person's github page URL
     should have the expected person's avatar
     should have the expected person's joining year
     should have the expected person's description
     should have the expected person's number of friends
     should have the expected person's friends URL


  7 passing (5s)

And I repeated the tests dozens of times, and they always passed now, so I'm happy I've nailed the code there.

I actually continued on from here and updated the code to allow passing an override username value in the URL, but this article is already massive and this is the third day working on it, so I'm gonna end here. This was probably a tedious read, but the exercise for me in TDDing some Vue stuff was absolute gold. I still need to find out how to separate Vue components out into separate files so I can test them individually, and outside the context of a web page that is using them, but that's for another day.

Righto.

--
Adam

Friday 15 January 2021

Part 8: Testing a simple web page built with Vue.js using Mocha, Chai and Puppeteer

G'day:

Familiar boilerplate about how this is one in a series follows. Please note that I initially intended this entire exercise to be a single article, but by the time I had finished the first two sections, it was way too long for a single read, so I've split it into the following sections, each as their own article:

  1. Intro / Nginx
  2. PHP
  3. PHPUnit
  4. Tweaks I made to my Bash environment in my Docker containers
  5. MariaDB
  6. Installing Symfony
  7. Using Symfony
  8. Testing a simple web page built with Vue.js using Mocha, Chai and Puppeteer (this article)
  9. I mess up how I configure my Docker containers
  10. An article about moving files and changing configuration
  11. Setting up a Vue.js project and integrating some existing code into it
  12. Unit testing Vue.js components

As indicated: this is the eighth article in the series, and follows on from Part 7: Using Symfony. Unlike the previous articles in this series, it's reasonably stand-alone: it doesn't rely on any of the Nginx / PHP / MariaDB / Symfony stuff I've written about so far. I do continue building on my Docker setup, but in an isolated fashion. So I dunno if you'd specifically benefit from going back and reading the earlier articles in the series to contextualise this article. Also as indicated in earlier articles: I'm a noob with all this stuff so this is basically a log of me working out how to get things working, rather than any sort of informed tutorial on the subject. I've said that previous sentence at the beginning of most of the articles, but it is particularly true of my exposure to Vue.js and everything I touch on in this article. One can hardly even call it "exposure": I've heard of Vue.js, I know it exists, and I have the Vue.js homepage open in the adjacent tab. That is the entirety of my exposure. Prior to this exercise I had not even heard of Puppeteer, and have only messed with Mocha and Chai very very superficially (JavaScript TDD: getting Mocha tests running on Bamboo). But such a distinct lack of knowledge has never stopped me blathering about stuff before, and I'm too old to change my ways now, so here goes.

Firstly: some full disclosure. I am going to write this article in good TDD sequence: tests first, then the web page I'm testing. In reality I did the Vue-powered web page first and then went "erm… ain't no way I'm gonna write this up without also having tests for it". Also the Vue side of things was so simple to get a "dynamic" "G'day World" page working that I hardly had anything to write about. So I'm gonna start where I ought to have, with the question of "how the hell am I going to test this?"

That question might seem really daft. My G'day World webpage looks like this:

Basically there's three testable mark-up elements: a title, heading and a paragraph all of which have "G'day World" in them. My intended Vue.js version of this will differ only in that it has "G'day world via Vue" as its intended content. When testing the flat mark-up version of this, all I need to do is curl the page, and then use a DOM parser to locate the <title>, <h1> and first <p> elements, and check their innerText values, eg:

/** @coversNothing */
public function testGdayWorldHtmlReturnsExpectedContent()
{
    $expectedContent = "G'day world";


    $client = new Client([
        'base_uri' => 'http://webserver.backend/'
    ]);

    $response = $client->get('gdayWorld.html');

    $this->assertEquals(Response::HTTP_OK, $response->getStatusCode());

    $html = $response->getBody();
    $document = new \DOMDocument();
    $document->loadHTML($html);

    $xpathDocument = new \DOMXPath($document);

    $hasTitle = $xpathDocument->query('/html/head/title[text() = "' . $expectedContent . '"]');
    $this->assertCount(1, $hasTitle);

    $hasHeading = $xpathDocument->query('/html/body/h1[text() = "' . $expectedContent . '"]');
    $this->assertCount(1, $hasHeading);

    $hasContent = $xpathDocument->query('/html/body/p[text() = "' . $expectedContent . '"]');
    $this->assertCount(1, $hasContent);
}

This approach is no good for the Vue.js-driven version of this, because the $expectedContent won't be present in the mark-up: Vue.js will be swapping it in at runtime, dynamically. All the mark-up will have in it is some placeholder like {{message}}. I can't just test for that, because it does not test that the Vue code is actually running correctly.

My first task is to find out how people are testing dynamically-created mark-up documents these days. I just googled that. Most of the material specific to Vue-managed stuff revolves around testing Vue components. This is where I hope to get to (in the next and maybe final article in this series), but for now my first "G'day world via Vue" exercise will be based on the "Hello world" example in the Vue docs, and all be inline within one HTML document, eg:

<!DOCTYPE html>
<html>
<head>
  <title>My first Vue app</title>
  <script src="https://unpkg.com/vue"></script>
</head>
<body>
  <div id="app">
    {{ message }}
  </div>

  <script>
    var app = new Vue({
      el: '#app',
      data: {
        message: 'Hello Vue!'
      }
    })
  </script>
</body>
</html>

No Vue components yet. After a bit more googling I landed on Puppeteer seeming like the way to go to test this sort of thing. From their home page:

Sounds cool.

The first thing I need to do is to add a Node.js container into my ever-growing family of containers. My initial approach was with this sorta thing in the docker/node/Dockerfile:

FROM node
WORKDIR  /usr/share/fullstackExercise/
RUN npm install puppeteer
RUN npm install mocha
RUN npm install chai
RUN npm install chai-as-promised

This would work, but I recalled something about Node.js having a package.json file, similar to Composer's composer.json (I say similar... Composer is based on NPM after all). Anyhow this seemed like a better way to go so I created one of those (with help from NPM itself, which has a wizard to generate the baseline file, via npm init). I ended up with this:

{
  "name": "fullstackexercise",
  "description": "Creating a web site with Vue.js, Nginx, Symfony on PHP8 &amp; MariaDB running in Docker containers",
  "version": "2.6.0",
  "main": "index.js",
  "directories": {
    "test": "tests"
  },
  "devDependencies": {
    "chai": "^4.2.0",
    "mocha": "^8.2.1",
    "puppeteer": "^5.5.0",
    "chai-as-promised": "^7.1.1"
  },
  "scripts": {
    "test": "mocha tests/**/*.js"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/adamcameron/fullstackExercise.git"
  },
  "author": "",
  "license": "GPL-3.0-or-later",
  "bugs": {
    "url": "https://github.com/adamcameron/fullstackExercise/issues"
  },
  "homepage": "https://github.com/adamcameron/fullstackExercise#readme"
}

That's a mix of the stuff npm init created, and the equivalent values from composer.json. This means the Dockerfile just becomes:

FROM node
WORKDIR  /usr/share/fullstackExercise/
RUN npm install

And the relevant section from docker-compose.yml:

node:
  build:
    context: ./node
  volumes:
    - ..:/usr/share/fullstackExercise
    - ./node/root_home:/root
  stdin_open: true
  tty: true
  networks:
    - backend

There's nothing unfamilar here. This all installs "fine":

adam@DESKTOP-QV1A45U:/mnt/c/src/fullstackExercise/docker$ docker-compose up --build --detach
Creating network "docker_backend" with driver "bridge"
Building nginx
[...]
Successfully built 7cb155649c3b
Successfully tagged docker_nginx:latest
Building php-fpm
[...]
Successfully built 2d3e6ba6d177
Successfully tagged docker_php-fpm:latest
Building mariadb
[...]
Successfully built 1f05ad3e3ad3
Successfully tagged docker_mariadb:latest
Building node
[...]
Step 5/5 : RUN npm install
[...]
Successfully built c9e1f709c33e
Successfully tagged docker_node:latest
Creating docker_mariadb_1 ... done
Creating docker_node_1    ... done
Creating docker_php-fpm_1 ... done
Creating docker_nginx_1   ... done
adam@DESKTOP-QV1A45U:/mnt/c/src/fullstackExercise/docker$
adam@DESKTOP-QV1A45U:/mnt/c/src/fullstackExercise/docker$ docker exec --interactive --tty docker_node_1 /bin/bash
root@33992838c2fe:/usr/share/fullstackExercise# npm list
fullstackexercise@2.6.0 /usr/share/fullstackExercise
+-- chai-as-promised@7.1.1
+-- chai@4.2.0
+-- mocha@8.2.1
`-- puppeteer@5.5.0

root@33992838c2fe:/usr/share/fullstackExercise#
root@cd18a4f3f4d4:/usr/share/fullstackExercise# node -i
Welcome to Node.js v15.5.1.
Type ".help" for more information.
>

So far so good. I've grabbed some code from the Puppeteer getting started docs, and will give that a whirl:

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');
  await page.screenshot({path: 'example.png'});

  await browser.close();
})();

I don't wanna make screenshots of web pages, but it'll do for a start:

> const puppeteer = require('puppeteer');
undefined
>
> (async () => {
...   const browser = await puppeteer.launch();
...   const page = await browser.newPage();
...   await page.goto('https://example.com');
...   await page.screenshot({path: 'example.png'});
...
...   await browser.close();
... })();
Promise { <pending> }
> Uncaught Error: Failed to launch the browser process!
/usr/share/fullstackExercise/node_modules/puppeteer/.local-chromium/linux-818858/chrome-linux/chrome: error while loading shared libraries: libnss3.so: cannot open shared object file: No such file or directory


TROUBLESHOOTING: https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md

    at onClose (/usr/share/fullstackExercise/node_modules/puppeteer/lib/cjs/puppeteer/node/BrowserRunner.js:193:20)
>

Oh. That's not what I wanted to see. I googled / stack-overflow-ed this, and yeah it seems that Puppeteer makes some assumptions about what libs are installed, and I'll be short some. I saw various different lists of stuff it was missing, and different approaches to remedying it, but for starters I figured I'd just install that lib in my container, and see what happens next. I update docker/node/Dockerfile thus:

FROM node
RUN apt-get update
RUN apt-get install libnss3-dev --yes
WORKDIR  /usr/share/fullstackExercise/
RUN npm install

After that I dropped my containers, rebuilt them, and ran that code again. I'm not expecting it to work yet, I just want to see that Puppeteer stops complaining about that library being missing:

[...]
Promise { >pending< }
> Uncaught Error: Failed to launch the browser process!
/usr/share/fullstackExercise/node_modules/puppeteer/.local-chromium/linux-818858/chrome-linux/chrome: error while loading shared libraries: libatk-bridge-2.0.so.0: cannot open shared object file: No such file or directory

It's a different library this time, so I'm calling this an "interim success". I'll rinse and repeat until I stop getting missing lib errors, and report back in a minute. Or so…

OK, so after some trial and error, I nailed it down to these missing libraries:

FROM node
RUN apt-get update
RUN apt-get install libnss3-dev libatk-bridge2.0-dev libx11-xcb1 libdrm-dev libxkbcommon-dev libgtk-3-dev libasound2-dev --yes
WORKDIR  /usr/share/fullstackExercise/
RUN npm install

All I did to track them down is to copy and paste the reference to the lib in the error and search for them on Ubuntu Packages Search. Sometimes the library name was not an exact match with the reference mentioned in the error. Anyway, this lot works for me. Given all the stuff I had read on this listed different combinations of missing packages, I'm guessing it'll all be platform- and situation-specific, so don't treat that list as canonical. Righto, this is what happens now when I run the code:

Promise { <pending> }
> Uncaught Error: Failed to launch the browser process!
[0113/143914.607623:ERROR:zygote_host_impl_linux.cc(90)] Running as root without --no-sandbox is not supported. See https://crbug.com/638180.

OK, what? I googled about and found a work around for this on Stack Overflow: Running headless Chrome / Puppeteer with --no-sandbox. It just showed me that one can pass that arg to Puppeteer, adn that makes the problem go away.

Warning

This is just a workaround and is not secure. A proper fix would be to not be running my code as root. However as this is experimental code and will never see production or be exposed externally, I'm happy to just bodge it. This is not an exercise in showing people how to secure Docker containers, and I would never want to go down that path, as I'm not qualified to discuss such things.

The code in question is now this:

const browser = await puppeteer.launch({args: ['--no-sandbox']});

And when I run this, I sat there looking at this for a while:

[...]
... })();
Promise { <pending> }
>

Nothing. Then I realised I'm a div cos this code doesn't output anything to stdout, it writes a file. So if I look in the correct place for the output, things are better:

root@00b5711ba57f:/usr/share/fullstackExercise# ll example.png
-rwxrwxrwx 1 node node 19373 Jan 13 15:12 example.png*
root@00b5711ba57f:/usr/share/fullstackExercise#

And here it is:

Perfect!

What I want to get out of this though is inspecting the actual document, so I have further updated the code to be this:

const puppeteer = require('puppeteer');

(async () => {
    const browser = await puppeteer.launch({args: ['--no-sandbox']});
    const page = await browser.newPage();
    await page.goto('https://example.com');
    const title = await page.title();
    console.info(`The title is: ${title}`);
    await browser.close();
})();

Here I'm just grabbing the title and outputing it. This will be part of my first test, so it's a reasonable next step here. And it works:

... })();
Promise { <pending> }
> The title is: Example Domain

>

I'm now content that Puppeteer works, and I have some code that executes, but I need to get this stuff into some tests now.

I've got Mocha (test framework), Chai (assertion library) and chai-as-promised (promise-oriented assertion library for Chai) installed. I'm not going to do a tutorial on running Mocha tests here, I'm just gonna look at my code. Here's my first test:

let puppeteer = require('puppeteer');

let chai = require("chai");
let chaiAsPromised = require("chai-as-promised");
chai.use(chaiAsPromised);

let should = chai.should();

describe("Baseline test of vue.js working", function () {

    this.timeout(5000);

    it("should return the correct page title", async function () {
        let browser = await puppeteer.launch( {args: ["--no-sandbox"]});
        let page = await browser.newPage();
        await page.goto("http://webserver.backend/gdayWorld.html");

        await page.title().should.eventually.equal("G'day world");

        await page.close();
        await browser.close();
    });
});

Notes:

  • This top bit is all just pulling in all the libs I need to use in the test.
  • I had to put this timeout in, as I was getting a timeout error sometimes (see error below).
  • We've already discussed this lot, it's from the Puppeteer example code.
  • For now, I am running my tests against the flat-HTML version of the file. I'm doing this because I'm kinda testing my usage of the test framework for now, and I don't have the Vue.js-driven version of the file yet.
  • And here's the assertion version of the key line in the sample code. I like this fluent syntax.
  • The eventually bit is the key bit from chai-as-promised: it handles all the async / promise carry one, and once it's done, runs the assertion. Nice.
  • And some tear down stuff. Note that I will refactor some of this stuff out from being inline in the test in the next iteration. For now I'm largely just copying and pasting the example code into a test, and tweaking it so it'll run.

And run it indeed does!

root@00b5711ba57f:/usr/share/fullstackExercise# npm test

> fullstackexercise@2.6.0 test
> mocha tests/**/*.js



  Baseline test of vue.js working
     should return the correct page title (4026ms)


  1 passing (4s)

root@00b5711ba57f:/usr/share/fullstackExercise#

I like this thing that npm did for me when I created package.json. It asks me how to run my tests, and then writes a test script for me:

"scripts": {
  "test": "mocha tests/**/*.js"
},

This is how I can just go npm test to run my tests. Very mundane to seasoned Node.js users obviously, but I am just a noob.

Oh, that timeout error I was getting:

root@00b5711ba57f:/usr/share/fullstackExercise# npm test

> fullstackexercise@2.6.0 test
> mocha tests/**/*.js



  Baseline test of vue.js working
    1) should return the correct page title


  0 passing (2s)
  1 failing

  1) Baseline test of vue.js working
       should return the correct page title:
     Error: Timeout of 2000ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves. (/usr/share/fullstackExercise/tests/functional/public/GdayWorldViaVueTest.js)
      at listOnTimeout (node:internal/timers:556:17)
      at processTimers (node:internal/timers:499:7)



npm ERR! code 1
npm ERR! path /usr/share/fullstackExercise
npm ERR! command failed
npm ERR! command sh -c mocha tests/**/*.js

npm ERR! A complete log of this run can be found in:
npm ERR!     /root/.npm/_logs/2021-01-13T16_16_36_577Z-debug.log
root@00b5711ba57f:/usr/share/fullstackExercise#

So that's why I added that timeout. Initially I thought I was doing the async stuff wrong given the hinting it was giving me, but it turned out literally to be the case that the test was taking too long to run. I guess the HTTP request and document render takes time.

Brimming with confidence, I now set out do write my other tests, and at the same time refactor the test to be a bit more sensibly organised:

let puppeteer = require('puppeteer');

let chai = require("chai");
let chaiAsPromised = require("chai-as-promised");
chai.use(chaiAsPromised);
let should = chai.should();

describe("Baseline test of vue.js working", function () {
    let browser;
    let page;

    this.timeout(5000);

    const expectedText = "G'day world";

    before (async function () {
        browser = await puppeteer.launch( {args: ["--no-sandbox"]});
        page = await browser.newPage();

        await page.goto("http://webserver.backend/gdayWorld.html");
    });

    after (async function () {
        await page.close();
        await browser.close();
    });

    it("should return the correct page title", async function () {
        await page.title().should.eventually.equal(expectedText);
    });

    it("should return the correct page heading", async function () {
        let headingText = await page.$eval("h1", headingElement => headingElement.innerText);

        headingText.should.equal(expectedText);
    });

    it("should return the correct page content", async function () {
        let paragraphContent = await page.$eval("p", paragraphElement => paragraphElement.innerText);

        paragraphContent.should.equal(expectedText);
    });
});
  • I've shifted all the Puppeteer setup/teardown into the appropriate handler functions, so they're only in there once for all three tests.
  • This really makes the tests very simple and focused.
  • For the tests needing the content of an element, I'm using this $eval method which passes the element to the callback once it's finally fetched…
  • … this way we don't need the async-handling "should.eventually code, and just should is fine.

And when I run this:

root@00b5711ba57f:/usr/share/fullstackExercise# npm test

> fullstackexercise@2.6.0 test
> mocha tests/**/*.js



  Baseline test of vue.js working
     should return the correct page title
     should return the correct page heading
     should return the correct page content


     should return the correct page heading
  3 passing (3s)

root@00b5711ba57f:/usr/share/fullstackExercise#

Wonderful. Now I'm happy my tests are doing the right thing (on the wrong file), I'm ready to implement my Vue.js version of the page.

The mark-up I have come up with has a slight change from the flat HTML version (public/gdayWorld.htm). The new file is public/gdayWorldViaVue.html:

<!doctype html>

<html lang="en">
<head>
    <meta charset="utf-8">

    <title id="title">{{ message }}</title>
</head>

<body>
<div id="app">
    <h1>{{ message }}</h1>
    <p>{{ message }}</p>
</div>
</body>
</html>

The Vue objects need to be hung off an element via an id, hence that <div id="app"> being added to handle the heading and content. But I'm gonna need a second Vue object to deal with the <title>. I presume there's a way around this, but I've not got that far yet.

Before I add the Vue part of the code, I'm going to update my tests to hit this new file, and also expect the updated text values:

const expectedText = "G'day world via Vue";

before (async function () {
    browser = await puppeteer.launch( {args: ["--no-sandbox"]});
    page = await browser.newPage();

    await page.goto("http://webserver.backend/gdayWorldViaVue.html");
});

As Vue.js is not wired in to the new file yet, the tests will fail, because the mark-up just contains the placeholder text ({{ message }}), and Vue has not rendered the dynamic values in their place yet.

root@00b5711ba57f:/usr/share/fullstackExercise# npm test

> fullstackexercise@2.6.0 test
> mocha tests/**/*.js



  Baseline test of vue.js working
    1) should return the correct page title
    2) should return the correct page heading
    3) should return the correct page content


  0 passing (3s)
  3 failing

  1) Baseline test of vue.js working
       should return the correct page title:

      AssertionError: expected '{{ message }}' to equal 'G\'day world via Vue'
      + expected - actual

      -{{ message }}
      +G'day world via Vue

      at /usr/share/fullstackExercise/node_modules/chai-as-promised/lib/chai-as-promised.js:302:22
      at processTicksAndRejections (node:internal/process/task_queues:93:5)
      at async Context.<anonymous> (tests/functional/public/GdayWorldViaVueTest.js:29:9)

  2) Baseline test of vue.js working
       should return the correct page heading:

      AssertionError: expected '{{ message }}' to equal 'G\'day world via Vue'
      + expected - actual

      -{{ message }}
      +G'day world via Vue

      at Context.<anonymous> (tests/functional/public/GdayWorldViaVueTest.js:35:28)
      at processTicksAndRejections (node:internal/process/task_queues:93:5)

  3) Baseline test of vue.js working
       should return the correct page content:

      AssertionError: expected '{{ message }}' to equal 'G\'day world via Vue'
      + expected - actual

      -{{ message }}
      +G'day world via Vue

      at Context.<anonymous> (tests/functional/public/GdayWorldViaVueTest.js:41:33)
      at processTicksAndRejections (node:internal/process/task_queues:93:5)



npm ERR! code 3
npm ERR! path /usr/share/fullstackExercise
npm ERR! command failed
npm ERR! command sh -c mocha tests/**/*.js

npm ERR! A complete log of this run can be found in:
npm ERR!     /root/.npm/_logs/2021-01-13T16_57_07_203Z-debug.log
root@00b5711ba57f:/usr/share/fullstackExercise#

This is good news: we have a failing test! Now we can do the work. The Vue.js code to do all the work is very very very simple (public/assets/scripts/gdayWorldViaVue.js):

let appData = {message: "G'day world via Vue"};
new Vue({el: '#title', data: appData});
new Vue({el: '#app', data: appData});

As per above, this hangs a Vue object off each of the #title and #app <div> elements, and binds the appData object with the message in it to them. That's it.

Back in the HTML file I need to load in Vue.js and my own script file:

</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script src="assets/scripts/gdayWorldViaVue.js"></script>
</body>
</html>

Now I can run my tests, and test that Vue.js is indeed loading the message into the title, heading and content elements:

root@00b5711ba57f:/usr/share/fullstackExercise# npm test

> fullstackexercise@2.6.0 test
> mocha tests/**/*.js



  Baseline test of vue.js working
     should return the correct page title
     should return the correct page heading
     should return the correct page content


  3 passing (3s)

root@00b5711ba57f:/usr/share/fullstackExercise#

Boom! It all works. That took a while, eh? (albeit longer to write it up and then to read about it, than to do the actual work).

For the sake of completeness, let's have a look at the page rendering in a browser. This is, after all, the "end user" requirement. Not simply that some tests pass ;-)

Perfect.

Now I admit that was an awful lot of work to test a full Vue-driven HTML document, and I won't be writing further tests like this (probably) because I'll be testing at Vue component level for the rest of the work. But I needed to get Node, Mocha and Chai all working, and - even though my initial Vue-driven test page was completely contrived - I still needed to test it.

In the next article I'll break this lot down into components and test those. All the time I will still have this test in place testing the end result expectations, and I think at least in the short term this is handy. But first I am gonna look at my green tests for a while, and have a beer. I'll be back on deck with this lot tomorrow.

Righto.

--
Adam