Selenium — Step-by-step guide

From zero to full reference: Selenium WebDriver 4, Mocha 11, Chai, Page Objects, API tests, explicit waits, and every npm script in the testflow-selenium suite.

E2E · API · POM · Mocha · Chai · testflow-selenium

What is Selenium?

Selenium WebDriver 4 drives a real browser for end-to-end testing. This project pairs it with Mocha 11, Chai, and ChromeChrome (headless in CI, headed locally).

  • Written in JavaScript (Node.js 20+)
  • Explicit waits via until.elementLocated / elementIsVisible
  • Global getHelpers() from tests/hooks.js — shared WebDriver per run
  • mochawesome HTML report locally; allure-mocha in CI
  • Page Objects in pages/ + publicRequest for API-only specs
Unlike in-browser runners such as Cypress, Selenium WebDriver controls the browser from outside the app — the same pattern used across most enterprise QA teams. See the official docs.

Why use Selenium?

Real browser E2E

Chrome WebDriver with headed (test:headed) or headless CI runs.

Stable locators

helpers.getByTestId on data-testid — centralized in Page Objects.

Mocha + Chai

describe / it structure with Chai expect assertions.

UI + API in one repo

Page Objects for UI; publicRequest for REST contracts without a browser.

Prerequisites

Environment

  • Node.js 20+
  • npm (or pnpm / yarn)
  • TestFlow app on port 5050
  • Editor: VS Code / Cursor (Selenium extension optional)
$ node --version
v20.x.x

$ docker run -p 5050:5050 qaschool/testflow:latest
# app available at localhost:5050

Environment setup by platform

Before cloning the repository, install Node.js 20+, Git, and Docker (to run TestFlow on port 5050). Project steps (npm install, browsers) are the same on every OS.

macOS

# Homebrew — https://brew.sh
brew install node@20 git
brew link --overwrite node@20
brew install --cask docker

node --version   # v20+
git --version
Homebrew nvm Docker Desktop

Windows

winget install OpenJS.NodeJS.LTS
winget install Git.Git
winget install Docker.DockerDesktop

node --version
winget PowerShell Git Bash

Installers: nodejs.org, Git for Windows, Docker Desktop.

Linux

# Debian / Ubuntu
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs git docker.io
sudo usermod -aG docker $USER

node --version
NodeSource install-deps Docker

After npm install: npx selenium install-deps (browser system libs). Fedora: dnf install nodejs git docker.

# TestFlow — same on macOS, Windows, and Linux
docker run --rm -p 5050:5050 qaschool/testflow:latest

Installation — testflow-selenium

  1. Clone the repository
    git clone https://github.com/lflucasferreira/testflow-selenium.git
    cd testflow-selenium
    Git GitHub
  2. Install dependencies
    npm install
    npm Node.js
  3. Chrome browser
    Install Google Chrome (or Chromium). WebDriver 4 manages the driver automatically via Selenium Manager.
    Chrome Selenium 4
  4. Run tests
    npm test or npm run test:headed
    Mocha npm scripts
Set BASE_URL, SELENIUM_DEMO_EMAIL, and SELENIUM_DEMO_PASSWORD via config/env.js or environment variables. See the project README for CI secrets.

Project structure

