还在为端到端测试头疼?Nightwatch.js 让你的测试效率提升10倍的秘密

还在为端到端测试头疼?Nightwatch.js 让你的测试效率提升10倍的秘密

还在为端到端测试头疼?Nightwatch.js 让你的测试效率提升10倍的秘密

前言

当你的 Web 应用从一个小项目成长为拥有数百个页面的复杂系统时,如何确保每一次功能迭代都不会破坏现有功能?手动测试已经无法满足快速迭代的需求,而选择一款合适的端到端测试框架就成了关键。

今天要介绍的这个开源项目,已经被全球超过 50 万开发者使用,并且在 GitHub 上获得了超过 1.2 万颗星——它就是 Nightwatch.js

在接下来的文章中,我将带你从零开始,深入了解这个强大的端到端测试框架。无论你是刚接触自动化测试的新手,还是希望提升测试效率的老手,这篇教程都能给你带来实质性的帮助。


一、为什么 Nightwatch.js 值得关注

1.1 行业痛点与 Nightwatch 的解决方案

在前端开发领域,自动化测试一直是公认的难题。传统的测试方式存在以下痛点:

传统测试痛点:
├── 测试编写复杂,学习曲线陡峭
├── 测试执行缓慢,CI/CD 集成困难
├── 跨浏览器兼容性测试难以实现
├── 测试代码难以维护和复用
└── 调试困难,问题定位耗时

Nightwatch.js 针对这些问题提供了优雅的解决方案:

Nightwatch 的核心优势:
├── 简洁的语法 - 使用自然语言风格的 API
├── 底层基于 W3C WebDriver 协议 - 标准化且稳定
├── 内置并行测试执行 - 大幅提升测试速度
├── 丰富的内置命令 - 覆盖 95% 的常见测试场景
├── 优秀的 CI/CD 集成 - 支持所有主流 CI 平台
└── 强大的等待机制 - 告别不稳定的 flaky tests

1.2 Nightwatch.js 是什么

Nightwatch.js 是一个端到端测试框架,使用 Node.js 编写,专为 Web 应用设计。它的核心特点包括:

  • 基于 WebDriver 协议:遵循 W3C WebDriver 标准,确保跨浏览器兼容性
  • 简洁的测试语法:使用类似英语的可读语句编写测试
  • 内置测试运行器:无需额外配置即可运行测试
  • 丰富的断言库:内置大量常用断言,也支持自定义
  • Page Object 模式支持:让测试代码更易维护

1.3 谁在使用 Nightwatch.js

Nightwatch.js 被众多知名企业和开源项目采用:

用户案例:
├── Stripe - 全球领先的支付处理平台
├── Airbnb - 民宿预订巨头
├── GoDaddy - 域名和托管服务提供商
├── Shopify - 电商建站平台
├── GitHub - 全球最大代码托管平台
└── 众多中小企业和独立开发者

这些企业的选择证明了 Nightwatch.js 在生产环境中的可靠性和稳定性。


二、环境搭建:从零开始配置 Nightwatch

2.1 前置要求

在开始之前,请确保你的开发环境满足以下要求:

环境要求:
├── Node.js 16.x 或更高版本
├── npm 8.x 或更高版本(或 yarn/pnpm)
├── Chrome、Firefox、Edge 或 Safari 浏览器
└── Git(用于版本控制)

验证环境配置:

# 检查 Node.js 版本
node --version
# 输出应该类似于: v18.17.0

# 检查 npm 版本
npm --version
# 输出应该类似于: 9.6.7

2.2 创建项目结构

让我们从创建一个新的测试项目开始:

# 创建项目目录
mkdir nightwatch-tutorial
cd nightwatch-tutorial

# 初始化 npm 项目
npm init -y

# 安装 Nightwatch.js
npm install nightwatch --save-dev

安装完成后,项目结构应该是这样的:

nightwatch-tutorial/
├── node_modules/
   └── nightwatch/
├── package.json
├── package-lock.json
└── tests/          # 稍后我们会创建这个目录

2.3 配置 Nightwatch

Nightwatch.js 需要一个配置文件来指定测试设置和浏览器选项。创建 nightwatch.conf.js 文件:

// nightwatch.conf.js
// Nightwatch 配置文件

module.exports = {
  // 源代码文件夹路径
  src_folders: ["tests"],

  // 输出文件夹
  output_folder: "reports",

  // 页面对象模型文件夹
  page_objects_path: ["page-objects"],

  // WebDriver 设置
  webdriver: {
    start_process: true,
    server_path: "",
    port: 9515,
    cli_args: [
      "--no-sandbox",
      "--disable-setuid-sandbox"
    ]
  },

  // 测试设置
  test_settings: {
    // 默认设置
    default: {
      launch_url: "http://localhost",

      desiredCapabilities: {
        browserName: "chrome",
        "goog:chromeOptions": {
          args: [
            "--headless",
            "--disable-gpu",
            "--window-size=1280,800"
          ]
        }
      }
    },

    // Firefox 设置
    firefox: {
      desiredCapabilities: {
        browserName: "firefox",
        "moz:firefoxOptions": {
          args: ["-headless"]
        }
      }
    },

    // Safari 设置
    safari: {
      desiredCapabilities: {
        browserName: "safari"
      }
    },

    // Chrome with GUI(用于调试)
    chrome: {
      desiredCapabilities: {
        browserName: "chrome",
        "goog:chromeOptions": {
          // 移除 headless 模式以便观察
          args: ["--window-size=1280,800"]
        }
      }
    }
  }
};

// ============================================
// 如果你想使用 ChromeDriver 自动下载和启动
// 可以使用 nightwatch 的内置 WebDriver 管理
// ============================================

2.4 使用 WebDriver Manager(推荐方式)

对于大多数用户来说,使用 Nightwatch 内置的 WebDriver 管理器会更加方便。更新配置文件:

// nightwatch.conf.js

const { faker } = require('@faker-js/faker');

module.exports = {
  src_folders: ["tests"],
  output_folder: "reports",
  page_objects_path: ["page-objects"],

  // 使用 Nightwatch 内置的 WebDriver
  webdriver: {
    start_process: true,
    use_ssl: true,
    port: 9515,
    server_path: require('chromedriver').path,
    cli_args: [
      '--whitelisted-ips=',
      '--no-sandbox',
      '--disable-gpu'
    ]
  },

  test_settings: {
    default: {
      launch_url: "https://www.example.com",

      desiredCapabilities: {
        browserName: "chrome",
        "goog:chromeOptions": {
          args: [
            "--headless",
            "--no-sandbox",
            "--disable-dev-shm-usage",
            "--disable-gpu",
            "--window-size=1280,800"
          ]
        }
      }
    }
  }
};

2.5 添加测试脚本到 package.json

为了方便运行测试,在 package.json 中添加脚本:

{
  "name": "nightwatch-tutorial",
  "version": "1.0.0",
  "description": "Nightwatch.js 端到端测试教程",
  "main": "index.js",
  "scripts": {
    "test": "nightwatch",
    "test:chrome": "nightwatch --env chrome",
    "test:firefox": "nightwatch --env firefox",
    "test:debug": "nightwatch --env chrome --debug",
    "test:parallel": "nightwatch --parallel"
  },
  "keywords": ["nightwatch", "e2e", "testing"],
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "nightwatch": "^2.6.0"
  }
}

2.6 验证安装

创建第一个简单的测试文件来验证安装是否成功:

mkdir tests
// tests/demo.test.js
// 第一个 Nightwatch 测试

describe('验证 Nightwatch 安装', function() {
  // 测试用例
  it('应该能够打开网页', function(browser) {
    // 打开指定 URL
    browser.url('https://example.com');

    // 等待页面加载完成
    browser.waitForElementVisible('body');

    // 获取页面标题
    browser.assert.title('Example Domain');

    // 结束测试
    browser.end();
  });
});

运行测试:

npm test

如果一切配置正确,你应该能看到类似以下的输出:

[验证 Nightwatch 安装] 测试开始
  ✓ 应该能够打开网页 (1.234s)
测试完成
1 个测试通过,0 个测试失败
总耗时: 2.345s

三、核心功能详解

3.1 浏览器操作

Nightwatch.js 提供了丰富的浏览器操作命令,让我们逐一了解。

3.1.1 导航操作

describe('导航操作演示', function() {
  it('基本导航操作', function(browser) {
    // 打开指定 URL
    browser.url('https://www.example.com');

    // 等待页面完全加载
    browser.waitForElementVisible('body');

    // 后退到上一页
    browser.back();

    // 前进到下一页
    browser.forward();

    // 刷新当前页面
    browser.refresh();

    // 获取当前 URL
    browser.url(function(result) {
      console.log('当前 URL:', result.value);
    });

    // 关闭浏览器
    browser.end();
  });

  it('导航到相对路径', function(browser) {
    browser.url('https://www.example.com');

    // 导航到相对路径
    browser.navigateTo('/page1');

    // 导航到绝对路径
    browser.navigateTo('https://www.example.com/page2');

    browser.end();
  });
});

3.1.2 窗口和框架操作

describe('窗口和框架操作', function() {
  it('窗口操作', function(browser) {
    browser.url('https://www.example.com');

    // 获取当前窗口句柄
    browser.windowHandles(function(handles) {
      console.log('窗口数量:', handles.value.length);
    });

    // 切换到新窗口(假设已打开)
    browser.switchWindow('windowTitle');

    // 关闭当前窗口
    browser.closeWindow();

    // 全屏窗口
    browser.maximizeWindow();

    // 最小化窗口
    browser.minimizeWindow();

    // 设置窗口大小
    browser.resizeWindow(1280, 720);

    // 获取窗口大小
    browser.getWindowSize(function(result) {
      console.log('窗口尺寸:', result.value);
    });

    browser.end();
  });

  it('iframe 操作', function(browser) {
    browser.url('https://www.example.com');

    // 切换到 iframe
    browser.frame('iframeName');
    // 或者使用索引
    browser.frame(0);
    // 或者使用 iframe 元素
    browser.frame('[name="iframeName"]');

    // 在 iframe 中执行操作
    browser.click('#iframe-element');

    // 切换回主文档
    browser.frame(null);

    browser.end();
  });
});

3.2 元素定位与交互

3.2.1 元素定位策略

Nightwatch 支持多种元素定位策略:

