Nightwatch.js Page Object Model: Best Practices

Nightwatch.js Page Object Model: Best Practices

As Nightwatch.js test suites grow, raw CSS selectors scattered across test files become a maintenance burden. Change a button's class, and you're hunting through dozens of test files. The Page Object Model (POM) solves this by centralizing page-specific selectors and interactions in dedicated modules.

This guide covers Nightwatch's built-in POM support, how to structure it effectively, and the patterns that keep large suites maintainable.

Why Use Page Objects?

Without page objects:

// tests/checkout.test.js
test('Checkout with credit card', async browser => {
  await browser
    .url('https://shop.example.com/cart')
    .waitForElementVisible('#checkout-btn', 2000)
    .click('#checkout-btn')
    .waitForElementVisible('.payment-form', 3000)
    .setValue('#card-number', '4242424242424242')
    .setValue('#card-expiry', '12/28')
    .setValue('#card-cvc', '123')
    .click('#pay-now')
    .waitForElementVisible('.confirmation', 5000)
    .assert.textContains('.confirmation-title', 'Order Confirmed');
});

The problem: when the #checkout-btn selector changes to .btn-checkout, you must find and update every test that references it. In a suite with 50+ tests, this becomes a constant maintenance tax.

With page objects:

// tests/checkout.test.js
const cartPage = browser.page.cartPage();
const paymentPage = browser.page.paymentPage();

await cartPage.navigate();
await cartPage.proceedToCheckout();
await paymentPage.enterCardDetails('4242424242424242', '12/28', '123');
await paymentPage.submitPayment();
await paymentPage.assertOrderConfirmed();

Selector changes happen in one place.

Nightwatch's Built-in Page Object Support

Nightwatch has first-class POM support. Configure the path in nightwatch.conf.js:

module.exports = {
  page_objects_path: ['pages'],
  // ...
};

Create page objects in the pages/ folder. Nightwatch loads them automatically and makes them available via browser.page.<filename>().

Basic Page Object Structure

// pages/loginPage.js
module.exports = {
  url: 'https://app.example.com/login',
  
  elements: {
    emailInput: {
      selector: '#email',
    },
    passwordInput: {
      selector: '#password',
    },
    submitButton: {
      selector: 'button[type="submit"]',
    },
    errorMessage: {
      selector: '.error-message',
    },
    forgotPasswordLink: {
      selector: 'a[href="/forgot-password"]',
    },
  },
};

Reference elements using @elementName in tests:

test('Login page elements', async browser => {
  const loginPage = browser.page.loginPage();
  
  await loginPage
    .navigate()
    .waitForElementVisible('@emailInput', 2000)
    .assert.visible('@passwordInput')
    .assert.visible('@submitButton');
});

The @ prefix resolves to the selector in the elements definition. When the selector changes, update it once in the page object.

Adding Commands

Page objects become truly useful when you encapsulate multi-step interactions as named commands:

// pages/loginPage.js
const loginCommands = {
  login(email, password) {
    return this
      .waitForElementVisible('@emailInput', 2000)
      .clearValue('@emailInput')
      .setValue('@emailInput', email)
      .clearValue('@passwordInput')
      .setValue('@passwordInput', password)
      .click('@submitButton');
  },

  waitForLoginSuccess() {
    return this.waitForElementNotPresent('@submitButton', 3000);
  },

  assertError(message) {
    return this
      .assert.visible('@errorMessage')
      .assert.textContains('@errorMessage', message);
  },

  assertInputValidationError(field) {
    const selector = field === 'email' ? '@emailInput' : '@passwordInput';
    return this.assert.cssClassPresent(selector, 'invalid');
  },
};

module.exports = {
  url: 'https://app.example.com/login',
  commands: [loginCommands],
  
  elements: {
    emailInput: { selector: '#email' },
    passwordInput: { selector: '#password' },
    submitButton: { selector: 'button[type="submit"]' },
    errorMessage: { selector: '.login-error' },
  },
};

Tests become self-documenting:

test('Invalid credentials show error', async browser => {
  const loginPage = browser.page.loginPage();
  
  await loginPage
    .navigate()
    .login('user@example.com', 'wrongpassword')
    .assertError('Invalid email or password');
});

test('Empty email shows validation error', async browser => {
  const loginPage = browser.page.loginPage();
  
  await loginPage
    .navigate()
    .login('', 'password123')
    .assertInputValidationError('email');
});

Sections for Complex Pages

Dashboard pages often have distinct regions — header, sidebar, main content. Nightwatch sections let you model this hierarchy:

// pages/dashboardPage.js
module.exports = {
  url: 'https://app.example.com/dashboard',
  
  sections: {
    header: {
      selector: 'header.app-header',
      elements: {
        logo: { selector: '.logo' },
        userMenu: { selector: '[data-testid="user-menu"]' },
        notificationBell: { selector: '.notifications-btn' },
        searchInput: { selector: '.header-search input' },
      },
      commands: [{
        openUserMenu() {
          return this.click('@userMenu');
        },
        search(query) {
          return this
            .click('@searchInput')
            .setValue('@searchInput', query)
            .api.keys(browser.Keys.ENTER);
        },
      }],
    },
    
    sidebar: {
      selector: 'nav.sidebar',
      elements: {
        analyticsLink: { selector: 'a[href="/analytics"]' },
        settingsLink: { selector: 'a[href="/settings"]' },
        logoutButton: { selector: '[data-testid="logout"]' },
      },
    },
    
    mainContent: {
      selector: 'main.content',
      elements: {
        pageTitle: { selector: 'h1.page-title' },
        loadingSpinner: { selector: '.spinner' },
        emptyState: { selector: '.empty-state' },
      },
    },
  },
};

Access sections in tests:

test('Dashboard navigation', async browser => {
  const dashboard = browser.page.dashboardPage();
  
  await dashboard.navigate();
  
  // Access section elements with @
  const header = dashboard.section.header;
  const sidebar = dashboard.section.sidebar;
  
  await header
    .assert.visible('@logo')
    .assert.visible('@userMenu');
  
  await sidebar.click('@analyticsLink');
  
  const mainContent = dashboard.section.mainContent;
  await mainContent.assert.textContains('@pageTitle', 'Analytics');
});

Nested Page Objects

For reusable components that appear across multiple pages (modals, toasts, date pickers), create standalone page objects and compose them:

// pages/components/modal.js
module.exports = {
  elements: {
    container: { selector: '.modal' },
    title: { selector: '.modal-title' },
    closeButton: { selector: '.modal-close' },
    confirmButton: { selector: '.modal-confirm' },
    cancelButton: { selector: '.modal-cancel' },
  },
  commands: [{
    confirm() {
      return this.click('@confirmButton');
    },
    cancel() {
      return this.click('@cancelButton');
    },
    close() {
      return this.click('@closeButton');
    },
    assertTitle(text) {
      return this.assert.textContains('@title', text);
    },
  }],
};
// pages/components/toast.js
module.exports = {
  elements: {
    success: { selector: '.toast.success' },
    error: { selector: '.toast.error' },
    message: { selector: '.toast .message' },
  },
  commands: [{
    assertSuccess(text) {
      return this
        .waitForElementVisible('@success', 3000)
        .assert.textContains('@message', text);
    },
    assertError(text) {
      return this
        .waitForElementVisible('@error', 3000)
        .assert.textContains('@message', text);
    },
  }],
};

Configure nested page objects path:

module.exports = {
  page_objects_path: ['pages', 'pages/components'],
};

Access in tests:

test('Delete account shows confirmation modal', async browser => {
  const settingsPage = browser.page.settingsPage();
  const modal = browser.page.modal();
  const toast = browser.page.toast();
  
  await settingsPage
    .navigate()
    .clickDeleteAccount();
  
  await modal
    .assertTitle('Delete Account')
    .confirm();
  
  await toast.assertSuccess('Account deleted successfully');
});

TypeScript Page Objects