testflow-selenium/
├── config/env.js       # BASE_URL, credentials, headless, window size
├── docs/
│   ├── en/tests/       # EN walkthroughs (block-by-block)
│   └── pt/tests/       # PT walkthroughs
├── fixtures/           # credentials.json, team-member.json, schemas/
├── pages/              # Page Object Model (LoginPage, TeamPage, …)
├── support/
│   ├── driver.js       # Chrome WebDriver builder
│   ├── helpers.js      # getByTestId, waits, screenshots
│   ├── auth.js         # createAuthSession, visitWithSession
│   ├── api.js          # publicRequest helper
│   └── factories/      # wizard / user payload builders
├── tests/
│   ├── api/            # auth.api.test.js
│   ├── auth/ smoke/ dashboard/ team/ settings/
│   ├── components/ wizard/ activity/ advanced/ states/
├── tests/hooks.js      # Mocha global hooks (driver lifecycle)
├── scripts/wd-run.sh   # npm test wrapper (--grep, --spec)
├── .mocharc.cjs
└── package.json
  • pages/ — selectors, actions, and assertions per screen
  • support/auth.js — session via API or UI login
  • docs/en/tests/ — block-by-block explanation of each spec
  • tests/*.test.js — 11 specs · Mocha + Chai
  • data-testid — stable selector convention

Configuration — .mocharc.cjs

module.exports = {
  require: ['tests/hooks.js'],
  timeout: 120000,
  reporter: process.env.CI ? 'allure-mocha' : 'mochawesome',
  spec: 'tests/**/*.test.js',
}

tests/hooks.js creates one Chrome WebDriver per run and exposes getHelpers() globally. Locally, open the mochawesome HTML report under reports/; in CI, Allure results go to allure-results/. Filter by tag with npm run test:smoke or npm run test:regression (--grep '@smoke' / '@regression' via scripts/wd-run.sh).

Your first test

// tests/auth/login.test.js
const { expect } = require('chai')
const LoginPage = require('../../pages/LoginPage')
const { getConfig } = require('../../support/driver')

describe('Authentication @regression', function () {
  let loginPage

  beforeEach(async function () {
    const helpers = getHelpers()
    loginPage = new LoginPage(helpers)
    await loginPage.open()
  })

  it('logs in via UI and redirects to dashboard @smoke @critical', async function () {
    const config = getConfig()
    await loginPage.loginWith(config.demoEmail, config.demoPassword)
    await loginPage.shouldRedirectToDashboard()
  })
})

Convention: data-testid="login-email" → helpers.getByTestId('login-email') inside Page Objects.

Locators & navigation

Selenium API used in the TestFlow suite — selectors via data-testid.

APITestFlow usage
driver.get(url)Open login, dashboard, team…
helpers.getByTestId(id)Default selector — login-email, page-team
helpers.clickByTestId(id)Submit, sidebar links, modals
helpers.typeByTestId(id, text)Email, password, search fields
helpers.getTextByTestId(id)Visible labels, error messages
driver.findElements(By.css(...))tbody tr, table rows
helpers.executeScript(...)sessionStorage after login
helpers.readSessionStorage(key)Assert auth token persisted
driver.navigate().refresh()Reload after state change
helpers.takeScreenshot(name)Failure capture (hooks attach in CI)

Interactions

ActionTestFlow example
helpers.typeByTestId(...)Email, password, table search
element.clear()Clear search before new filter
helpers.clickByTestId(...)Submit, sidebar, pagination, modals
helpers.selectByTestId(...)Role filter, invite role, run suite
checkbox.click()Remember me, API toggle
Key.ESCAPEClose invite / new run modal
element.getText()Extract numeric KPI value
element.isDisplayed()Conditional — toast or result msg
helpers.executeScript(...)Checkbox covered by custom label
driver.actions().sendKeys(...)Keyboard shortcuts in UI

Assertions — Chai expect()

AssertionExample
expect(x).to.be.trueSubmit enabled, checkbox state
expect(text).to.include(...)Errors, greeting, row count label
expect(url).to.include(...)Post-login redirect
expect(value).to.equal(...)Placeholder, password type
expect(arr).to.have.length(n)Visible rows, activity items
expect(text).to.match(/regex/)Pass rate ^\d+%$
expect(res.status).to.equal(200)API contracts
expect(duration).to.be.below(ms)API response SLA
Pair explicit waits (waitForVisible, getByTestId) with Chai assertions — Selenium does not auto-retry assertions like Playwright expect(locator).

Shared helpers