describe('元素定位策略', function() {
  it('使用 CSS 选择器定位', function(browser) {
    browser.url('https://www.example.com');

    // CSS 选择器
    browser.click('#submit-button');        // ID
    browser.click('.btn-primary');          // Class
    browser.click('div.container');         // 标签名
    browser.click('ul.nav > li:first-child'); // 组合选择器

    browser.end();
  });

  it('使用 XPath 定位', function(browser) {
    browser.url('https://www.example.com');

    // XPath 绝对路径
    browser.click('xpath=/html/body/div[2]/form/input');

    // XPath 相对路径
    browser.click('xpath=//input[@type="submit"]');

    // XPath 包含文本
    browser.click('xpath=//button[contains(text(),"提交")]');

    // XPath 多个条件
    browser.click('xpath=//div[@class="form"]//input[@name="email"]');

    browser.end();
  });

  it('使用文本内容定位', function(browser) {
    browser.url('https://www.example.com');

    // 链接文本
    browser.click('link=关于我们');

    // 部分链接文本
    browser.click('linkText=了解更多');

    browser.end();
  });

  it('使用 DOM 属性定位', function(browser) {
    browser.url('https://www.example.com');

    // name 属性
    browser.setValue('[name="username"]', 'testuser');

    // data-testid 属性
    browser.click('[data-testid="submit-btn"]');

    // 多个属性组合
    browser.click('[type="submit"][disabled]');

    browser.end();
  });
});

3.2.2 鼠标操作

describe('鼠标操作', function() {
  it('基本鼠标操作', function(browser) {
    browser.url('https://www.example.com');

    // 单击
    browser.click('#button');

    // 双击
    browser.doubleClick('#double-click-button');

    // 右键单击
    browser.rightClick('#context-menu-button');

    // 悬停
    browser.moveToElement('#hover-element');

    browser.end();
  });

  it('拖拽操作', function(browser) {
    browser.url('https://www.example.com');

    // 将元素拖拽到目标元素
    browser.dragAndDrop('#source-element', '#target-element');

    // 或者分步进行
    browser.perform(function(done) {
      // 获取元素位置
      this.element('id', 'source-element', function(result) {
        var elementId = result.value.ELEMENT;

        // 执行拖拽
        this.moveTo(elementId, 5, 5)
            .mouseDown()
            .moveTo(null, 100, 100)
            .mouseUp();

        done();
      });
    });

    browser.end();
  });
});

3.2.3 键盘操作

describe('键盘操作', function() {
  it('输入操作', function(browser) {
    browser.url('https://www.example.com');

    // 设置输入值(清除现有内容后输入)
    browser.setValue('#search-input', 'nightwatch');

    // 追加输入(不清除现有内容)
    browser.appendValue('#search-input', ' tutorial');

    // 清除输入
    browser.clearValue('#search-input');

    // 提交表单
    browser.submitForm('#search-form');

    browser.end();
  });

  it('特殊按键', function(browser) {
    browser.url('https://www.example.com');

    browser.setValue('#search-input', 'hello');

    // 模拟回车键
    browser.keys(browser.Keys.ENTER);

    // 模拟 Tab 键
    browser.keys(browser.Keys.TAB);

    // 模拟 Ctrl+A(全选)
    browser.keys([browser.Keys.CONTROL, 'a']);

    // 模拟 Ctrl+C(复制)
    browser.keys([browser.Keys.CONTROL, 'c']);

    // 模拟 Ctrl+V(粘贴)
    browser.keys([browser.Keys.CONTROL, 'v']);

    // 模拟退格键
    browser.keys(browser.Keys.BACKSPACE);

    // 模拟 Escape 键
    browser.keys(browser.Keys.ESCAPE);

    browser.end();
  });

  it('组合键示例', function(browser) {
    browser.url('https://www.example.com');

    // Ctrl+A 全选
    browser.setValue('#input', 'Hello World');
    browser.click('#input');
    browser.keys([browser.Keys.CONTROL, 'a']);

    // Ctrl+C 复制
    browser.keys([browser.Keys.CONTROL, 'c']);

    // 点击另一个输入框
    browser.click('#another-input');

    // Ctrl+V 粘贴
    browser.keys([browser.Keys.CONTROL, 'v']);

    browser.end();
  });
});

3.3 断言与验证

断言是测试的核心部分,Nightwatch 提供了丰富的断言方法。

3.3.1 元素断言

describe('元素断言', function() {
  it('可见性断言', function(browser) {
    browser.url('https://www.example.com');

    // 断言元素可见
    browser.assert.visible('#visible-element');

    // 断言元素不可见
    browser.assert.hidden('#hidden-element');

    // 断言元素存在
    browser.assert.elementPresent('#existing-element');

    // 断言元素不存在
    browser.assert.elementNotPresent('#non-existing-element');

    browser.end();
  });

  it('文本断言', function(browser) {
    browser.url('https://www.example.com');

    // 断言元素包含指定文本
    browser.assert.containsText('#element', '预期文本');

    // 断言元素文本等于指定值
    browser.assert.textEquals('#element', '精确文本');

    // 断言元素文本符合正则表达式
    browser.assert.matchesText('#element', /^\d{4}-\d{2}-\d{2}$/);

    browser.end();
  });

  it('值断言', function(browser) {
    browser.url('https://www.example.com');

    // 断言输入框的值
    browser.assert.value('#input', '预期值');

    // 断言输入框的值包含
    browser.assert.valueContains('#input', '部分值');

    // 断言复选框被选中
    browser.assert.checked('#checkbox');

    // 断言复选框未被选中
    browser.assert.not.checked('#checkbox');

    browser.end();
  });

  it('属性断言', function(browser) {
    browser.url('https://www.example.com');

    // 断言元素具有指定属性
    browser.assert.attributeEquals('#element', 'data-id', '123');

    // 断言元素属性包含
    browser.assert.attributeContains('#element', 'class', 'active');

    // 断言元素有 disabled 属性
    browser.assert.attributePresent('#element', 'disabled');

    browser.end();
  });

  it('CSS 断言', function(browser) {
    browser.url('https://www.example.com');

    // 断言 CSS 属性
    browser.assert.cssProperty('#element', 'display', 'none');

    // 断言 CSS 属性值包含
    browser.assert.cssProperty('#element', 'color', 'rgb(255, 0, 0)');

    browser.end();
  });
});

3.3.2 高级断言

describe('高级断言', function() {
  it('使用 expect API', function(browser) {
    browser.url('https://www.example.com');

    // expect 风格的断言
    browser.expect.element('#title').text.to.equal('页面标题');
    browser.expect.element('#title').text.to.contain('页面');
    browser.expect.element('#count').text.to.match(/^\d+$/);

    // expect 可见性
    browser.expect.element('#modal').to.be.visible;
    browser.expect.element('#modal').to.not.be.visible;

    // expect 属性
    browser.expect.element('#link').to.have.attribute('href').which.equals('https://example.com');
    browser.expect.element('#input').to.have.attribute('disabled').which.is.empty;

    // expect 类名
    browser.expect.element('#element').to.have.class('active');
    browser.expect.element('#element').to.not.have.class('hidden');

    browser.end();
  });

  it('自定义断言', function(browser) {
    browser.url('https://www.example.com');

    // 使用 assert.title
    browser.assert.title('Example Domain');

    // 使用 assert.url
    browser.assert.urlContains('/page');

    // 使用 assert.url.equals
    browser.assert.urlEquals('https://www.example.com/page');

    // 使用 assert.cookie
    browser.assert.cookiePresent('session_id');
    browser.assert.cookieEquals('session_id', 'abc123');

    // 使用 assert.jsEnabled
    browser.assert.jsEnabled();

    // 使用 assert.cssAnimation
    browser.assert.cssAnimation('#element');

    browser.end();
  });
});

3.4 等待机制

等待是端到端测试中最重要也最容易出问题的地方。Nightwatch 提供了智能的等待机制。

3.4.1 隐式等待

describe('隐式等待设置', function() {
  // 在配置文件中全局设置
  before(browser => {
    // 设置全局超时时间
    browser.globals.waitForConditionTimeout = 5000;
    browser.globals.waitForConditionPollInterval = 100;
  });

  it('自动等待元素出现', function(browser) {
    browser.url('https://www.example.com');

    // Nightwatch 会自动等待元素变为可见
    browser.waitForElementVisible('#async-element');

    // 等待元素出现在 DOM 中
    browser.waitForElementPresent('#new-element');

    // 等待元素消失
    browser.waitForElementNotVisible('#loading');

    browser.end();
  });
});

3.4.2 显式等待

describe('显式等待', function() {
  it('使用 waitForElementVisible', function(browser) {
    browser.url('https://www.example.com');

    // 等待元素可见,最多等待 10 秒,每 500ms 检查一次
    browser.waitForElementVisible('#target', 10000, 500);

    browser.end();
  });

  it('使用 waitForElementNotVisible', function(browser) {
    browser.url('https://www.example.com');

    // 等待元素不可见(加载完成)
    browser.waitForElementNotVisible('.loading-spinner', 30000);

    browser.end();
  });

  it('等待条件满足', function(browser) {
    browser.url('https://www.example.com');

    // 等待 JavaScript 条件满足
    browser.waitForCondition('return document.readyState === "complete"', 10000);

    // 等待自定义函数返回 true
    browser.waitForCondition(function() {
      return this.execute(function() {
        return window.someAsyncData !== undefined;
      });
    }, 10000);

    browser.end();
  });

  it('等待属性变化', function(browser) {
    browser.url('https://www.example.com');

    // 等待元素有特定属性
    browser.waitForElementAttribute('#button', 'disabled', null, 5000);

    // 等待元素有特定类名
    browser.waitForElementPresent('#element.active', 5000);

    browser.end();
  });
});

3.4.3 自定义等待条件

describe('自定义等待条件', function() {
  it('等待 AJAX 请求完成', function(browser) {
    browser.url('https://www.example.com');

    // 定义一个等待 AJAX 完成的函数
    function waitForAjaxComplete(browser) {
      return browser.execute(function() {
        return jQuery.active === 0;
      });
    }

    // 使用自定义条件等待
    browser.perform(function(done) {
      this.waitForCondition(waitForAjaxComplete, 10000, function() {
        console.log('AJAX 请求完成');
        done();
      });
    });

    browser.end();
  });

  it('等待元素文本变化', function(browser) {
    browser.url('https://www.example.com');

    // 获取初始文本
    var initialText;
    browser.getText('#status', function(result) {
      initialText = result.value;
    });

    // 点击按钮触发更新
    browser.click('#update-button');

    // 等待文本变化
    browser.perform(function(done) {
      var maxAttempts = 20;
      var attempt = 0;

      var checkText = function() {
        this.getText('#status', function(result) {
          if (result.value !== initialText) {
            done();
          } else if (attempt < maxAttempts) {
            attempt++;
            setTimeout(checkText.bind(this), 500);
          } else {
            done(new Error('文本未在预期时间内变化'));
          }
        });
      };

      checkText.call(this);
    });

    browser.end();
  });
});

