Needed to automate some tests on a Node.js application that require usage of browsers. Have been doing this setup for a while so taking notes to avoid reinventing the wheel every time.

My overal idea is to start all needed services with docker-compose rather than requiring to install them. I start one Webdriver Hub container, and one container per browser. In tests I then only point Webdriver JS library to the hub. Browsers need to access the application, but I did not want to start them in the host network as they would fight for ports. So instead since my app runs fine in Docker I actually start it as another docker-compose service for it and will have to refer to it under http://app hostname (port exposed for me to easily troubleshoot the application).

version: "3.4"

services:

  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - $PORT:$PORT

  hub:
    image: selenium/hub:$SELENIUM_VERSION
    ports:
      - 4444:4444

  chrome:
    image: selenium/node-chrome:$SELENIUM_VERSION
    environment:
      - SE_EVENT_BUS_HOST=hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443

  edge:
    image: selenium/node-edge:$SELENIUM_VERSION
    environment:
      - SE_EVENT_BUS_HOST=hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443

  firefox:
    image: selenium/node-firefox:$SELENIUM_VERSION
    environment:
      - SE_EVENT_BUS_HOST=hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443

The dockerfile is pretty straightforward and makes good use of layer caching to get fast builds:

FROM node:15.5.1-buster

EXPOSE 8081

COPY package.json package-lock.json ./
RUN npm install

COPY . .
CMD ["npm", "start"]

I use a .env file to keep track of configuration for both docker-compose (which reads it by default), the application and tests. For Node.js I use dotenv to automatically load them. Dotenv does not override values set by the command line so VAR=value npm start still allow me to override for one particular command without touching the file, hence putting mostly defaults there.

PORT=8081

SELENIUM_VERSION=4.0.0
BROWSER_NAME=firefox
APP_BASE_URL=http://app:8081
HUB_URL=http://localhost:4444/wd/hub

My tests run with Mocha, and Webdriver being slow to start/stop I create an instance and reuses for all tests thanks to a root hook plugin. Configuration of Webdriver capabilities is done via environment variables to choose the browser. I use modules, and expose Webdriver instance as a mutable export (exports are references thus modules importing it will get the live value). Not very good for running tests in parallel, but fine for now.

import { Builder } from 'selenium-webdriver'

let capabilities = {
  'browserName' : process.env.BROWSER_NAME,
  'resolution' : '1024x768',
  'headless': true
};

export const base_url = process.env.APP_BASE_URL;

export let driver;

export const mochaHooks = {
  beforeAll : async function() {
    driver = new Builder()
      .usingServer(process.env.HUB_URL)
      .withCapabilities(capabilities)
      .build();
  },
  afterAll : async function() {
    if (driver) {
      await driver.quit();
    }
  }
};

Tests then import this module (which conveniently exposes base url too, to avoid computing it from environnment variable in every single test) and just execute.

import assert from 'assert'
import { driver, base_url } from '../webdriver.js'

describe('app', () => {
  it('opens homepage', async () => {
    await driver.get(`${base_url}/`);
    let title = await driver.getTitle();
    assert.equal('My app!', title);
  });
});

The package.json file keep tracks of dependencies and defines commands for running tests.

{
  "scripts": {
    "start": "node --require dotenv/config server.js",
    "test": "mocha --require dotenv/config --require test/webdriver.js --recursive"
  },
  "type": "module",
  "devDependencies": {
    "mocha": "^8.3.2",
    "selenium-webdriver": "^4.0.0-beta.3"
  },
  "dependencies": {
    "dotenv": "^8.2.0"
  }
}

With this organization, I can easily run tests on different browsers without installing any of them or dealing with driver binaries. I could also quite easily run against providers like Browserstack instead of using my local hub (would need to add authentication in capabilities).

npm test
BROWSER_NAME=firefox npm test
BROWSER_NAME=chrome npm test
BROWSER_NAME=MicrosoftEdge npm test