support/helpers.js
getByTestId(testId)Wait + return visible element
clickByTestId · typeByTestIdCommon interactions
takeScreenshot(name)Saves PNG under screenshots/
readSessionStorage(key)Auth state after login
support/auth.js
createAuthSession(helpers)Programmatic auth via API
visitWithSession(helpers, path)Navigate already authenticated
clearSession(helpers)Reset before login specs
support/api.js
publicRequest(path, opts)HTTP helper for API specs (no browser)
loadFixture(name)Load JSON from fixtures/
fixtures/
credentials.jsonvalid / invalid email and password
team-member.jsonInvite modal payload
schemas/*.jsonAJV schemas for API responses

Selector strategy

This project uses data-testid exclusively on the TestFlow app — no component tests. See also docs/selector-strategy.md.

ContextPreferred locatorNotes
TestFlow elementshelpers.getByTestId(...)Primary strategy — stable, defined by the app
Modals, tabs (ARIA)helpers.getByTestId(...)Complements test IDs where ARIA roles exist
Internal state (CSS)page.locator('.class')Only when no test ID (.active, .spinner)
Shadow DOM / iframesframeLocator() + test IDSee advanced.test.js
Select2, SweetAlert2Vendor CSS.select2-container, .swal2-popup — last resort
Page Objects in pages/ are the single source of locators — never scatter selectors in specs.

Spec structure

describe('Team', () => {
  beforeEach(async ({ page, request }) => {
    await visitAuthenticated(page, request, '/web/team.html')
    await expect(new TeamPage(page).pageRoot()).toBeVisible()
  })

  describe('Search', () => {
    it('filters rows by member name', async ({ page }) => {
      const team = new TeamPage(page)
      await team.search('Alice')
      await team.shouldHaveRowCount(1)
    })
  })

  describe('Invite modal', () => { /* … */ })
})
nested describe beforeEach setup 1 scenario / it()

beforeEach vs before

// beforeEach — fresh state per test
beforeEach(async function () {
  const helpers = getHelpers()
  await createAuthSession(helpers)
  await helpers.driver.get(`${config.baseUrl}/web/dashboard.html`)
})
// before — shared API response (auth.api.test.js)
let res

before(async function () {
  res = await publicRequest('/api/auth/login', {
    method: 'POST',
    body: { email: config.demoEmail, password: config.demoPassword },
  })
})

it('returns status 200', function () {
  expect(res.status).to.equal(200)
})

before avoids N identical API calls; beforeEach isolates UI state between scenarios.

Data-driven tests

// tests/smoke/navigation.test.js
const PAGES = [
  { path: '/web/dashboard.html', testId: 'page-dashboard', title: 'Dashboard' },
  { path: '/web/team.html', testId: 'page-team', title: 'Team' },
  // settings, components, activity, wizard, states…
]

PAGES.forEach(({ path, testId, title }) => {
  it(`${title} page loads without error @smoke`, async function () {
    const helpers = getHelpers()
    await helpers.driver.get(`${config.baseUrl}${path}`)
    await helpers.getByTestId(testId)
    expect(await helpers.getTitle()).to.include(title)
  })
})

A loop generates N named tests — smoke covers all routes with a single pattern.

Explicit waits — stable UI checks

const { until, By } = require('selenium-webdriver')

// helpers.getByTestId wraps waitForVisible:
async waitForVisible(locator, timeout = TIMEOUTS.DEFAULT) {
  const element = await this.driver.wait(until.elementLocated(locator), timeout)
  await this.driver.wait(until.elementIsVisible(element), timeout)
  return element
}

// Page Object assertion — wait first, then read state
async shouldRedirectToDashboard() {
  await this.helpers.getByTestId('page-dashboard')
  const url = await this.helpers.getCurrentUrl()
  if (!url.includes('/web/dashboard.html')) {
    throw new Error(`Expected dashboard, got ${url}`)
  }
}

Always wait for elements before interacting. Implicit wait is set to 0 in support/driver.js — explicit waits in helpers keep failures diagnosable.

API testing — request fixture

// tests/api/auth.api.test.js
const ENDPOINT = '/api/auth/login'
const VALID = { email: DEMO_EMAIL, password: DEMO_PASSWORD }