3.5 Page Object 模式

Page Object 是测试代码组织的最佳实践,它将页面元素和操作封装成独立的对象。

3.5.1 创建 Page Objects

// page-objects/google-search.js
// Google 搜索页面对象

class GoogleSearchPage {
  constructor(browser) {
    this.browser = browser;

    // 元素选择器
    this.elements = {
      searchInput: 'textarea[name="q"]',
      searchButton: 'input[name="btnK"]',
      searchResults: '#search',
      firstResult: '#search a:first-child',
      logo: '#hplogo'
    };
  }

  // 打开页面
  open() {
    this.browser.url('https://www.google.com');
    return this;
  }

  // 等待页面加载
  waitForPageLoad() {
    this.browser.waitForElementVisible(this.elements.searchInput);
    return this;
  }

  // 执行搜索
  search(keyword) {
    this.browser
      .setValue(this.elements.searchInput, keyword)
      .click(this.elements.searchButton);

    return this;
  }

  // 等待搜索结果加载
  waitForResults() {
    this.browser.waitForElementVisible(this.elements.searchResults);
    return this;
  }

  // 获取搜索结果数量
  getResultCount() {
    return this.browser.elements(this.elements.searchResults + ' a');
  }

  // 获取第一个结果的标题
  getFirstResultTitle() {
    var title;
    this.browser.getText(this.elements.firstResult, function(result) {
      title = result.value;
    });
    return title;
  }

  // 检查是否搜索成功
  assertSearchSuccessful() {
    this.browser.assert.visible(this.elements.searchResults);
    return this;
  }
}

// 导出页面对象
module.exports = GoogleSearchPage;

3.5.2 在测试中使用 Page Objects

// tests/google-search.test.js
// 使用 Page Object 的测试示例

const GoogleSearchPage = require('../page-objects/google-search');

describe('Google 搜索功能测试', function() {
  // 创建页面对象实例
  let googleSearch;

  before(browser => {
    googleSearch = new GoogleSearchPage(browser);
  });

  it('应该能够搜索并显示结果', function(browser) {
    // 使用 Page Object 的方法
    googleSearch
      .open()
      .waitForPageLoad()
      .search('Nightwatch.js')
      .waitForResults()
      .assertSearchSuccessful();

    // 获取结果并验证
    var resultCount = googleSearch.getResultCount();
    console.log('找到', resultCount.value.length, '个搜索结果');

    browser.end();
  });

  it('验证第一个搜索结果包含关键词', function(browser) {
    googleSearch
      .open()
      .waitForPageLoad()
      .search('automation testing');

    googleSearch.waitForResults();

    // 验证第一个结果
    var firstTitle = googleSearch.getFirstResultTitle();
    console.log('第一个结果:', firstTitle);

    browser.end();
  });
});

3.5.3 高级 Page Object 模式

// page-objects/base-page.js
// 基础页面对象类

class BasePage {
  constructor(browser) {
    this.browser = browser;
  }

  // 打开页面
  open(path) {
    this.browser.url(path);
    return this;
  }

  // 等待页面加载
  waitForPageLoad() {
    this.browser.waitForElementVisible('body');
    return this;
  }

  // 点击元素
  click(selector) {
    this.browser.click(selector);
    return this;
  }

  // 输入文本
  setValue(selector, value) {
    this.browser.setValue(selector, value);
    return this;
  }

  // 获取元素文本
  getText(selector) {
    return this.browser.getText(selector);
  }

  // 等待元素可见
  waitForVisible(selector, timeout) {
    this.browser.waitForElementVisible(selector, timeout);
    return this;
  }

  // 断言元素可见
  assertVisible(selector) {
    this.browser.assert.visible(selector);
    return this;
  }

  // 滚动到元素
  scrollIntoView(selector) {
    this.browser.execute(function(selector) {
      document.querySelector(selector).scrollIntoView();
    }, selector);
    return this;
  }

  // 获取当前 URL
  getCurrentUrl() {
    return this.browser.url(function(result) {
      return result.value;
    });
  }
}

// ============================================
// 子页面继承基础页面
// ============================================

class LoginPage extends BasePage {
  constructor(browser) {
    super(browser);

    this.elements = {
      usernameInput: '#username',
      passwordInput: '#password',
      submitButton: 'button[type="submit"]',
      errorMessage: '.error-message',
      rememberMe: '#remember-me'
    };
  }

  open() {
    super.open('/login');
    return this;
  }

  login(username, password, rememberMe = false) {
    this.browser
      .setValue(this.elements.usernameInput, username)
      .setValue(this.elements.passwordInput, password);

    if (rememberMe) {
      this.browser.click(this.elements.rememberMe);
    }

    this.browser.click(this.elements.submitButton);
    return this;
  }

  waitForError() {
    this.browser.waitForElementVisible(this.elements.errorMessage);
    return this;
  }
}

class DashboardPage extends BasePage {
  constructor(browser) {
    super(browser);

    this.elements = {
      welcomeMessage: '.welcome',
      logoutButton: 'a[href="/logout"]',
      userMenu: '.user-menu',
      profileLink: 'a[href="/profile"]'
    };
  }

  open() {
    super.open('/dashboard');
    return this;
  }

  waitForLoad() {
    this.browser.waitForElementVisible(this.elements.welcomeMessage);
    return this;
  }

  getWelcomeMessage() {
    return this.getText(this.elements.welcomeMessage);
  }

  logout() {
    this.click(this.elements.logoutButton);
    return this;
  }
}

// 导出所有页面对象
module.exports = {
  BasePage,
  LoginPage,
  DashboardPage
};

3.5.4 Page Object 目录结构

page-objects/
├── base-page.js           # 基础页面类
├── google-search.js       # Google 搜索页面
├── login-page.js          # 登录页面
├── dashboard-page.js      # 仪表盘页面
├── page-objects.json      # 页面对象配置(可选)
└── components/            # 可复用的组件
    ├── header.js          # 页头组件
    ├── footer.js          # 页脚组件
    └── modal.js           # 模态框组件

四、实战教程:完整测试场景

4.1 项目实战:电商网站测试

让我们通过一个完整的实战项目来学习 Nightwatch 的实际应用。我们将测试一个假设的电商网站「ShopDemo」。

4.1.1 创建项目结构

# 创建完整的项目结构
mkdir -p tests/{pages,components,tests,utils}
mkdir -p reports/screenshots
mkdir -p page-objects/{pages,components}

# 安装额外的依赖
npm install @faker-js/faker chai --save-dev

4.1.2 配置文件

// nightwatch.conf.js

module.exports = {
  // 源码目录
  src_folders: ['tests'],

  // 页面对象目录
  page_objects_path: ['page-objects'],

  // 输出目录
  output_folder: 'reports',

  // 自定义命令目录
  custom_commands_path: ['commands'],

  // 自定义断言目录
  custom_assertions_path: ['assertions'],

  // WebDriver 设置
  webdriver: {
    start_process: true,
    server_path: '',
    port: 9515,
    cli_args: [
      '--no-sandbox',
      '--disable-setuid-sandbox',
      '--disable-dev-shm-usage'
    ]
  },

  // 测试设置
  test_settings: {
    default: {
      launch_url: 'https://shopdemo.com',
      screenshots: {
        enabled: true,
        on_failure: true,
        path: 'reports/screenshots'
      },
      desiredCapabilities: {
        browserName: 'chrome',
        'goog:chromeOptions': {
          args: [
            '--headless',
            '--no-sandbox',
            '--disable-gpu',
            '--window-size=1280,800'
          ]
        }
      }
    },

    // Chrome 调试模式
    chrome_debug: {
      launch_url: 'https://shopdemo.com',
      desiredCapabilities: {
        browserName: 'chrome',
        'goog:chromeOptions': {
          args: [
            '--window-size=1280,800',
            '--auto-open-devtools-for-tabs'
          ]
        }
      }
    },

    // Firefox 设置
    firefox: {
      desiredCapabilities: {
        browserName: 'firefox',
        'moz:firefoxOptions': {
          args: ['-headless']
        }
      }
    }
  }
};

4.1.3 登录页面对象

// page-objects/pages/login-page.js
// 登录页面对象

module.exports = {
  elements: {
    // 表单元素
    emailInput: 'input[name="email"]',
    passwordInput: 'input[name="password"]',
    rememberMeCheckbox: 'input[name="remember"]',
    submitButton: 'button[type="submit"]',

    // 错误提示
    emailError: '[data-testid="email-error"]',
    passwordError: '[data-testid="password-error"]',
    formError: '.form-error-message',

    // 链接
    forgotPasswordLink: 'a[href="/forgot-password"]',
    registerLink: 'a[href="/register"]',

    // Logo
    logo: '.logo img'
  },

  commands: [{
    // 打开登录页面
    open() {
      return this.navigate();
    },

    // 输入邮箱
    setEmail(email) {
      return this.setValue('@emailInput', email);
    },

    // 输入密码
    setPassword(password) {
      return this.setValue('@passwordInput', password);
    },

    // 点击记住我
    checkRememberMe() {
      return this.click('@rememberMeCheckbox');
    },

    // 点击提交按钮
    submit() {
      return this.click('@submitButton');
    },

    // 完整登录流程
    login(email, password, rememberMe = false) {
      this.setEmail(email);
      this.setPassword(password);

      if (rememberMe) {
        this.checkRememberMe();
      }

      return this.submit();
    },

    // 等待表单加载
    waitForForm() {
      return this.waitForElementVisible('@emailInput');
    },

    // 获取邮箱错误信息
    getEmailError() {
      return this.getText('@emailError');
    },

    // 获取表单错误信息
    getFormError() {
      return this.getText('@formError');
    },

    // 检查是否显示邮箱错误
    hasEmailError() {
      return this.isVisible('@emailError');
    }
  }]
};

4.1.4 产品列表页面对象

// page-objects/pages/product-list-page.js
// 产品列表页面对象

