Skip to content

行为驱动开发(BDD)

📋 概述

行为驱动开发(Behavior-Driven Development, BDD)是一种敏捷软件开发技术,强调通过定义系统行为来驱动开发过程。BDD使用自然语言描述功能需求,促进业务人员、开发者和测试人员之间的协作。

🎯 学习目标

  • 理解BDD的核心概念和价值
  • 掌握Given-When-Then的语法结构
  • 学会使用Cucumber和Jest编写BDD测试
  • 了解BDD与TDD的区别和联系

🔍 BDD核心概念

BDD的三个层次

mermaid
graph TB
    A[需求分析] --> B[场景定义]
    B --> C[自动化测试]
    
    A --> A1[业务价值]
    A --> A2[用户故事]
    
    B --> B1[Given-When-Then]
    B --> B2[场景描述]
    
    C --> C1[步骤定义]
    C --> C2[测试执行]

BDD语法结构

gherkin
Feature: 用户注册
  As a 访客
  I want to 注册账户
  So that I can 使用网站功能

  Scenario: 成功注册新用户
    Given 我是一个新用户
    When 我填写有效的注册信息
    And 我点击注册按钮
    Then 我应该看到注册成功消息
    And 我应该收到确认邮件

  Scenario: 注册时邮箱已存在
    Given 系统中已存在邮箱 "existing@example.com"
    When 我尝试用邮箱 "existing@example.com" 注册
    Then 我应该看到错误消息 "邮箱已存在"
    And 注册应该失败

🛠 Node.js中的BDD实现

使用Cucumber.js

bash
# 安装Cucumber.js
npm install --save-dev @cucumber/cucumber
npm install --save-dev @cucumber/pretty-formatter
javascript
// cucumber.js - Cucumber配置
module.exports = {
  default: {
    require: [
      'tests/features/step-definitions/**/*.js',
      'tests/features/support/**/*.js'
    ],
    format: [
      'progress-bar',
      'json:tests/reports/cucumber.json'
    ],
    formatOptions: {
      snippetInterface: 'async-await'
    }
  }
};

功能文件(Feature Files)

gherkin
# tests/features/user-registration.feature
Feature: 用户注册功能
  As a 网站访客
  I want to 创建新账户
  So that I can 访问受保护的功能

  Background:
    Given 注册页面已加载
    And 数据库是空的

  Scenario: 使用有效信息注册
    Given 我在注册页面
    When 我输入以下信息:
      | 字段     | 值                    |
      | 姓名     | John Doe             |
      | 邮箱     | john@example.com     |
      | 密码     | SecurePassword123    |
      | 确认密码 | SecurePassword123    |
    And 我点击"注册"按钮
    Then 我应该被重定向到仪表板页面
    And 我应该看到欢迎消息 "欢迎, John Doe"
    And 用户 "john@example.com" 应该存在于数据库中

  Scenario Outline: 无效输入验证
    Given 我在注册页面
    When 我输入 "<字段>""<值>"
    And 我点击"注册"按钮
    Then 我应该看到错误消息 "<错误消息>"
    And 我应该仍在注册页面

    Examples:
      | 字段   | 值              | 错误消息         |
      | 邮箱   |                 | 邮箱不能为空     |
      | 邮箱   | invalid-email   | 邮箱格式无效     |
      | 密码   |                 | 密码不能为空     |
      | 密码   | 123             | 密码至少8个字符  |
      | 姓名   |                 | 姓名不能为空     |

步骤定义(Step Definitions)

javascript
// tests/features/step-definitions/user-registration.steps.js
const { Given, When, Then, Before, After } = require('@cucumber/cucumber');
const { expect } = require('@jest/globals');
const request = require('supertest');
const app = require('@/app');
const User = require('@/models/user');

// 测试状态存储
let testContext = {};

Before(async function() {
  // 每个场景前清理
  testContext = {};
  await User.deleteMany({});
});

After(async function() {
  // 每个场景后清理
  testContext = {};
});

// Background步骤
Given('注册页面已加载', async function() {
  // 模拟页面加载
  testContext.pageLoaded = true;
});

Given('数据库是空的', async function() {
  const userCount = await User.countDocuments();
  expect(userCount).toBe(0);
});

// 场景步骤
Given('我在注册页面', function() {
  testContext.currentPage = 'register';
});

Given('系统中已存在邮箱 {string}', async function(email) {
  await User.create({
    name: 'Existing User',
    email: email,
    password: 'hashedPassword'
  });
});

When('我输入以下信息:', function(dataTable) {
  testContext.formData = {};
  const rows = dataTable.hashes();
  
  rows.forEach(row => {
    const field = row['字段'];
    const value = row['值'];
    
    switch(field) {
      case '姓名':
        testContext.formData.name = value;
        break;
      case '邮箱':
        testContext.formData.email = value;
        break;
      case '密码':
        testContext.formData.password = value;
        break;
      case '确认密码':
        testContext.formData.confirmPassword = value;
        break;
    }
  });
});