describe('Valid credentials', () => {
  beforeAll(async ({ request }) => {
    res = await request.post(ENDPOINT, { data: VALID })
    body = await res.json()
  })

  it('returns status 200', () => expect(res.status()).toBe(200))
  it('body has token as non-empty string', () => {
    expect(typeof body.token).toBe('string')
  })
  it('responds within 2000ms', () => expect(duration).toBeLessThan(2000))
})

validateSchema — REST contract

export function validateSchema(obj, schema: Schema) {
  for (const [key, type] of Object.entries(schema)) {
    expect(obj).toHaveProperty(key)
    expect(typeof obj[key]).toBe(type)
  }
}

for (const user of body.users) {
  validateSchema(user, { name: 'string', email: 'string', role: 'string' })
}
GET /api/users GET /health GET /api/errors/404 GET /api/errors/422

helpers.net — spy & mock (CDP)

Equivalent to Cypress cy.intercept / Playwright page.route. Requires Chrome. Attached in tests/hooks.js as helpers.net.

const { interceptLogin, stubLoginFailure, mockApiGet } = require('./support/network/presets')
const { waitForIntercept, assertResponseSchema } = require('./support/network/assertions')

// Phase 1 — Spy
interceptLogin(helpers.net)
await loginPage.toggleUseApi()
await loginPage.loginWith(config.demoEmail, config.demoPassword)
const call = await waitForIntercept(helpers.net, 'loginApi')
expect(call.response.status).to.equal(200)

// Phase 2 — Mock empty users
await mockApiGet(helpers.net, 'users/empty-list', /\/api\/users/)
await helpers.clickByTestId('fetch-users-btn')
await waitForIntercept(helpers.net, 'mock_users_empty-list')

// Phase 2 — Mock 500 login
await stubLoginFailure(helpers.net, 500)

// Phase 3 — Ajv contract
assertResponseSchema(call, loadFixture('schemas/auth-login.json'))
interceptLogin interceptGetUsers stubLoginFailure mockApiGet interceptSlowApi waitForIntercept

UI intercept — Team & Settings (optional)

TestFlow invite/password/rotate are UI-only today. Use optional helpers.net.get(alias) like Cypress if (interception).

const { interceptInvite } = require('./support/network/presets')

interceptInvite(helpers.net)
await teamPage.submitInvite()

const call = helpers.net.get('inviteApi')
if (call) {
  assertRequestBodyKeys(call, ['name', 'email'])
}

Auth helpers — fast setup

async function injectAuth(page, session) {
  await page.addInitScript((auth) => {
    sessionStorage.setItem('sandbox-auth', JSON.stringify(auth))
    sessionStorage.setItem('sandbox-token', auth.token)
  }, session)
}

export async function loginViaApi(page, request) {
  const session = await fetchAuthToken(request)
  await injectAuth(page, session)
  await page.goto('/web/dashboard.html')
  await helpers.getByTestId('page-dashboard').waitFor()
}

Used in dashboard, team, settings — specs focus on the feature, not login.

Custom fixtures

Replaces global commands — injects token and headers into every request.

export const test = base.extend<{
  authToken: string
  userAccessToken: string
}>({
  authToken: async ({}, use) => {
    const token = await getOrRefreshSystemToken()
    await use(token)
  },
  request: async ({ request }, use) => {
    await use(wrapRequestWithDefaultHeaders(request, DEFAULT_HEADERS))
  },
})

afterEach(async ({}, testInfo) => {
  if (testInfo.status === 'failed') {
    await testInfo.attach('failure-details', { body: formatError(testInfo) })
  }
})

EXPECT — typed status codes

export const EXPECT = {
  happy: 200,
  noAuth: 403,
  invalidBearer: 401,
  notFound: 404,
  validationError: 422,
} as const

it(`[${EXPECT.noAuth}] GET without Authorization`, async ({ request }) => {
  const res = await request.get('/api/resources')
  expect(res.status()).toBe(EXPECT.noAuth)
})

