Deno Diary logo

Craig's Deno Diary


2022-08-21

7 min read

End-to-end test a Deno webapp using deno-puppeteer

The deno-puppeteer library -- created by Deno team member Luca Casonato -- is a Deno-compatible port of the puppeteer Node (npm) library. The library has a number of uses including generating app screenshots and automating form submissions. We are using it here to do end-to-end (e2e) testing of a Deno webapp. The code for this blog post can be found here.

App Setup

Although puppeteer and deno-puppeteer can be used to e2e test any webapp, we are going to test the default Fresh app created with this command-line (assuming Deno has been installed):

deno run -A -r https://fresh.deno.dev using_deno-puppeteer

The new project will be found in the using_deno-puppeteer folder. Moving into that directory, the app can be started in that folder:

deno task start

The resulting page at http://localhost:8000 looks like this: Fresh starter page

Using deno-puppeteer for e2e testing

Imports

A puppeteer.test.ts file is created to hold the tests. Inside that file, the deno-puppeteer library needs to be imported:

import puppeteer, {
  Browser,
  Page,
} from "https://deno.land/x/puppeteer@14.1.1/mod.ts";

The Browser and Page classes are used in type annotations, while puppeteer does the library's work.

We are going to use the bdd library in the deno_std testing module to run our puppeteer e2e tests. That library includes the familiar functions describe, beforeAll, afterAll, beforeEach, afterEach and it imported like this:

import {
  afterAll,
  beforeAll,
  describe,
  it,
} from "https://deno.land/std@0.151.0/testing/bdd.ts";

We will also need assert functions found in the deno_std testing module to verify test results:

import {
  assert,
  assertEquals,
  fail,
} from "https://deno.land/std@0.151.0/testing/asserts.ts";

Finally, we need to add the readlines function from the standard io library to capture subprocess stdout messages in a helper function (see below).

import { readLines } from "https://deno.land/std@0.151.0/io/mod.ts";

Test setup and cleanup

Behavior-driven development test libraries (like the popular Jest lib) include a describe function to wrap a test suite and it for test functions. Also included are beforeAll and afterAll functions used to initialize and cleanup test resources. These functions run before and after all tests are run. There are also beforeEach and afterEach functions to allow code to be run before and after each test runs (not used here).