When('我输入 {string} 为 {string}', function(field, value) {
  if (!testContext.formData) {
    testContext.formData = {};
  }
  
  switch(field) {
    case '邮箱':
      testContext.formData.email = value;
      break;
    case '密码':
      testContext.formData.password = value;
      break;
    case '姓名':
      testContext.formData.name = value;
      break;
  }
});

When('我尝试用邮箱 {string} 注册', function(email) {
  testContext.formData = {
    name: 'Test User',
    email: email,
    password: 'password123'
  };
});

When('我点击{string}按钮', async function(buttonText) {
  if (buttonText === '注册') {
    // 执行注册API调用
    testContext.response = await request(app)
      .post('/api/auth/register')
      .send(testContext.formData);
  }
});

Then('我应该被重定向到仪表板页面', function() {
  expect(testContext.response.status).toBe(201);
  expect(testContext.response.body.redirectUrl).toBe('/dashboard');
});

Then('我应该看到欢迎消息 {string}', function(expectedMessage) {
  expect(testContext.response.body.message).toContain(expectedMessage);
});

Then('用户 {string} 应该存在于数据库中', async function(email) {
  const user = await User.findOne({ email });
  expect(user).toBeTruthy();
  expect(user.email).toBe(email);
});

Then('我应该看到错误消息 {string}', function(expectedError) {
  expect(testContext.response.status).toBeGreaterThanOrEqual(400);
  expect(testContext.response.body.error).toContain(expectedError);
});

Then('我应该仍在注册页面', function() {
  expect(testContext.response.body.redirectUrl).toBeUndefined();
});

Then('注册应该失败', function() {
  expect(testContext.response.status).toBeGreaterThanOrEqual(400);
});

支持文件

javascript
// tests/features/support/world.js
const { setWorldConstructor } = require('@cucumber/cucumber');

class CustomWorld {
  constructor() {
    this.context = {};
  }
  
  setContext(key, value) {
    this.context[key] = value;
  }
  
  getContext(key) {
    return this.context[key];
  }
}

setWorldConstructor(CustomWorld);

🔧 使用Jest进行BDD

Jest BDD风格测试

javascript
// tests/bdd/user-management.spec.js
describe('Feature: 用户管理', () => {
  describe('Scenario: 创建新用户', () => {
    let userService;
    let mockUserRepository;
    let result;
    let error;
    
    beforeEach(() => {
      // Given 步骤通常在 beforeEach 中设置
    });
    
    describe('Given 我有有效的用户数据', () => {
      beforeEach(() => {
        // 设置测试数据和模拟
        mockUserRepository = {
          findByEmail: jest.fn(),
          create: jest.fn()
        };
        userService = new UserService(mockUserRepository);
      });
      
      describe('When 我创建用户', () => {
        beforeEach(async () => {
          const userData = {
            name: 'John Doe',
            email: 'john@example.com',
            password: 'securePassword123'
          };
          
          mockUserRepository.findByEmail.mockResolvedValue(null);
          mockUserRepository.create.mockResolvedValue({
            id: '123',
            ...userData,
            createdAt: new Date()
          });
          
          try {
            result = await userService.createUser(userData);
          } catch (err) {
            error = err;
          }
        });
        
        it('Then 用户应该被成功创建', () => {
          expect(error).toBeUndefined();
          expect(result).toEqual(
            expect.objectContaining({
              id: expect.any(String),
              name: 'John Doe',
              email: 'john@example.com'
            })
          );
        });
        
        it('And 应该检查邮箱是否已存在', () => {
          expect(mockUserRepository.findByEmail)
            .toHaveBeenCalledWith('john@example.com');
        });
        
        it('And 应该保存用户到数据库', () => {
          expect(mockUserRepository.create)
            .toHaveBeenCalledWith(
              expect.objectContaining({
                name: 'John Doe',
                email: 'john@example.com'
              })
            );
        });
      });
    });
  });
  
  describe('Scenario: 邮箱已存在的情况', () => {
    let userService;
    let mockUserRepository;
    let error;
    
    describe('Given 系统中已存在邮箱', () => {
      beforeEach(() => {
        mockUserRepository = {
          findByEmail: jest.fn(),
          create: jest.fn()
        };
        userService = new UserService(mockUserRepository);
        
        // 模拟邮箱已存在
        mockUserRepository.findByEmail.mockResolvedValue({
          id: '456',
          email: 'existing@example.com'
        });
      });
      
      describe('When 我尝试用相同邮箱注册', () => {
        beforeEach(async () => {
          const userData = {
            name: 'Another User',
            email: 'existing@example.com',
            password: 'password123'
          };
          
          try {
            await userService.createUser(userData);
          } catch (err) {
            error = err;
          }
        });
        
        it('Then 应该抛出邮箱已存在错误', () => {
          expect(error).toBeDefined();
          expect(error.message).toContain('邮箱已存在');
        });
        
        it('And 不应该创建新用户', () => {
          expect(mockUserRepository.create).not.toHaveBeenCalled();
        });
      });
    });
  });
});