Route modules + exchange helpers

export const AUTH_LOGIN_PATH = '/api/auth/login'

export async function postLoginExchange(
  request, testInfo, label, body
) {
  const response = await request.post(AUTH_LOGIN_PATH, { data: body })
  return attachHttpExchangeReport(testInfo, {
    label, method: 'POST', url: AUTH_LOGIN_PATH, response,
  })
}
  • Centralized URLs — no scattered strings
  • Exchange helper = request + attach report
  • Path aliases: @pw/core/*, @pw/helpers/*

attachHttpExchangeReport

await attachHttpExchangeReport(testInfo, {
  label: 'login-200',
  method: 'POST',
  url: '/api/auth/login',
  requestHeaders: { Authorization: 'Bearer …' },  // masked
  response,
})
// Attaches: status, headers, JSON body, reproducible curl
testInfo.attach curl replay mask Authorization

Tags, grep & serial mode

describe('Happy path suite @needs-browser', () => {
  describe.configure({ mode: 'serial', timeout: 150_000 })
  beforeAll(async () => { /* setup MFA / OAuth once */ })
})

describe('Negative testing', { tag: '@negative-testing' }, () => { /* … */ })
# CI — separate jobs by tag
npx npm test --grep-invert '@needs-browser'   # API only
npx npm test --grep '@needs-browser'          # OAuth browser flow
npx npm test --grep '@negative-testing'
npm run test:grep:smoke                              # @smoke (Chromium)

globalSetup — session cache

export default defineConfig({
  globalSetup: require.resolve('./globalSetup'),
})

// tests/hooks.js — runs once before all workers
// 1. Reuse token from .selenium/token-cache.json
// 2. Login via API
// 3. Save storageState to .selenium/.auth/user.json

Equivalent to cy.session — heavy login once, reused across N specs.

Enterprise API — overview

API-only suite: no browser, no UI — focus on REST contracts, OAuth, and cross-service sync.

Cypress legacy

Service Objects + cy.request + localStorage token

Current Selenium

request fixture + helpers + JWT cache

Organization

api/gets/ · api/patch/ · JSON fixtures

CI

PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1

Service Objects — Cypress legacy

POM equivalent for API — encapsulates URLs, auth, and HTTP methods.

export class ProfileApiClient {
  flushProfile() {
    return cy.request({
      method: 'PUT',
      url: this.url.flushProfile,
      headers: { Authorization: `Bearer ${localStorage.getItem('access_token')}` },
    }).its('status').should('eq', 204)
  }

  getCountries() {
    return cy.request({ method: 'GET', url: this.url.countries, headers: { /* … */ } })
  }
}

cy.wrap(new ProfileApiClient()).as('profileApi')

Auth cache — Cypress → Selenium

// Cypress — localStorage plugin
before(() => {
  preCondition.loginByApi({ username, password })
  cy.saveLocalStorage()
})
beforeEach(() => cy.restoreLocalStorage())
// Selenium — JWT parse + Map cache
export async function getOrRefreshToken(user) {
  const cached = tokenCache.get(user.username)
  if (cached && isTokenValid(cached)) return cached
  const token = await fetchTokenFromOAuth(user)
  tokenCache.set(user.username, token)
  return token
}

if (process.env.AUTH_TOKEN) return process.env.AUTH_TOKEN

test.use — per-suite user override

export const test = base.extend<{
  authToken: string
  testUser: TestUserCredentials | null
}>({
  testUser: [null, { option: true }],
  authToken: async ({ testUser }, use) => {
    const token = testUser
      ? await getOrRefreshTokenForUser(testUser)
      : await getOrRefreshBusinessToken()
    await use(token)
  },
})

test.use({ testUser: getTestUser('lendingPartner') })

Required context headers