Nightwatch v3 has first-class TypeScript support. Type your page objects for better IDE autocomplete and compile-time checking:

// pages/loginPage.ts
import { EnhancedPageObject, EnhancedSectionInstance } from 'nightwatch';

export type LoginPage = EnhancedPageObject<
  typeof loginCommands,
  typeof loginElements
>;

const loginElements = {
  emailInput: { selector: '#email' as const },
  passwordInput: { selector: '#password' as const },
  submitButton: { selector: 'button[type="submit"]' as const },
  errorMessage: { selector: '.error-message' as const },
};

const loginCommands = {
  login(this: LoginPage, email: string, password: string) {
    return this
      .waitForElementVisible('@emailInput')
      .setValue('@emailInput', email)
      .setValue('@passwordInput', password)
      .click('@submitButton');
  },

  assertError(this: LoginPage, message: string) {
    return this
      .assert.visible('@errorMessage')
      .assert.textContains('@errorMessage', message);
  },
};

export default {
  url: 'https://app.example.com/login',
  commands: [loginCommands],
  elements: loginElements,
};

Common POM Anti-Patterns

1. Assertions in page objects

Keep page objects action-focused. Mixing assertions into page objects makes them harder to reuse across different test scenarios:

// BAD — page object knows about expected outcomes
login(email, password) {
  this
    .setValue('@emailInput', email)
    .setValue('@passwordInput', password)
    .click('@submitButton')
    .assert.visible('.dashboard'); // ← wrong place for assertion
}

// GOOD — page object provides navigation, test provides assertion
login(email, password) {
  return this
    .setValue('@emailInput', email)
    .setValue('@passwordInput', password)
    .click('@submitButton');
}

2. Too many page objects

Don't create a page object for every element. Page objects are for meaningful pages and complex components, not every <div>.

3. Duplicating selectors

If you reference the same selector in multiple page objects, you have duplication. Extract shared selectors into a constants file:

// pages/selectors.js
module.exports = {
  GLOBAL_NAV: 'nav.global',
  LOADING_SPINNER: '[data-testid="spinner"]',
  ERROR_BANNER: '.global-error',
};

4. Page objects that test logic

Page objects should describe UI, not test logic. Conditional logic in page objects is a sign of overreach:

// BAD
login(email, password, expectSuccess) {
  this.setValue('@emailInput', email);
  if (expectSuccess) {
    this.assert.visible('.dashboard');
  } else {
    this.assert.visible('@errorMessage');
  }
}

Organizing a Real Test Suite

pages/
  loginPage.js
  dashboardPage.js
  settingsPage.js
  checkoutPage.js
  components/
    modal.js
    toast.js
    datePicker.js
    dropdown.js

tests/
  auth/
    login.test.js
    logout.test.js
    forgot-password.test.js
  checkout/
    cart.test.js
    payment.test.js
    confirmation.test.js
  settings/
    profile.test.js
    notifications.test.js

The page objects folder mirrors the components of your app. The tests folder mirrors user journeys and features.

Testing HelpMeTest with Nightwatch Page Objects

Here's how a real POM test might look for monitoring a production application with HelpMeTest:

// pages/helpmetestDashboard.js
module.exports = {
  url: 'https://app.helpmetest.com/dashboard',
  elements: {
    testList: { selector: '.test-list' },
    healthCheckList: { selector: '.health-checks' },
    runButton: { selector: '[data-testid="run-test"]' },
    statusBadge: { selector: '.status-badge' },
  },
  commands: [{
    runTest(testName) {
      return this
        .waitForElementVisible('@testList')
        .assert.textContains('.test-name', testName)
        .click('@runButton');
    },
  }],
};

Conclusion

Nightwatch's built-in POM support — elements, commands, and sections — gives you everything you need to build maintainable test suites without third-party libraries. The key discipline is keeping page objects focused on describing the UI interface and delegating test logic back to the test files.

For large applications, invest in page objects early. The upfront cost of structuring your pages pays off the first time a selector changes across 30 tests and you update it in one place.

Read more