BDD测试工具类

javascript
// tests/bdd/helpers/bdd-helpers.js
class BDDHelper {
  static describe(description, tests) {
    return describe(`Feature: ${description}`, tests);
  }
  
  static scenario(description, tests) {
    return describe(`Scenario: ${description}`, tests);
  }
  
  static given(description, setup) {
    return describe(`Given ${description}`, setup);
  }
  
  static when(description, action) {
    return describe(`When ${description}`, action);
  }
  
  static then(description, assertion) {
    return it(`Then ${description}`, assertion);
  }
  
  static and(description, assertion) {
    return it(`And ${description}`, assertion);
  }
  
  static but(description, assertion) {
    return it(`But ${description}`, assertion);
  }
}

// 导出BDD风格的函数
module.exports = {
  Feature: BDDHelper.describe,
  Scenario: BDDHelper.scenario,
  Given: BDDHelper.given,
  When: BDDHelper.when,
  Then: BDDHelper.then,
  And: BDDHelper.and,
  But: BDDHelper.but
};
javascript
// 使用BDD助手的测试
const { Feature, Scenario, Given, When, Then, And } = require('./helpers/bdd-helpers');

Feature('订单处理系统', () => {
  Scenario('成功处理订单', () => {
    let orderService;
    let order;
    let result;
    
    Given('我有一个有效的订单', () => {
      beforeEach(() => {
        orderService = new OrderService();
        order = {
          customerId: '123',
          items: [
            { productId: 'p1', quantity: 2, price: 100 },
            { productId: 'p2', quantity: 1, price: 50 }
          ]
        };
      });
    });
    
    When('我处理订单', () => {
      beforeEach(async () => {
        result = await orderService.processOrder(order);
      });
    });
    
    Then('订单应该被成功处理', () => {
      expect(result.status).toBe('processed');
      expect(result.total).toBe(250);
    });
    
    And('应该生成订单号', () => {
      expect(result.orderNumber).toBeDefined();
      expect(result.orderNumber).toMatch(/^ORD-\\d{8}$/);
    });
    
    And('应该计算正确的总价', () => {
      expect(result.total).toBe(250); // (2*100) + (1*50)
    });
  });
});

📊 BDD测试报告

自定义报告生成器

javascript
// tests/bdd/reporters/bdd-reporter.js
class BDDReporter {
  constructor() {
    this.features = [];
    this.currentFeature = null;
    this.currentScenario = null;
  }
  
  onRunStart() {
    console.log('🚀 开始BDD测试执行');
  }
  
  onTestFileStart(test) {
    this.currentFeature = {
      name: test.path,
      scenarios: [],
      status: 'pending'
    };
  }
  
  onTestStart(test) {
    this.currentScenario = {
      name: test.name,
      steps: [],
      status: 'running',
      startTime: Date.now()
    };
  }
  
  onTestComplete(test, result) {
    this.currentScenario.status = result.status;
    this.currentScenario.duration = Date.now() - this.currentScenario.startTime;
    this.currentScenario.errors = result.errors;
    
    this.currentFeature.scenarios.push(this.currentScenario);
  }
  
  onTestFileComplete(test, result) {
    this.currentFeature.status = result.success ? 'passed' : 'failed';
    this.features.push(this.currentFeature);
  }
  
  onRunComplete() {
    this.generateReport();
  }
  
  generateReport() {
    const report = {
      summary: this.generateSummary(),
      features: this.features,
      timestamp: new Date().toISOString()
    };
    
    // 生成HTML报告
    this.generateHTMLReport(report);
    
    // 生成JSON报告
    this.generateJSONReport(report);
    
    // 控制台输出
    this.printSummary(report.summary);
  }
  
  generateSummary() {
    const totalFeatures = this.features.length;
    const passedFeatures = this.features.filter(f => f.status === 'passed').length;
    const totalScenarios = this.features.reduce((sum, f) => sum + f.scenarios.length, 0);
    const passedScenarios = this.features.reduce((sum, f) => 
      sum + f.scenarios.filter(s => s.status === 'passed').length, 0
    );
    
    return {
      features: {
        total: totalFeatures,
        passed: passedFeatures,
        failed: totalFeatures - passedFeatures
      },
      scenarios: {
        total: totalScenarios,
        passed: passedScenarios,
        failed: totalScenarios - passedScenarios
      },
      passRate: totalScenarios > 0 ? (passedScenarios / totalScenarios * 100).toFixed(2) : 0
    };
  }
  