module.exports = {
  elements: {
    // 搜索相关
    searchInput: 'input[name="search"]',
    searchButton: 'button[type="submit"]',

    // 分类筛选
    categoryMenu: '.category-menu',
    categoryLinks: '.category-menu a',

    // 价格筛选
    minPriceInput: 'input[name="min-price"]',
    maxPriceInput: 'input[name="max-price"]',
    applyPriceButton: 'button.apply-price',

    // 产品卡片
    productCards: '.product-card',
    productNames: '.product-card .product-name',
    productPrices: '.product-card .product-price',
    productImages: '.product-card img',

    // 第一个产品
    firstProduct: '.product-card:first-child',
    firstProductName: '.product-card:first-child .product-name',
    firstProductPrice: '.product-card:first-child .product-price',
    firstProductImage: '.product-card:first-child img',
    addToCartButton: '.product-card:first-child .add-to-cart',

    // 分页
    pagination: '.pagination',
    nextPageButton: '.pagination .next',
    currentPage: '.pagination .current',

    // 加载状态
    loadingSpinner: '.loading-spinner',
    noResultsMessage: '.no-results'
  },

  commands: [{
    // 搜索产品
    search(keyword) {
      return this
        .clearValue('@searchInput')
        .setValue('@searchInput', keyword)
        .click('@searchButton');
    },

    // 等待搜索结果
    waitForResults() {
      return this
        .waitForElementNotVisible('@loadingSpinner')
        .waitForElementVisible('@productCards');
    },

    // 获取产品数量
    getProductCount(callback) {
      return this.elements('@productCards', callback);
    },

    // 获取第一个产品名称
    getFirstProductName() {
      return this.getText('@firstProductName');
    },

    // 获取第一个产品价格
    getFirstProductPrice() {
      return this.getText('@firstProductPrice');
    },

    // 点击第一个产品的添加到购物车按钮
    addFirstProductToCart() {
      return this.click('@addToCartButton');
    },

    // 筛选分类
    selectCategory(categoryName) {
      return this
        .click('@categoryMenu')
        .click(`xpath=//a[contains(text(),"${categoryName}")]`);
    },

    // 设置价格范围
    setPriceRange(min, max) {
      return this
        .clearValue('@minPriceInput')
        .setValue('@minPriceInput', min)
        .clearValue('@maxPriceInput')
        .setValue('@maxPriceInput', max)
        .click('@applyPriceButton');
    },

    // 点击下一页
    goToNextPage() {
      return this.click('@nextPageButton');
    },

    // 获取当前页码
    getCurrentPage() {
      return this.getText('@currentPage');
    },

    // 悬停在产品上
    hoverOnFirstProduct() {
      return this.moveToElement('@firstProduct');
    },

    // 检查是否有结果
    hasResults() {
      return this.isVisible('@productCards');
    }
  }]
};

4.1.5 购物车页面对象

// page-objects/pages/cart-page.js
// 购物车页面对象

module.exports = {
  elements: {
    // 购物车内容
    cartItems: '.cart-item',
    emptyCartMessage: '.empty-cart-message',

    // 第一个商品
    firstItem: '.cart-item:first-child',
    firstItemName: '.cart-item:first-child .item-name',
    firstItemPrice: '.cart-item:first-child .item-price',
    firstItemQuantity: '.cart-item:first-child .item-quantity input',
    firstItemRemoveButton: '.cart-item:first-child .remove-item',

    // 数量控制
    quantityInput: '.quantity-input',
    decreaseButton: '.quantity-decrease',
    increaseButton: '.quantity-increase',

    // 金额计算
    subtotal: '.cart-subtotal',
    shipping: '.cart-shipping',
    tax: '.cart-tax',
    total: '.cart-total',

    // 操作按钮
    continueShoppingButton: 'button.continue-shopping',
    checkoutButton: 'button.checkout',
    clearCartButton: 'button.clear-cart',

    // 优惠券
    couponInput: 'input[name="coupon"]',
    applyCouponButton: 'button.apply-coupon',
    couponSuccess: '.coupon-success',
    couponError: '.coupon-error'
  },

  commands: [{
    // 等待购物车加载
    waitForLoad() {
      return this.waitForElementVisible('.cart-container');
    },

    // 获取商品数量
    getItemCount() {
      return this.elements('@cartItems', result => {
        return result.value.length;
      });
    },

    // 获取第一个商品名称
    getFirstItemName() {
      return this.getText('@firstItemName');
    },

    // 获取小计
    getSubtotal() {
      return this.getText('@subtotal');
    },

    // 获取总价
    getTotal() {
      return this.getText('@total');
    },

    // 设置商品数量
    setQuantity(itemSelector, quantity) {
      return this.clearValue(itemSelector)
        .setValue(itemSelector, quantity);
    },

    // 增加数量
    increaseQuantity() {
      return this.click('@increaseButton');
    },

    // 减少数量
    decreaseQuantity() {
      return this.click('@decreaseButton');
    },

    // 移除第一个商品
    removeFirstItem() {
      return this.click('@firstItemRemoveButton');
    },

    // 应用优惠券
    applyCoupon(code) {
      return this
        .setValue('@couponInput', code)
        .click('@applyCouponButton');
    },

    // 等待优惠券结果
    waitForCouponResult() {
      return this
        .waitForElementVisible('@couponSuccess', 3000)
        .catch(() => {
          // 优惠券可能无效
          return this.waitForElementVisible('@couponError', 3000);
        });
    },

    // 点击结账
    proceedToCheckout() {
      return this.click('@checkoutButton');
    },

    // 点击继续购物
    continueShopping() {
      return this.click('@continueShoppingButton');
    },

    // 清空购物车
    clearCart() {
      return this.click('@clearCartButton');
    },

    // 检查购物车是否为空
    isEmpty() {
      return this.isVisible('@emptyCartMessage');
    },

    // 等待商品添加成功
    waitForItemAdded() {
      // 等待通知消息或购物车更新
      return this.waitForElementVisible('.notification.success');
    }
  }]
};

4.1.6 登录测试

// tests/login.test.js
// 登录功能测试套件

describe('登录功能测试', function() {

  // 测试配置
  before(browser => {
    // 设置全局超时
    browser.globals.waitForConditionTimeout = 10000;
  });

  describe('成功登录场景', function() {
    it('使用正确的凭据登录成功', function(browser) {
      // 打开登录页面
      browser
        .url('https://shopdemo.com/login')
        .waitForElementVisible('input[name="email"]');

      // 输入有效凭据
      browser
        .setValue('input[name="email"]', 'testuser@example.com')
        .setValue('input[name="password"]', 'ValidPassword123')
        .click('button[type="submit"]');

      // 等待跳转到仪表盘
      browser.waitForUrlContains('/dashboard');

      // 验证登录成功
      browser.assert.visible('.welcome-message');
      browser.assert.containsText('.user-name', 'Test User');

      // 验证 URL 正确
      browser.assert.urlEquals('https://shopdemo.com/dashboard');

      browser.end();
    });

    it('勾选记住我后登录', function(browser) {
      browser
        .url('https://shopdemo.com/login')
        .waitForElementVisible('input[name="email"]');

      // 勾选记住我
      browser
        .setValue('input[name="email"]', 'testuser@example.com')
        .setValue('input[name="password"]', 'ValidPassword123')
        .click('input[name="remember"]')
        .click('button[type="submit"]');

      // 验证记住我 cookie 被设置
      browser.getCookie('remember_token', function(result) {
        this.assert.ok(result.value, '记住我令牌已设置');
      });

      browser.end();
    });
  });

  describe('登录失败场景', function() {
    it('使用错误密码登录失败', function(browser) {
      browser
        .url('https://shopdemo.com/login')
        .waitForElementVisible('input[name="email"]');

      browser
        .setValue('input[name="email"]', 'testuser@example.com')
        .setValue('input[name="password"]', 'WrongPassword')
        .click('button[type="submit"]');

      // 等待错误消息出现
      browser.waitForElementVisible('.error-message');

      // 验证错误消息
      browser.assert.containsText('.error-message', '用户名或密码错误');

      // 验证仍然在登录页面
      browser.assert.urlContains('/login');

      browser.end();
    });

    it('使用不存在的账号登录失败', function(browser) {
      browser
        .url('https://shopdemo.com/login')
        .waitForElementVisible('input[name="email"]');

      browser
        .setValue('input[name="email"]', 'nonexistent@example.com')
        .setValue('input[name="password"]', 'AnyPassword123')
        .click('button[type="submit"]');

      browser.waitForElementVisible('.error-message');
      browser.assert.containsText('.error-message', '账号不存在');

      browser.end();
    });

    it('使用空邮箱登录失败', function(browser) {
      browser
        .url('https://shopdemo.com/login')
        .waitForElementVisible('input[name="email"]');

      browser
        .setValue('input[name="email"]', '')
        .setValue('input[name="password"]', 'SomePassword')
        .click('button[type="submit"]');

      // 验证字段验证错误
      browser.waitForElementVisible('input[name="email"]:invalid');

      browser.end();
    });

    it('使用无效邮箱格式登录失败', function(browser) {
      browser
        .url('https://shopdemo.com/login')
        .waitForElementVisible('input[name="email"]');

      browser
        .setValue('input[name="email"]', 'not-an-email')
        .click('button[type="submit"]');

      browser.waitForElementVisible('.field-error');
      browser.assert.containsText('.field-error', '请输入有效的邮箱地址');

      browser.end();
    });
  });

  describe('页面链接测试', function() {
    it('忘记密码链接跳转正确', function(browser) {
      browser
        .url('https://shopdemo.com/login')
        .waitForElementVisible('input[name="email"]');

      browser.click('a[href="/forgot-password"]');
      browser.waitForUrlContains('/forgot-password');

      browser.assert.visible('input[name="email"]');
      browser.assert.containsText('h1', '重置密码');

      browser.end();
    });

    it('注册链接跳转正确', function(browser) {
      browser
        .url('https://shopdemo.com/login')
        .waitForElementVisible('input[name="email"]');

      browser.click('a[href="/register"]');
      browser.waitForUrlContains('/register');

      browser.assert.visible('form.register-form');

      browser.end();
    });
  });

  // 清理
  after(browser => {
    // 清理 session
    browser.deleteCookies();
    browser.end();
  });
});

4.1.7 产品搜索测试

// tests/product-search.test.js
// 产品搜索功能测试