export function buildApiHeaders(options: {
  profileContext: 'KYC' | 'OAA'
  lineOfBusiness?: 'Brokerage' | 'ConsumerLending'
}) {
  return {
    'X-Profile-Context': options.profileContext,
    'Correlation-Id': uuid(),
    'Session-Id': uuid(),
    'Client-Ip': '192.168.1.1',
    LineOfBusiness: options.lineOfBusiness ?? 'Brokerage',
    SourceLob: 'selenium-automation',
  }
}

Golden fixtures — contract without JSON Schema

export function withoutId(arr) {
  return arr.map(({ id, ...rest }) => rest)
}

export function expectSameMembers(expected, received) {
  // compare arrays ignoring order — readable diff in report
}

const { body } = await fetchLookup(request, authToken, '/v1/lookups/countries', testInfo)
expect(response.status()).toBe(200)
expect(withoutId(body)).toEqual(expectedCountriesFixture)

Data-driven — runPatchTests()

export function runPatchTests(context, { payloads, tag }) {
  for (const [label, payload] of Object.entries(payloads)) {
    describe(label, () => {
      it(`204 — PATCH accepted, GET profile matches`, async ({ request, authToken }) => {
        const patchRes = await request.patch(PATCH_PATH, { headers, data: payload })
        expect(patchRes.status()).toBe(204)
        const profile = await pollGetUntilMatch(request, GET_PATH, headers, payload)
        expect(profile).toMatchObject(normalizePayload(payload))
      })
    })
  }
}

runPatchTests('KYC', { payloads: EMPLOYED_PAYLOADS, tag: 'employmentCode' })

PATCH → poll GET — eventual consistency

async function pollGetUntilMatch(request, path, headers, expected, deadlineMs = 10_000) {
  const start = Date.now()
  while (Date.now() - start < deadlineMs) {
    const res = await request.get(path, { headers })
    if (res.status() === 200) {
      const body = normalizeProfile(await res.json())
      if (matchesExpected(body, expected)) return body
    }
    await new Promise((r) => setTimeout(r, 500))
  }
  throw new Error('GET did not match PATCH payload within deadline')
}

Dual write + read verification

profileApi.patchCustomerName(nameBody).should(r => expect(r.status).to.eq(204))
profileApi.getBasicProfile().should(r => {
  expect(r.body.pendingProfile.customerName).to.deep.include(nameBody)
})
lifecycleApi.getBasicProfile().should(r => {
  expect(r.body.pendingProfile).to.deep.include(nameBody)
})

PATCH on Profile API + GET on Lifecycle Platform confirms pending state propagation.

Diff + curl in failure report

await testInfo.attach('comparison-diff', {
  body: buildValidationReport(label, expected, received),
  contentType: 'text/markdown',
})
await testInfo.attach('curl-replay', { body: buildCurl('PATCH', url, headers, body) })

Postman → Selenium migration

  • Pre-request script → buildApiHeaders()
  • Tests tab assertions → expect() + golden fixtures
  • Collection env vars → .env + CI/CD variables
  • JWT validation → isTokenValid(token, claims)

CI — env diagnostics & conditional skip

it('required CI variables are set', async () => {
  expect(process.env.CLIENT_ID, 'CLIENT_ID').toBeTruthy()
  expect(process.env.API_BASE_URL, 'API_BASE_URL').toBeTruthy()
})

beforeAll(async () => {
  test.skip(!process.env.OAUTH_CLIENT_ID, 'OAuth client not configured in CI')
})

Page Object Model (POM)

Encapsulates selectors, actions, and assertions — the spec describes the scenario, not the DOM. For API-only: use Service Objects or helpers.

Spec (.test.js)

  • Given / When / Then
  • No scattered selectors
  • One scenario per it()
→

Page Object

  • getByTestId locators
  • Actions (loginWith)
  • Assertions (shouldRedirect…)

Anatomy — LoginPage.ts

export class LoginPage {
  constructor(private readonly page: Page) {}

  emailInput() { return this.helpers.getByTestId('login-email') }
  submitBtn()  { return this.helpers.getByTestId('login-submit') }