  printSummary(summary) {
    console.log('\\n📊 BDD测试报告');
    console.log('================');
    console.log(`功能: ${summary.features.passed}/${summary.features.total} 通过`);
    console.log(`场景: ${summary.scenarios.passed}/${summary.scenarios.total} 通过`);
    console.log(`通过率: ${summary.passRate}%`);
    
    if (summary.scenarios.failed > 0) {
      console.log(`\\n❌ 失败的场景:`);
      this.features.forEach(feature => {
        feature.scenarios.filter(s => s.status === 'failed').forEach(scenario => {
          console.log(`  - ${feature.name}: ${scenario.name}`);
        });
      });
    }
  }
}

module.exports = BDDReporter;

🔄 BDD与TDD的结合

由外而内的开发方法

javascript
// 1. 从BDD场景开始(外层)
describe('Feature: 用户购物流程', () => {
  describe('Scenario: 用户完成购买', () => {
    // BDD层面的集成测试
    it('应该允许用户完成整个购买流程', async () => {
      // Given - 用户有商品在购物车中
      const user = await createTestUser();
      const cart = await addItemsToCart(user.id, testItems);
      
      // When - 用户进行结账
      const order = await checkoutService.processCheckout(user.id, cart.id);
      
      // Then - 订单应该被创建并支付成功
      expect(order.status).toBe('completed');
      expect(order.total).toBe(expectedTotal);
    });
  });
});

// 2. 然后用TDD实现具体组件(内层)
describe('CheckoutService (TDD)', () => {
  // TDD红-绿-重构循环
  it('应该计算订单总价', () => {
    // 红色:写失败测试
    const items = [
      { price: 100, quantity: 2 },
      { price: 50, quantity: 1 }
    ];
    
    const total = checkoutService.calculateTotal(items);
    expect(total).toBe(250);
  });
  
  // 绿色:实现最少代码
  // 重构:改进设计
});

多层次测试策略

javascript
// 测试金字塔中的BDD应用
const TestStrategy = {
  BDD_ACCEPTANCE: {
    level: 'E2E/Integration',
    purpose: '验证用户故事和业务需求',
    tools: ['Cucumber', 'Cypress'],
    focus: '业务价值和用户体验'
  },
  
  TDD_UNIT: {
    level: 'Unit',
    purpose: '驱动代码设计和实现',
    tools: ['Jest', 'Mocha'],
    focus: '代码质量和功能正确性'
  },
  
  COMBINED_APPROACH: {
    workflow: [
      '1. 编写BDD场景描述需求',
      '2. 运行BDD测试(红色)',
      '3. 用TDD实现所需组件',
      '4. 运行BDD测试(绿色)',
      '5. 重构和优化'
    ]
  }
};

📝 BDD最佳实践

编写好的场景

gherkin
# ✅ 好的场景
Scenario: 顾客购买商品
  Given 我是已登录用户
  And 购物车中有商品
  When 我进行结账
  Then 订单应该被创建
  And 我应该收到确认邮件

# ❌ 避免的场景
Scenario: 测试购买功能
  Given 用户登录系统
  When 点击购买按钮
  Then 系统处理订单
  And 数据库更新
  And 发送邮件API被调用

场景组织原则

javascript
const BDDBestPractices = {
  SCENARIO_WRITING: {
    DO: [
      '使用业务语言,避免技术术语',
      '专注于行为和结果,不是实现',
      '保持场景简短和专注',
      '使用具体的例子和数据',
      '确保场景是可执行的'
    ],
    DONT: [
      '不要描述UI细节',
      '不要测试多个业务规则',
      '不要使用模糊的语言',
      '不要包含实现细节',
      '不要编写过于复杂的场景'
    ]
  },
  
  STEP_DEFINITIONS: {
    DO: [
      '保持步骤定义简单',
      '使用参数化提高重用性',
      '实现幂等的Given步骤',
      '确保When步骤触发行为',
      '在Then步骤中进行断言'
    ],
    DONT: [
      '不要在步骤间共享状态',
      '不要在Given中包含断言',
      '不要让步骤定义过于复杂',
      '不要忽略错误处理',
      '不要硬编码测试数据'
    ]
  }
};

📝 总结

BDD为Node.js开发提供了以行为为中心的开发方法:

  • 需求驱动:从业务需求和用户故事开始
  • 协作促进:使用自然语言促进团队沟通
  • 活文档:可执行的规格说明文档
  • 质量保证:确保开发符合业务期望
  • TDD集成:与TDD结合实现全面质量保证

BDD特别适用于需要业务人员深度参与的项目,有助于确保交付的软件真正满足用户需求。

🔗 相关资源