describe('产品搜索功能测试', function() {

  before(browser => {
    browser.globals.waitForConditionTimeout = 15000;
  });

  describe('基本搜索功能', function() {
    it('搜索关键词显示相关产品', function(browser) {
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.search-input');

      // 执行搜索
      browser
        .setValue('.search-input', '手机')
        .click('.search-button');

      // 等待结果
      browser.waitForElementVisible('.search-results');

      // 验证至少有一个结果
      var resultCount;
      browser.elements('.product-card', function(result) {
        resultCount = result.value.length;
      });

      browser.perform(function() {
        console.log('找到', resultCount, '个结果');
      });

      // 验证结果包含搜索关键词
      browser.elements('.product-name', function(results) {
        results.value.forEach(function(item) {
          this.getText(item.ELEMENT, function(text) {
            console.log('产品:', text.value);
          });
        }.bind(this));
      });

      browser.end();
    });

    it('搜索无结果时显示提示', function(browser) {
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.search-input');

      browser
        .setValue('.search-input', 'xyznonexistent123')
        .click('.search-button');

      // 等待无结果提示
      browser.waitForElementVisible('.no-results');
      browser.assert.containsText('.no-results', '未找到相关产品');

      browser.end();
    });
  });

  describe('分类筛选功能', function() {
    it('按分类筛选产品', function(browser) {
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.category-menu');

      // 获取初始产品数量
      var initialCount;
      browser.elements('.product-card', result => {
        initialCount = result.value.length;
      });

      // 点击第一个分类
      browser.click('.category-menu a:first-child');

      // 等待产品更新
      browser.waitForElementVisible('.product-list');

      // 验证分类标题更新
      browser.assert.containsText('.category-title', '电子产品');

      // 验证产品数量
      browser.elements('.product-card', result => {
        browser.assert.ok(
          result.value.length <= initialCount,
          '筛选后产品数量应减少'
        );
      });

      browser.end();
    });

    it('按价格范围筛选', function(browser) {
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.filter-section');

      // 设置价格范围
      browser
        .setValue('input[name="min-price"]', '100')
        .setValue('input[name="max-price"]', '500')
        .click('button.apply-price');

      // 等待筛选结果
      browser.waitForElementVisible('.product-list');

      // 验证所有产品价格都在范围内
      browser.elements('.product-price', function(results) {
        results.value.forEach(function(item) {
          this.getText(item.ELEMENT, function(text) {
            // 提取价格数字
            var price = parseFloat(text.value.replace(/[^0-9.]/g, ''));
            browser.assert.ok(
              price >= 100 && price <= 500,
              '价格应该在 100-500 范围内'
            );
          });
        }.bind(this));
      });

      browser.end();
    });
  });

  describe('产品排序功能', function() {
    it('按价格从低到高排序', function(browser) {
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.sort-dropdown');

      // 选择价格升序
      browser
        .click('.sort-dropdown')
        .click('option[value="price-asc"]');

      // 等待排序完成
      browser.waitForElementVisible('.product-list');
      browser.pause(500);

      // 获取所有产品价格
      var prices = [];
      browser.elements('.product-price', function(results) {
        results.value.forEach(function(item) {
          this.getText(item.ELEMENT, function(text) {
            prices.push(parseFloat(text.value.replace(/[^0-9.]/g, '')));
          });
        }.bind(this));
      });

      // 验证排序(需要等待获取所有价格)
      browser.perform(function() {
        browser.pause(1000);
        // 这里可以添加价格排序验证
      });

      browser.end();
    });

    it('按销量排序', function(browser) {
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.sort-dropdown');

      browser
        .click('.sort-dropdown')
        .click('option[value="sales-desc"]');

      browser.waitForElementVisible('.product-list');

      // 验证热销标签可见
      browser.assert.visible('.bestseller-badge');

      browser.end();
    });
  });

  describe('产品卡片交互', function() {
    it('悬停产品显示快速操作', function(browser) {
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.product-card:first-child');

      // 悬停到第一个产品
      browser.moveToElement('.product-card:first-child');

      // 等待快速操作按钮出现
      browser.waitForElementVisible('.quick-actions');
      browser.assert.visible('.quick-view-button');
      browser.assert.visible('.add-to-cart-button');

      browser.end();
    });

    it('点击产品跳转到详情页', function(browser) {
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.product-card:first-child .product-name');

      // 获取产品名称
      var productName;
      browser.getText('.product-card:first-child .product-name', function(result) {
        productName = result.value;
      });

      // 点击产品卡片
      browser.click('.product-card:first-child');

      // 等待详情页加载
      browser.waitForUrlContains('/product/');
      browser.waitForElementVisible('.product-detail');

      // 验证产品名称匹配
      browser.assert.containsText('.product-title', productName);

      browser.end();
    });
  });
});

4.1.8 购物车功能测试

// tests/cart.test.js
// 购物车功能测试

describe('购物车功能测试', function() {

  before(browser => {
    // 确保登录状态
    browser
      .url('https://shopdemo.com/login')
      .waitForElementVisible('input[name="email"]')
      .setValue('input[name="email"]', 'testuser@example.com')
      .setValue('input[name="password"]', 'ValidPassword123')
      .click('button[type="submit"]')
      .waitForUrlContains('/dashboard');
  });

  describe('添加到购物车', function() {
    it('从产品列表添加商品到购物车', function(browser) {
      // 导航到产品列表
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.product-card:first-child');

      // 获取产品名称和价格
      var productName, productPrice;
      browser.getText('.product-card:first-child .product-name', function(result) {
        productName = result.value;
      });
      browser.getText('.product-card:first-child .product-price', function(result) {
        productPrice = result.value;
      });

      // 点击添加购物车
      browser.click('.product-card:first-child .add-to-cart');

      // 等待成功提示
      browser.waitForElementVisible('.notification.success');
      browser.assert.containsText('.notification.success', '已添加到购物车');

      // 点击购物车图标查看
      browser.click('.cart-icon');

      // 验证商品在购物车中
      browser.waitForElementVisible('.cart-item');
      browser.assert.containsText('.cart-item .item-name', productName);

      browser.end();
    });

    it('添加多件商品', function(browser) {
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.product-card');

      // 添加第一个商品
      browser.click('.product-card:nth-child(1) .add-to-cart');
      browser.waitForElementVisible('.notification.success');

      // 等待通知消失
      browser.pause(500);

      // 添加第二个商品
      browser.click('.product-card:nth-child(2) .add-to-cart');
      browser.waitForElementVisible('.notification.success');

      // 查看购物车
      browser.click('.cart-icon');
      browser.waitForElementVisible('.cart-container');

      // 验证有两个商品
      browser.elements('.cart-item', function(result) {
        browser.assert.equal(result.value.length, 2, '购物车应有 2 件商品');
      });

      browser.end();
    });
  });

  describe('修改购物车数量', function() {
    it('增加商品数量', function(browser) {
      // 添加商品到购物车
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.product-card:first-child')
        .click('.product-card:first-child .add-to-cart')
        .waitForElementVisible('.notification.success')
        .click('.cart-icon')
        .waitForElementVisible('.cart-container');

      // 获取初始数量
      browser.getValue('.cart-item:first-child .quantity-input', function(result) {
        var initialQty = parseInt(result.value);

        // 点击增加按钮
        browser.click('.cart-item:first-child .increase-btn');

        // 等待更新
        browser.pause(500);

        // 验证数量增加
        browser.getValue('.cart-item:first-child .quantity-input', function(newResult) {
          var newQty = parseInt(newResult.value);
          browser.assert.equal(newQty, initialQty + 1, '数量应增加 1');
        });
      });

      browser.end();
    });

    it('减少商品数量', function(browser) {
      // 添加并修改数量
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.product-card:first-child')
        .click('.product-card:first-child .add-to-cart')
        .waitForElementVisible('.notification.success')
        .click('.cart-icon')
        .waitForElementVisible('.cart-container');

      // 先增加数量
      browser.click('.cart-item:first-child .increase-btn');
      browser.pause(300);

      // 获取当前数量
      browser.getValue('.cart-item:first-child .quantity-input', function(result) {
        var currentQty = parseInt(result.value);

        // 点击减少按钮
        browser.click('.cart-item:first-child .decrease-btn');
        browser.pause(500);

        // 验证数量减少
        browser.getValue('.cart-item:first-child .quantity-input', function(newResult) {
          var newQty = parseInt(newResult.value);
          browser.assert.equal(newQty, currentQty - 1, '数量应减少 1');
        });
      });

      browser.end();
    });

    it('清空购物车', function(browser) {
      // 添加多个商品
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.product-card:nth-child(1) .add-to-cart')
        .click('.product-card:nth-child(1) .add-to-cart')
        .waitForElementVisible('.notification.success')
        .pause(300)
        .click('.product-card:nth-child(2) .add-to-cart')
        .waitForElementVisible('.notification.success')
        .click('.cart-icon')
        .waitForElementVisible('.cart-container');

      // 点击清空购物车
      browser.click('.clear-cart-btn');

      // 确认清空
      browser.acceptAlert();

      // 等待购物车清空
      browser.waitForElementVisible('.empty-cart-message');
      browser.assert.containsText('.empty-cart-message', '购物车是空的');

      browser.end();
    });
  });

  describe('删除商品', function() {
    it('删除单个商品', function(browser) {
      // 添加商品
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.product-card:first-child')
        .click('.product-card:first-child .add-to-cart')
        .waitForElementVisible('.notification.success')
        .click('.cart-icon')
        .waitForElementVisible('.cart-container');

      // 记录删除前的商品数量
      var beforeCount;
      browser.elements('.cart-item', result => {
        beforeCount = result.value.length;
      });

      // 点击删除按钮
      browser.click('.cart-item:first-child .remove-btn');

      // 等待删除动画完成
      browser.pause(500);

      // 验证商品数量减少
      browser.elements('.cart-item', result => {
        browser.assert.equal(
          result.value.length,
          beforeCount - 1,
          '商品数量应减少 1'
        );
      });

      browser.end();
    });
  });

  describe('价格计算', function() {
    it('验证价格计算正确', function(browser) {
      // 添加商品
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.product-card:first-child')
        .click('.product-card:first-child .add-to-cart')
        .waitForElementVisible('.notification.success')
        .click('.cart-icon')
        .waitForElementVisible('.cart-container');

      // 获取商品价格和数量
      var itemPrice;
      browser.getText('.cart-item:first-child .item-price', function(result) {
        itemPrice = parseFloat(result.value.replace(/[^0-9.]/g, ''));
      });

      browser.getValue('.cart-item:first-child .quantity-input', function(result) {
        var quantity = parseInt(result.value);

        // 计算预期小计
        var expectedSubtotal = itemPrice * quantity;

        // 获取实际小计
        browser.getText('.cart-subtotal', function(subtotalResult) {
          var actualSubtotal = parseFloat(
            subtotalResult.value.replace(/[^0-9.]/g, '')
          );

          browser.assert.equal(
            actualSubtotal,
            expectedSubtotal,
            '小计计算应该正确'
          );
        });
      });

      browser.end();
    });

    it('验证优惠券应用', function(browser) {
      // 添加商品到购物车
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.product-card:first-child')
        .click('.product-card:first-child .add-to-cart')
        .waitForElementVisible('.notification.success')
        .click('.cart-icon')
        .waitForElementVisible('.cart-container');

      // 获取原始总价
      var originalTotal;
      browser.getText('.cart-total', function(result) {
        originalTotal = parseFloat(result.value.replace(/[^0-9.]/g, ''));
      });

      // 输入优惠券
      browser
        .setValue('input[name="coupon"]', 'DISCOUNT10')
        .click('.apply-coupon-btn');

      // 等待优惠券应用
      browser.waitForElementVisible('.coupon-success');

      // 获取新总价
      browser.getText('.cart-total', function(result) {
        var newTotal = parseFloat(result.value.replace(/[^0-9.]/g, ''));

        // 验证总价降低(假设 10% 折扣)
        browser.assert.ok(
          newTotal < originalTotal,
          '使用优惠券后总价应降低'
        );

        // 验证折扣金额显示
        browser.assert.visible('.discount-amount');
      });

      browser.end();
    });
  });

  describe('结账流程', function() {
    it('导航到结账页面', function(browser) {
      // 添加商品
      browser
        .url('https://shopdemo.com/products')
        .waitForElementVisible('.product-card:first-child')
        .click('.product-card:first-child .add-to-cart')
        .waitForElementVisible('.notification.success')
        .click('.cart-icon')
        .waitForElementVisible('.cart-container');

      // 点击结账按钮
      browser.click('.checkout-btn');

      // 验证跳转到结账页面
      browser.waitForUrlContains('/checkout');
      browser.waitForElementVisible('.checkout-form');

      // 验证购物车商品信息
      browser.assert.visible('.order-summary');
      browser.assert.visible('.shipping-form');

      browser.end();
    });
  });

  after(browser => {
    browser.deleteCookies().end();
  });
});