  async visit() {
    await this.page.goto('/web/login.html')
    return this
  }

  async loginWith(email: string, password: string) {
    await this.emailInput().fill(email)
    await this.passwordInput().fill(password)
    await this.submitBtn().click()
    return this
  }

  async shouldRedirectToDashboard() {
    await expect(this.helpers.getByTestId('page-dashboard')).toBeVisible()
    await expect(this.page).toHaveURL(/\/web\/dashboard\.html/)
  }
}

TeamPage — locators & actions

Locators
tableRows()users-table tbody tr
nameCell(id)cell-name-{id}
inviteModal()Invite modal
Actions
search(term)Filter table
filterByRole(role)Role select
openInviteModal()Open + assert visible
Assertions
shouldHaveRowCount(n)Row count
shouldShowInviteError(text)Modal validation

TestFlow Page Objects

LoginPage.js auth, validation, API toggle
DashboardPage.js KPIs, activity, new run modal
TeamPage.js table, invite, inline edit
SettingsPage.js forms, toggles, save
WizardPage.js multi-step, validation, review

Components, Activity, Advanced, and States specs use helpers directly — no dedicated Page Object yet.

WebDriver waits

Selenium WebDriver 4 uses explicit waits in support/helpers.js. Every getByTestId call waits for the element to exist and be visible before returning.

const { until, By } = require('selenium-webdriver')

await driver.wait(until.elementLocated(By.css('[data-testid="page-dashboard"]')), 10000)
await driver.wait(until.elementIsVisible(element), 10000)

// Timeouts centralized in support/enums/timeouts.js
await driver.manage().setTimeouts({
  implicit: 0,
  pageLoad: TIMEOUTS.PAGE_LOAD,
  script: TIMEOUTS.DEFAULT,
})
until.elementLocated until.elementIsVisible TIMEOUTS.DEFAULT

Mocha hooks & reporters

tests/hooks.js registers global hooks via .mocharc.cjs.

module.exports = {
  mochaHooks: {
    async beforeAll() {
      const driver = await getDriver()
      helpers = new Helpers(driver)
    },
    async afterEach() {
      if (this.currentTest?.state === 'failed') {
        const filePath = await helpers.takeScreenshot(`failed-${name}`)
        await attachFailureScreenshot(filePath) // Allure in CI
      }
    },
    async afterAll() {
      await quitDriver()
    },
  },
}

Local runs: mochawesome → reports/selenium-report.html. CI: allure-mocha → allure-results/.

Allure in CI

Workflow .github/workflows/selenium.yml uploads Allure results from smoke, regression, and full-suite jobs. Job publish-allure merges artifacts and deploys the report to GitHub Pages.

npm run report:allure          # generate locally
npm run report:allure:serve    # generate + open
npm run allure:merge-results   # merge CI artifacts
npm run pages:prepare-allure   # stage for GitHub Pages
publish-allure publish-pages screenshots on failure

API specs + Page Objects

UI suites use Page Objects; API suite uses publicRequest — no browser required.

// API — tests/api/auth.api.test.js
const { publicRequest } = require('../../support/api')

before(async function () {
  res = await publicRequest('/api/auth/login', { method: 'POST', body: VALID })
})

// UI — tests/team/team.test.js
const TeamPage = require('../../pages/TeamPage')

beforeEach(async function () {
  const helpers = getHelpers()
  await createAuthSession(helpers)
  teamPage = new TeamPage(helpers)
  await teamPage.open()
})

See Page Object Model and API testing sections below.

Factories & data fixtures

fixtures/ (JSON)
credentials.jsonValid/invalid email and password — negative login
team-member.jsonInvite modal payload
wizard.jsonMulti-step wizard data
support/factories/
index.jsWizard and invite payload builders
support/schemaValidator.js
validateWithSchema(obj, schema)AJV validation for API responses
config/env.js
resolveConfig()BASE_URL, demo credentials, headless flag

Training documentation (block-by-block)