Our puppeteer tests have app server (server), Browser and Page objects that need to be declared at the top of the describe block since they are used in the `before' and 'after' functions.

describe("e2e tests using puppeteer: ", () => {
  let server: { close: () => void };
  let browser: Browser;
  let page: Page;

  beforeAll(async () => {
    server = await startAppServer();
    browser = await startBrowser();
    page = await browser.newPage();
    await page.setViewport({ width: 400, height: 200 });
  });

  afterAll(async () => {
    await page?.close();
    await browser?.close();
    await server?.close();
  });

  // test functions next. . .
});

The app server, browser and page objects are used in all tests, so they are initialized in the beforeAll function and closed in the afterAll function. The close calls prevents the occurrence of 'leaking resources' errors. Make sure that the page object is always closed before the browser object.

Note that the server, browser and page variables need type annotations to avoid TypeScript 'implicit any' errors.

Helper functions

The startAppServer and startBrowser functions abstract the mechanics of starting the Fresh app server that serves the webapp and the puppeteer browser which we will run in headless mode to allow the tests to run in a CI/CD environment.

/**
 * Run the Fresh server locally.
 *
 * @returns {{close: () => void}}: A handle to the server allowing closing of the server sub-process and
 * stdout/stderr within that.
 */
export async function startAppServer(): Promise<{ close: () => void }> {
  const serverProcess = Deno.run({
    // Fresh command line without the wait flag
    cmd: [Deno.execPath(), "run", "-A", "dev.ts"],
    cwd: Deno.cwd(),
    stdout: "piped",
    stderr: "piped",
  });
  console.log("Waiting for server to start...");
  //  Display some stdout messages.
  for await (const line of readLines(serverProcess.stdout)) {
    if (line.includes("Listening on http")) {
      console.log(line);
      break;
    }
  }
  return {
    async close() {
      await serverProcess.stdout.close();
      await serverProcess.stderr.close();
      await serverProcess.close();
    },
  };
}
/**
 * Start the puppeteer browser in headless mode
 *
 * @returns {Promise<Browser>}: Resolves to a puppeteer Browser instance
 */
export async function startBrowser(): Promise<Browser> {
  const browser: Browser = await puppeteer.launch({
    headless: true,
    args: ["--no-sandbox"],
  });
  return browser;
}

Test functions

Individual tests are found in it functions:

it({
  name: "should display welcome message",
  fn: async () => {
    await page.goto("http://localhost:8000/", {
      waitUntil: "networkidle2",
    });
    const selection = await page.waitForSelector("body > div > p");
    if (selection) {
      const text = await page?.evaluate(
        (element: HTMLElement) => element.textContent,
        selection,
      );
      assert(text?.startsWith("Welcome"));
    } else {
      fail(`ERROR: Selector not found`);
    }
  },
});

Once the page.goto function runs and navigates to the page to be tested. All tests in this file work on the same page. The page.waitForSelector is called with a CSS selector pointing to the DOM node on the page to be verified. In this case, we are verifying that a message that starts with 'Welcome' exists at that spot. We use the page.evaluate to obtain the text content (i.e. the message) from the HTMLElement.

The function of the counter component is tested in the next two test functions. We want to know that the increment (+1) and decrement (-1) buttons work properly.

it({
  name: "should decrement counter",
  fn: async () => {
    await page.goto("http://localhost:8000/", {
      waitUntil: "networkidle2",
    });
    // TODO: Get counter text for later decrement comparison
    // click -1 button
    await page.click("body > div > div > button:nth-child(2)");
    // check to see if counter has decremented
    const selection = await page.waitForSelector("body > div > div > p");
    if (selection) {
      const text = await page.evaluate(
        (element: HTMLElement) => element.textContent,
        selection,
      );
      // counter should be 2 now
      assertEquals(text, "2");
    } else {
      fail(`ERROR: Selector not found`);
    }
  },
});

it({
  name: "should increment counter",
  fn: async () => {
    await page.goto("http://localhost:8000/", {
      waitUntil: "networkidle2",
    });
    // TODO: Get counter text for later increment comparison
    // click +1 button
    await page.click("body > div > div > button:nth-child(3)");
    // check to see if counter has decremented
    const selection = await page.waitForSelector("body > div > div > p");
    if (selection) {
      const text = await page.evaluate(
        (element: HTMLElement) => element.textContent,
        selection,
      );
      // counter should be 4 now
      assertEquals(text, "4");
    } else {
      fail(`ERROR: Selector not found`);
    }
  },
});

The page.click function is used to click a button found at the DOM node defined by the CSS selector. Once the button is clicked, the DOM node containing the counter's value is found and verified to have been incremented or decremented (note that the Fresh app sets the counter's start value to 3).

Test output

The command line to run all tests (deno test --no-check-A) has been encapsulated in the deno.json file as task 'test'. You run that with deno task test. When that is done, the following output should appear:

$> deno task test
Warning deno task is unstable and may drastically change in the future
Task test deno test --no-check -A
running 1 test from ./puppeteer.test.ts
Puppeteer e2e testing...  ...
------- output -------
Waiting for server to start...
Listening on http://localhost:8000/
----- output end -----
  should display welcome message ... ok (1s)
  should decrement counter ... ok (1s)
  should increment counter ... ok (1s)
Puppeteer e2e testing...  ... ok (4s)

ok | 1 passed (3 steps) | 0 failed (5s)

Puppeteer API

There are a number of functions found in the Puppeteer API that I have not covered here. Here are few of the highlights:

  • Page.select to target a drop-down or select element.
  • Page.type to type text into an input box.
  • Page.waitForFunction waits for a function to finish evaluating.
  • Page.screenshot to take a screenshot of the current page.
  • Page.pdf to create a pdf of the current page.

Conclusion

As stated previously, complete source code for the example in this article is available here. Please note that the current versions of the libraries used in this example will change with time.

While I used Fresh for this work, I learned how to use deno-puppeteer when I created e2e tests for the Ultra full-stack Deno framework. I want to thank Omar Mashaal and James Edmonds for their assistance in this effort.

I'd also like to thank Kyle June for reviewing previous versions of this post.

The deno-puppeteer library can now be combined with Deno's build-in testing modules to allow the full testing pyramid to be done in a Deno-native way.

References