五、常见使用场景

5.1 API 响应验证

// tests/api.test.js
// API 响应验证测试

describe('API 响应验证', function() {
  it('验证 API 返回正确的 JSON 数据', function(browser) {
    browser
      .url('https://api.example.com/data')
      .perform(function(done) {
        // 使用 execute 执行自定义 JavaScript
        this.execute(function() {
          return fetch('https://api.example.com/products')
            .then(response => response.json())
            .then(data => {
              // 在 DOM 中存储数据以便验证
              window.apiData = data;
              return data;
            });
        }, [], function(result) {
          // 验证返回的数据
          var data = result.value;
          console.log('API 返回数据:', JSON.stringify(data));

          // 验证数据结构
          this.assert.ok(Array.isArray(data), '返回数据应该是数组');
          this.assert.ok(data.length > 0, '数组不应为空');

          // 验证第一个元素的结构
          var firstItem = data[0];
          this.assert.ok(firstItem.id, '应有 id 字段');
          this.assert.ok(firstItem.name, '应有 name 字段');
          this.assert.ok(firstItem.price, '应有 price 字段');

          done();
        }.bind(this));
      });

    browser.end();
  });

  it('验证 API 错误处理', function(browser) {
    browser
      .perform(function(done) {
        this.execute(function() {
          return fetch('https://api.example.com/invalid-endpoint')
            .then(response => {
              if (!response.ok) {
                throw new Error('API 请求失败');
              }
              return response.json();
            })
            .catch(error => {
              return { error: error.message };
            });
        }, [], function(result) {
          var data = result.value;

          // 验证错误被正确处理
          this.assert.ok(data.error, '应包含错误信息');

          done();
        }.bind(this));
      });

    browser.end();
  });
});

5.2 表单验证测试

// tests/form-validation.test.js
// 表单验证测试

describe('表单验证测试', function() {

  describe('注册表单验证', function() {
    it('验证所有必填字段', function(browser) {
      browser
        .url('https://shopdemo.com/register')
        .waitForElementVisible('form')
        .click('button[type="submit"]');

      // 验证所有错误提示
      browser.assert.visible('input[name="username"] + .error');
      browser.assert.containsText('input[name="username"] + .error', '用户名不能为空');

      browser.assert.visible('input[name="email"] + .error');
      browser.assert.containsText('input[name="email"] + .error', '邮箱不能为空');

      browser.assert.visible('input[name="password"] + .error');
      browser.assert.containsText('input[name="password"] + .error', '密码不能为空');

      browser.end();
    });

    it('验证邮箱格式', function(browser) {
      browser
        .url('https://shopdemo.com/register')
        .waitForElementVisible('input[name="email"]')
        .setValue('input[name="email"]', 'invalid-email')
        .click('button[type="submit"]');

      browser.waitForElementVisible('input[name="email"] + .error');
      browser.assert.containsText(
        'input[name="email"] + .error',
        '请输入有效的邮箱地址'
      );

      browser.end();
    });

    it('验证密码强度', function(browser) {
      browser
        .url('https://shopdemo.com/register')
        .waitForElementVisible('input[name="password"]')
        .setValue('input[name="password"]', 'weak');

      // 等待密码强度指示器更新
      browser.pause(500);

      // 验证弱密码提示
      browser.assert.containsText('.password-strength', '密码强度:弱');

      // 尝试强密码
      browser
        .clearValue('input[name="password"]')
        .setValue('input[name="password"]', 'StrongPass123!');

      browser.pause(500);
      browser.assert.containsText('.password-strength', '密码强度:强');

      browser.end();
    });

    it('验证密码确认匹配', function(browser) {
      browser
        .url('https://shopdemo.com/register')
        .waitForElementVisible('form')
        .setValue('input[name="password"]', 'Password123')
        .setValue('input[name="confirm_password"]', 'DifferentPass123')
        .click('button[type="submit"]');

      browser.waitForElementVisible('input[name="confirm_password"] + .error');
      browser.assert.containsText(
        'input[name="confirm_password"] + .error',
        '两次密码输入不一致'
      );

      browser.end();
    });
  });

  describe('联系表单测试', function() {
    it('提交有效的联系表单', function(browser) {
      browser
        .url('https://shopdemo.com/contact')
        .waitForElementVisible('form.contact-form')
        .setValue('input[name="name"]', '张三')
        .setValue('input[name="email"]', 'zhangsan@example.com')
        .setValue('input[name="phone"]', '13812345678')
        .setValue('textarea[name="message"]', '这是一条测试留言')
        .click('button[type="submit"]');

      // 验证成功消息
      browser.waitForElementVisible('.success-message');
      browser.assert.containsText('.success-message', '感谢您的留言');

      browser.end();
    });
  });
});

5.3 响应式设计测试

// tests/responsive.test.js
// 响应式设计测试

describe('响应式设计测试', function() {

  const viewports = [
    { width: 320, height: 568, name: 'iPhone SE' },
    { width: 375, height: 667, name: 'iPhone X' },
    { width: 768, height: 1024, name: 'iPad' },
    { width: 1280, height: 800, name: '笔记本' },
    { width: 1920, height: 1080, name: '桌面端' }
  ];

  viewports.forEach(viewport => {
    it(`在 ${viewport.name} 视口下布局正确`, function(browser) {
      // 设置视口大小
      browser
        .url('https://shopdemo.com')
        .resizeWindow(viewport.width, viewport.height);

      // 验证导航菜单
      if (viewport.width < 768) {
        // 移动端应该显示汉堡菜单
        browser.assert.visible('.mobile-menu-toggle');
        browser.assert.hidden('.desktop-nav');
      } else {
        // 桌面端应该显示完整导航
        browser.assert.hidden('.mobile-menu-toggle');
        browser.assert.visible('.desktop-nav');
      }

      // 验证产品网格
      browser.url('https://shopdemo.com/products');

      if (viewport.width < 480) {
        // 超小屏幕:单列
        browser.assert.cssProperty('.product-grid', 'grid-template-columns', '1fr');
      } else if (viewport.width < 768) {
        // 小屏幕:两列
        browser.assert.cssProperty('.product-grid', 'grid-template-columns', '1fr 1fr');
      } else if (viewport.width < 1024) {
        // 中等屏幕:三列
        browser.assert.cssProperty('.product-grid', 'grid-template-columns', '1fr 1fr 1fr');
      } else {
        // 大屏幕:四列
        browser.assert.cssProperty('.product-grid', 'grid-template-columns', 'repeat(4, 1fr)');
      }

      // 验证字体大小
      var baseFontSize;
      browser.execute(function() {
        return parseFloat(getComputedStyle(document.body).fontSize);
      }, [], function(result) {
        baseFontSize = result.value;
        console.log(`视口 ${viewport.name} 基础字体大小: ${baseFontSize}px`);
      });

      browser.end();
    });
  });
});

5.4 文件上传测试

// tests/file-upload.test.js
// 文件上传测试

describe('文件上传测试', function() {

  it('上传图片文件', function(browser) {
    browser
      .url('https://shopdemo.com/upload')
      .waitForElementVisible('input[type="file"]');

    // 使用 setValue 设置文件路径
    browser.setValue(
      'input[type="file"]',
      require('path').resolve(__dirname, 'fixtures/test-image.jpg')
    );

    // 点击上传按钮
    browser.click('.upload-button');

    // 等待上传完成
    browser.waitForElementVisible('.upload-success');
    browser.assert.containsText('.upload-success', '上传成功');

    // 验证预览显示
    browser.assert.visible('.image-preview img');

    browser.end();
  });

  it('验证文件类型限制', function(browser) {
    browser
      .url('https://shopdemo.com/upload')
      .waitForElementVisible('input[type="file"]');

    // 尝试上传不允许的文件类型
    browser.setValue(
      'input[type="file"]',
      require('path').resolve(__dirname, 'fixtures/document.pdf')
    );

    browser.click('.upload-button');

    // 验证错误提示
    browser.waitForElementVisible('.file-error');
    browser.assert.containsText('.file-error', '只允许上传图片文件');

    browser.end();
  });

  it('验证文件大小限制', function(browser) {
    browser
      .url('https://shopdemo.com/upload')
      .waitForElementVisible('input[type="file"]');

    // 尝试上传超大文件
    browser.setValue(
      'input[type="file"]',
      require('path').resolve(__dirname, 'fixtures/large-image.jpg')
    );

    browser.click('.upload-button');

    // 验证错误提示
    browser.waitForElementVisible('.file-error');
    browser.assert.containsText('.file-error', '文件大小不能超过 5MB');

    browser.end();
  });
});