Each spec has a detailed walkthrough with Given/When/Then, tags, and run commands. Choose language: English · Português

SuiteDoc ENDoc PTCommand
Smokenavigation.mdnavigation.mdnpm run test:smoke
Authlogin.mdlogin.mdnpm run test:auth
Dashboarddashboard.mddashboard.mdnpm run test:dashboard
Teamteam.mdteam.mdnpm run test:team
Settingssettings.mdsettings.mdnpm run test:settings
Componentscomponents.mdcomponents.mdnpm run test:components
Wizardwizard.mdwizard.mdnpm run test:wizard
Activityactivity.mdactivity.mdnpm run test:activity
Advancedadvanced.mdadvanced.mdnpm run test:advanced
UI Statesstates.mdstates.mdnpm run test:states
API authauth.api.mdauth.api.mdnpm run test:api

Suite coverage

SuiteSpecTagsCovers
smokenavigation.test.js@smoke @regressionPages, sidebar, health API, logout
authlogin.test.js@regression @criticalLogin UI, sessionStorage, errors, logout
dashboarddashboard.test.js—KPIs, activity, new run modal
teamteam.test.js—Search, filters, pagination, invite, inline edit
settingssettings.test.js—Profile, notifications, security, integrations
componentscomponents.test.js@regressionButtons, modal, tabs, accordion
wizardwizard.test.js—Multi-step, validation, review
activityactivity.test.js—API fetch, counter, pipeline, file drop
advancedadvanced.test.js@regressionShadow DOM, iframe, mobile viewport
statesstates.test.js—Skeleton, empty, error, partial load
apiauth.api.test.js@api @regressionPOST /api/auth/login contracts

11 specs · Chrome WebDriver · Mocha 11 + Chai · mochawesome / Allure

How to run

Local HTML report

npm test
npm run report:open

Headed

npm run test:headed
npm run test:team
npm test                         # full suite (all 11 specs)
npm run test:smoke               # @smoke tag
npm run test:regression          # @regression tag
npm run test:critical            # @critical tag
npm run test:auth                # login UI
npm run test:dashboard           # dashboard
npm run test:team                # team table
npm run test:settings            # settings forms
npm run test:components          # UI library
npm run test:wizard              # multi-step wizard
npm run test:activity            # dynamic activity page
npm run test:advanced            # shadow DOM / iframe
npm run test:states              # loading / empty / error
npm run test:api                 # auth.api.test.js
npm run report:allure            # Allure report (CI-style)
npm run report:open              # open mochawesome HTML

CI — GitHub Actions

CI jobs

smoke, regression, selenium-run (full suite) — Chrome headless against TestFlow on port 5050

Service container

qaschool/testflow:latest on port 5050

Allure + Pages

publish-allure merges results and deploys Allure Report 3 to GitHub Pages on every push to main

Artifacts

Allure results per job; failure screenshots uploaded when tests fail

Workflow: .github/workflows/selenium.yml · jobs: setup → smoke / regression / selenium-run → publish-pages → publish-allure

Best practices

  • Page Objects in pages/ — isolated locators, actions, and assertions
  • helpers.getByTestId + data-testid — stable selectors on TestFlow
  • createAuthSession in beforeEach — feature specs without repeating UI login
  • before + shared response — API contracts without N identical requests
  • validateWithSchema — AJV schemas for REST responses
  • publicRequest — API tests without launching a browser
  • JSON fixtures — deterministic data (credentials, team-member)
  • One scenario per it() — easy-to-diagnose failures in the report
  • Tags @smoke / @regression / @critical in test titles — filter with npm scripts
  • Explicit waits in helpers.js — never rely on implicit wait (set to 0)
  • visitWithSession — navigate authenticated to any route
  • Failure screenshots attached to Allure in CI via tests/hooks.js
  • Factories in support/factories/ — reusable wizard payloads
  • Read the block-by-block walkthrough in docs/en/ or docs/pt/
  • npm run test:headed in dev — watch the browser while debugging

Next steps