5.5 认证和授权测试

// tests/auth.test.js
// 认证授权测试

describe('认证授权测试', function() {

  describe('未登录用户访问受保护页面', function() {
    it('应重定向到登录页', function(browser) {
      browser
        .url('https://shopdemo.com/dashboard')
        .waitForUrlContains('/login');

      browser.assert.urlContains('/login');
      browser.assert.visible('form.login-form');

      browser.end();
    });

    it('应提示需要登录', function(browser) {
      browser
        .url('https://shopdemo.com/dashboard');

      // 等待重定向
      browser.pause(1000);

      browser.assert.containsText('.page-content', '请先登录');

      browser.end();
    });
  });

  describe('登录状态持久化', function() {
    it('刷新页面后保持登录状态', function(browser) {
      // 登录
      browser
        .url('https://shopdemo.com/login')
        .waitForElementVisible('input[name="email"]')
        .setValue('input[name="email"]', 'testuser@example.com')
        .setValue('input[name="password"]', 'ValidPassword123')
        .click('button[type="submit"]')
        .waitForUrlContains('/dashboard');

      // 验证已登录
      browser.assert.visible('.welcome-message');

      // 获取 session cookie
      var sessionCookie;
      browser.getCookie('session_id', function(result) {
        sessionCookie = result.value;
        this.assert.ok(sessionCookie, 'Session cookie 应该存在');
      }.bind(browser));

      // 刷新页面
      browser.refresh();

      // 验证仍然登录
      browser.waitForElementVisible('.welcome-message');
      browser.assert.visible('.welcome-message');

      browser.end();
    });
  });

  describe('角色权限测试', function() {
    it('普通用户不能访问管理后台', function(browser) {
      // 以普通用户登录
      browser
        .url('https://shopdemo.com/login')
        .waitForElementVisible('input[name="email"]')
        .setValue('input[name="email"]', 'user@example.com')
        .setValue('input[name="password"]', 'UserPassword123')
        .click('button[type="submit"]');

      // 尝试访问管理后台
      browser
        .url('https://shopdemo.com/admin')
        .pause(1000);

      // 验证被拒绝
      browser.assert.urlContains('/access-denied');
      browser.assert.containsText('.error-message', '权限不足');

      browser.end();
    });

    it('管理员可以访问管理后台', function(browser) {
      // 以管理员登录
      browser
        .url('https://shopdemo.com/login')
        .waitForElementVisible('input[name="email"]')
        .setValue('input[name="email"]', 'admin@example.com')
        .setValue('input[name="password"]', 'AdminPassword123')
        .click('button[type="submit"]');

      // 访问管理后台
      browser
        .url('https://shopdemo.com/admin')
        .waitForElementVisible('.admin-dashboard');

      browser.assert.visible('.admin-panel');
      browser.assert.visible('.user-management');

      browser.end();
    });
  });

  describe('会话过期测试', function() {
    it('会话过期后应重新登录', function(browser) {
      // 手动清除 session
      browser.deleteCookies();

      // 尝试访问需要认证的页面
      browser
        .url('https://shopdemo.com/dashboard')
        .waitForUrlContains('/login');

      // 验证需要重新登录
      browser.assert.visible('form.login-form');
      browser.assert.containsText('.page-content', '会话已过期');

      browser.end();
    });
  });
});

六、最佳实践与技巧

6.1 测试组织结构

e2e-tests/
├── nightwatch.conf.js        # 配置文件
├── globals.js                # 全局变量和钩子
├── commands/                 # 自定义命令
   ├── loginAsAdmin.js
   ├── waitForAjax.js
   └── screenshotOnFail.js
├── assertions/               # 自定义断言
   ├── hasClass.js
   └── isInViewport.js
├── page-objects/             # 页面对象
   ├── BasePage.js
   ├── login/
      └── LoginPage.js
   ├── dashboard/
      └── DashboardPage.js
   └── products/
       ├── ProductListPage.js
       └── ProductDetailPage.js
├── tests/                    # 测试文件
   ├── smoke/                # 冒烟测试
      └── basic-navigation.js
   ├── login/                # 登录功能
      ├── login-success.js
      └── login-failure.js
   ├── products/             # 产品功能
      ├── search.js
      ├── filter.js
      └── detail.js
   └── regression/            # 回归测试
       └── checkout-flow.js
├── reports/                  # 测试报告
   ├── screenshots/
   └── html-reports/
├── fixtures/                 # 测试数据
   ├── users.json
   ├── products.json
   └── images/
└── docker/                   # Docker 配置
    └── docker-compose.yml

6.2 自定义命令

// commands/waitForAjax.js
// 等待 AJAX 请求完成的自定义命令

/**
 * 等待所有 AJAX 请求完成
 * @param {number} timeout - 超时时间(毫秒)
 * @param {function} callback - 回调函数
 */
exports.command = function(timeout = 10000, callback) {
  const self = this;

  // jQuery 的 active 计数器表示正在进行的 AJAX 请求
  function checkAjaxComplete() {
    self.execute(function() {
      // 尝试多种方式检测 AJAX 状态
      if (typeof jQuery !== 'undefined') {
        return jQuery.active === 0;
      } else if (typeof window.axios !== 'undefined') {
        return window.axios.isCancel() || true;
      } else {
        // 使用全局计数器
        return window.ajaxComplete === true;
      }
    }, [], function(result) {
      if (result.value) {
        if (callback) {
          callback.call(self);
        } else {
          self.emit('complete');
        }
      } else {
        // 继续等待
        setTimeout(checkAjaxComplete, 100);
      }
    });
  }

  // 启动超时检查
  self.perform(function(done) {
    var startTime = Date.now();

    function checkWithTimeout() {
      self.execute(function() {
        if (typeof jQuery !== 'undefined') {
          return jQuery.active === 0;
        } else {
          return window.ajaxComplete === true;
        }
      }, [], function(result) {
        if (result.value) {
          done();
        } else if (Date.now() - startTime > timeout) {
          done(new Error('AJAX 请求超时'));
        } else {
          setTimeout(checkWithTimeout, 100);
        }
      });
    }

    checkWithTimeout();
  });

  return this;
};
// commands/takeScreenshot.js
// 截取屏幕截图的自定义命令

/**
 * 截取屏幕截图
 * @param {string} name - 截图名称
 */
exports.command = function(name) {
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
  const filename = `${name}-${timestamp}.png`;

  return this.saveScreenshot(`reports/screenshots/${filename}`, function() {
    console.log(`截图已保存: ${filename}`);
  });
};
// commands/loginAs.js
// 登录用户的自定义命令

/**
 * 使用指定用户登录
 * @param {string} username - 用户名
 * @param {string} password - 密码
 */
exports.command = function(username, password) {
  return this
    .url('/login')
    .waitForElementVisible('input[name="email"]')
    .setValue('input[name="email"]', username)
    .setValue('input[name="password"]', password)
    .click('button[type="submit"]')
    .waitForUrlContains('/dashboard');
};

6.3 自定义断言

// assertions/hasClass.js
// 验证元素具有指定类名的自定义断言

/**
 * 断言元素具有指定类名
 * @param {string} selector - 元素选择器
 * @param {string} className - 类名
 */
exports.assertion = function(selector, className) {
  // 缓存的值
  this.expected = className;
  this.message = `元素 ${selector} 应包含类名 ${className}`;

  // 执行断言
  this.pass = function(value) {
    return value.includes(className);
  };

  this.value = function(result) {
    return result;
  };

  this.command = function(callback) {
    const self = this;

    this.api.execute(function(selector) {
      const element = document.querySelector(selector);
      if (element) {
        return element.className;
      }
      return null;
    }, [selector], function(result) {
      callback.call(self, result.value);
    });

    return this;
  };
};
// assertions/isInViewport.js
// 验证元素在视口内的自定义断言

exports.assertion = function(selector) {
  this.message = `元素 ${selector} 应在视口内`;

  this.pass = function(value) {
    return value === true;
  };

  this.value = function(result) {
    return result.value;
  };

  this.command = function(callback) {
    this.api.execute(function(selector) {
      const element = document.querySelector(selector);
      if (!element) return false;

      const rect = element.getBoundingClientRect();
      return (
        rect.top >= 0 &&
        rect.left >= 0 &&
        rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
        rect.right <= (window.innerWidth || document.documentElement.clientWidth)
      );
    }, [selector], function(result) {
      callback.call(this, result.value);
    });

    return this;
  };
};

6.4 全局配置

// globals.js
// 全局配置和钩子

module.exports = {
  // 全局测试超时
  waitForConditionTimeout: 10000,
  waitForConditionPollInterval: 100,

  // 全局钩子
  before: function(browser) {
    console.log('========================================');
    console.log('测试开始 - 初始化环境');
    console.log('========================================');

    // 设置全局等待时间
    browser.globals.waitForConditionTimeout = 10000;

    // 设置测试环境
    browser.globals.testEnvironment = process.env.TEST_ENV || 'staging';

    // 初始化测试数据
    browser.globals.testUsers = {
      admin: {
        email: 'admin@test.com',
        password: 'AdminPass123'
      },
      user: {
        email: 'user@test.com',
        password: 'UserPass123'
      }
    };
  },

  after: function(browser) {
    console.log('========================================');
    console.log('测试完成 - 清理环境');
    console.log('========================================');

    browser.end();
  },

  // 每个测试套件前执行
  beforeEach: function(browser) {
    console.log(`开始测试: ${browser.currentTest.name}`);

    // 重置 cookies
    browser.deleteCookies();

    // 设置默认视口
    browser.resizeWindow(1280, 800);
  },

  // 每个测试套件后执行
  afterEach: function(browser, done) {
    console.log(`完成测试: ${browser.currentTest.name}`);
    console.log(`结果: ${browser.currentTest.results.passed ? '通过' : '失败'}`);

    // 如果测试失败且需要截图
    if (!browser.currentTest.results.passed && browser.currentTest.results.errors.length > 0) {
      const testName = browser.currentTest.name.replace(/[^a-z0-9]/gi, '_');
      const timestamp = new Date().toISOString().replace(/[:.]/g, '-');

      browser.saveScreenshot(
        `reports/screenshots/failure_${testName}_${timestamp}.png`,
        function() {
          done();
        }
      );
    } else {
      done();
    }
  }
};

6.5 并行测试配置

// nightwatch.conf.js (并行测试配置)

module.exports = {
  src_folders: ['tests'],
  output_folder: 'reports',

  // 启用并行测试
  parallelism: {
    // 每个 worker 的超时时间
    timeout: 300000,

    // 重试次数
    retries: 2,

    // Worker 数量(默认使用 CPU 核心数)
    workers: 'auto'
  },

  webdriver: {
    start_process: true,
    port: 9515,
    server_path: '',
    cli_args: [
      '--no-sandbox',
      '--disable-setuid-sandbox'
    ]
  },

  test_settings: {
    default: {
      launch_url: 'https://shopdemo.com',
      screenshots: {
        enabled: true,
        on_failure: true,
        path: 'reports/screenshots'
      },
      desiredCapabilities: {
        browserName: 'chrome',
        'goog:chromeOptions': {
          args: [
            '--headless',
            '--no-sandbox',
            '--disable-gpu',
            '--window-size=1280,800',
            '--disable-dev-shm-usage'
          ]
        }
      }
    }
  }
};

6.6 CI/CD 集成

GitHub Actions 配置

# .github/workflows/e2e-tests.yml

name: E2E Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  e2e-tests:
    runs-on: ubuntu-latest

    # ============================================
    # 服务容器配置
    # ============================================
    services:
      chrome:
        image: selenium/standalone-chrome:latest
        ports:
          - 4444:4444
      app:
        image: node:18
        env:
          NODE_ENV: test
          APP_URL: https://shopdemo.com
        ports:
          - 3000:3000
        options: >-
          --health-cmd "curl -f http://localhost:3000/health"
          --health-interval 30s
          --health-timeout 10s
          --health-retries 5

    steps:
      # ============================================
      # 检出代码
      # ============================================
      - name: Checkout code
        uses: actions/checkout@v3

      # ============================================
      # 设置 Node.js 环境
      # ============================================
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'

      # ============================================
      # 安装依赖
      # ============================================
      - name: Install dependencies
        run: npm ci

      # ============================================
      # 运行测试
      # ============================================
      - name: Run E2E tests
        run: npm test
        env:
          CHROME_PORT: 4444
          APP_URL: https://shopdemo.com

      # ============================================
      # 上传测试报告
      # ============================================
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: test-reports
          path: reports/

      # ============================================
      # 上传失败截图
      # ============================================
      - name: Upload failure screenshots
        if: failure()
        uses: actions/upload-artifact@v3
        with:
          name: failure-screenshots
          path: reports/screenshots/*.png

6.7 测试数据管理

// fixtures/users.json
{
  "validUsers": [
    {
      "id": "user-001",
      "email": "admin@test.com",
      "password": "AdminPass123!",
      "role": "admin",
      "name": "管理员"
    },
    {
      "id": "user-002", 
      "email": "test@example.com",
      "password": "TestPass123!",
      "role": "user",
      "name": "测试用户"
    }
  ],
  "invalidUsers": [
    {
      "email": "invalid-email",
      "password": "short",
      "expectedError": "邮箱格式不正确"
    },
    {
      "email": "nonexistent@test.com",
      "password": "WrongPassword",
      "expectedError": "用户不存在"
    }
  ]
}
// tests/data-driven.test.js
// 数据驱动测试示例

const users = require('../fixtures/users.json');

describe('数据驱动登录测试', function() {

  users.validUsers.forEach(user => {
    it(`使用 ${user.name} 登录应该成功`, function(browser) {
      browser
        .url('https://shopdemo.com/login')
        .waitForElementVisible('input[name="email"]')
        .setValue('input[name="email"]', user.email)
        .setValue('input[name="password"]', user.password)
        .click('button[type="submit"]')
        .waitForUrlContains('/dashboard');

      // 验证用户信息
      browser.assert.containsText('.user-name', user.name);
      browser.assert.visible(`.role-badge.${user.role}`);

      browser.end();
    });
  });

  users.invalidUsers.forEach(user => {
    it(`验证错误: ${user.expectedError}`, function(browser) {
      browser
        .url('https://shopdemo.com/login')
        .waitForElementVisible('input[name="email"]')
        .setValue('input[name="email"]', user.email)
        .setValue('input[name="password"]', user.password)
        .click('button[type="submit"]')
        .waitForElementVisible('.error-message');

      browser.assert.containsText('.error-message', user.expectedError);

      browser.end();
    });
  });
});

6.8 调试技巧

// 调试模式下的测试示例

describe('调试测试', function() {
  it('带详细日志的测试', function(browser) {
    browser
      .url('https://shopdemo.com')
      .perform(function(done) {
        console.log('===== 开始调试 =====');
        console.log('当前 URL:', this.launchUrl);

        // 获取页面信息
        this.execute(function() {
          return {
            title: document.title,
            url: window.location.href,
            readyState: document.readyState
          };
        }, [], function(result) {
          console.log('页面标题:', result.value.title);
          console.log('页面状态:', result.value.readyState);
        });

        done();
      })
      .pause(5000)  // 暂停以便观察
      .end();
  });

  it('使用 debug 命令', function(browser) {
    browser
      .url('https://shopdemo.com')
      .waitForElementVisible('body');

    // 在这里设置断点
    // 可以打开 DevTools 检查浏览器状态

    browser.debug();  // 暂停执行进入调试模式

    // 继续执行
    browser.end();
  });
});

6.9 性能测试

// tests/performance.test.js
// 性能测试

describe('性能测试', function() {

  it('测量页面加载时间', function(browser) {
    const startTime = Date.now();

    browser.url('https://shopdemo.com');

    browser.perform(function() {
      const endTime = Date.now();
      const loadTime = endTime - startTime;

      console.log(`页面加载时间: ${loadTime}ms`);

      // 断言加载时间
      browser.assert.ok(
        loadTime < 3000,
        `页面加载时间应小于 3 秒,实际: ${loadTime}ms`
      );
    });

    browser.end();
  });

  it('测量 API 响应时间', function(browser) {
    const startTime = Date.now();

    browser.perform(function(done) {
      this.execute(function() {
        const start = Date.now();

        return fetch('/api/products')
          .then(response => {
            const end = Date.now();
            return {
              status: response.status,
              time: end - start
            };
          });
      }, [], function(result) {
        console.log(`API 响应时间: ${result.value.time}ms`);

        this.assert.ok(
          result.value.time < 1000,
          `API 响应时间应小于 1 秒,实际: ${result.value.time}ms`
        );

        this.assert.equal(result.value.status, 200);

        done();
      }.bind(this));
    });

    browser.end();
  });

  it('测量关键元素渲染时间', function(browser) {
    browser.url('https://shopdemo.com');

    const startTime = Date.now();

    browser.waitForElementVisible('.main-content', function() {
      const renderTime = Date.now() - startTime;
      console.log(`主要内容渲染时间: ${renderTime}ms`);

      browser.assert.ok(
        renderTime < 2000,
        `渲染时间应小于 2 秒,实际: ${renderTime}ms`
      );
    });

    browser.end();
  });
});

七、总结与资源

7.1 Nightwatch.js 核心要点回顾

Nightwatch.js 学习路径:
│
├── 基础入门
│   ├── 安装和配置
│   ├── 编写第一个测试
│   └── 运行和调试测试
│
├── 核心功能
│   ├── 元素定位(CSS/XPath)
│   ├── 浏览器操作(导航、窗口、iframe)
│   ├── 交互操作(点击、输入、拖拽)
│   ├── 断言系统(assert/expect)
│   └── 等待机制(显式/隐式)
│
├── 进阶特性
│   ├── Page Object 模式
│   ├── 自定义命令和断言
│   ├── 全局配置和钩子
│   └── 测试数据管理
│
├── 高级应用
│   ├── 并行测试执行
│   ├── CI/CD 集成
│   ├── 响应式测试
│   └── 性能测试
│
└── 最佳实践
    ├── 测试组织结构
    ├── 代码复用
    ├── 维护性优化
    └── 持续改进

7.2 官方资源链接

官方文档和资源:
├── Nightwatch.js 官方文档: https://nightwatchjs.org/
├── GitHub 仓库: https://github.com/nightwatchjs/nightwatch
├── API 参考: https://nightwatchjs.org/api/
├── 示例项目: https://github.com/nightwatchjs/nightwatch/tree/master/examples
└── 社区论坛: https://discuss.nightwatchjs.org/

7.3 相关推荐项目

测试工具生态:
├── Selenium WebDriver - WebDriver 协议实现
├── Playwright - 微软出品的端到端测试框架
├── Cypress - 现代前端测试框架
├── Puppeteer - Chrome/Chromium 控制库
├── WebdriverIO - JavaScript 的 WebDriver 封装
├── TestCafe - 无需 WebDriver 的测试框架
└── Jest + Testing Library - React 组件测试

7.4 继续学习路径

成为 Nightwatch 专家的学习路径:
│
├── 第一阶段:基础掌握(1-2 周)
│   ├── 完成官方入门教程
│   ├── 编写 10+ 个基础测试用例
│   └── 掌握元素定位和断言
│
├── 第二阶段:实践应用(2-4 周)
│   ├── 在实际项目中应用
│   ├── 掌握 Page Object 模式
│   └── 实现完整的测试套件
│
├── 第三阶段:高级特性(1-2 月)
│   ├── 自定义命令和断言开发
│   ├── CI/CD 集成实践
│   └── 测试报告优化
│
└── 第四阶段:团队贡献(持续)
    ├── 测试框架贡献
    ├── 最佳实践分享
    └── 团队培训指导

7.5 结语

通过这篇完整的教程,你已经学习了 Nightwatch.js 的各个方面,从基础的安装配置到高级的 CI/CD 集成。Nightwatch.js 凭借其简洁的 API、强大的功能和优秀的可扩展性,成为了端到端测试领域的优秀选择。

记住,优秀的测试不是一蹴而就的,需要在实践中不断优化和完善。建议你:

实践建议:
├── 从简单的冒烟测试开始
├── 逐步覆盖核心业务流程
├── 保持测试代码的整洁和可维护
├── 持续关注测试执行时间和稳定性
├── 定期回顾和改进测试策略
└── 与团队分享测试最佳实践

现在,是时候开始你的 Nightwatch.js 之旅了。祝你测试愉快!


相关文章推荐

如果内容对您有帮助,欢迎打赏

您的支持是我继续创作的动力

前往打赏页面

评论区

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注