开发过程中的测试

软件测试的层次与 V-Model

在软件开发生命周期中,测试不是一个单一的活动,而是贯穿始终、分层进行的过程。V-Model 是一个经典的开发模型,它清晰地展示了开发阶段与测试阶段的对应关系。

graph LR
    subgraph 开发
        A[用户需求] --> B[需求分析与系统设计];
        B --> C[概要设计];
        C --> D[详细设计];
        D --> E[编码];
    end

    subgraph 测试
        F[单元测试] --> G[集成测试];
        G --> H[系统测试];
        H --> I[验收测试];
    end

    E -- 对应 --> F;
    D -- 对应 --> G;
    C -- 对应 --> H;
    B -- 对应 --> I;

    style A fill:#cde4ff
    style I fill:#cde4ff
    style B fill:#d7e9ff
    style H fill:#d7e9ff
    style C fill:#e2eeff
    style G fill:#e2eeff
    style D fill:#ecf3ff
    style F fill:#ecf3ff
    style E fill:#f7f8ff
  • 单元测试 验证编码是否符合详细设计。
  • 集成测试 验证模块组合后是否符合概要设计。
  • 系统测试 验证整个系统是否符合系统设计和需求。
  • 验收测试 验证最终产品是否满足用户的实际需求。

测试金字塔

测试金字塔(Testing Pyramid)是一个广为接受的理念,它指导了不同层次测试在数量和投入上的理想配比。

graph TD
    subgraph "冰淇淋甜筒(反模式)"
        direction TB
        U2[单元测试] --> I2[集成测试];
        I2 --> G2[UI 测试];
        G2 --> M2[大量手动测试];
    end

    subgraph "测试金字塔(理想模型)"
        direction TB
        M[手动/探索性测试] --> G[UI/端到端测试];
        G --> I[集成/API 测试];
        I --> U[单元测试];
    end

    style M fill:#ff9a8b
    style G fill:#ffb3a7
    style I fill:#ffcccb
    style U fill:#ffe5e5

    style M2 fill:#ff9a8b,stroke-width:2px
    style G2 fill:#ffb3a7
    style I2 fill:#ffcccb
    style U2 fill:#ffe5e5,stroke-width:2px
  • 基础(单元测试):数量最多。它们运行速度快,反馈及时,能精确定位问题,是保证代码质量的基石。
  • 中间(集成测试):数量适中。用于测试模块间的交互和接口。
  • 顶层(UI/端到端测试):数量最少。它们模拟真实用户操作,但运行慢、不稳定且维护成本高。

测试反模式:冰淇淋甜筒

当项目中缺乏单元测试和集成测试,过度依赖手动测试和 UI 自动化测试时,就会形成「冰淇淋甜筒」结构。这种模式会导致测试成本高昂、反馈周期长、质量保障脆弱。

单元测试

单元测试

单元测试(Unit Testing)是针对软件设计的最小可测试单元(如函数、方法或类)进行的验证活动。它在开发过程中由开发者编写,用于检验代码中一个微小且明确的功能是否按预期工作。

为什么单元测试至关重要

单元测试是软件质量的基石。一个复杂的系统由成千上万个微小的单元构成,就像一架飞机由数百万个零件组成。

  • 可靠性的乘法效应:假设系统有 100 个单元,每个单元的可靠性是 99%,那么整个系统的可靠性是 0.9910036.6%0.99^{100} \approx 36.6\%。只有将单个单元的可靠性提升到极高水平(如 99.99%),系统整体才能达到可接受的可靠度。
  • 单元质量决定系统质量:底层单元的缺陷会像滚雪球一样,在集成后被放大,导致系统层面的严重问题,且届时定位和修复的成本将急剧增加。

单元测试的评价内容

一个全面的单元测试应该覆盖以下五个关键方面:

  1. 模块接口:检查数据是否能正确地传入和传出模块。
    • 参数数量、类型、顺序是否正确?
    • 全局变量的交互是否符合预期?
    • I/O 操作(如文件读写)是否正确处理?
  2. 局部数据结构:确保模块内部的数据处理正确无误。
    • 变量是否正确初始化?
    • 是否存在上溢、下溢或空指针等异常?
    • 数据类型和赋值是否兼容?
  3. 重要的执行路径:验证模块内的逻辑和控制流是否正确。
    • 使用路径覆盖等白盒方法,确保所有分支都被执行。
    • 检查计算、比较和逻辑运算的正确性。
  4. 出错处理路径:确保模块能优雅地处理异常和错误情况。
    • 当接收到无效输入或遇到异常状态时,模块是否能按设计进行处理(如返回错误码、抛出异常)?
    • 错误信息是否准确、有用?
  5. 边界条件:测试模块在处理极限或特殊值时的行为。
    • 循环的零次、一次、最大次执行。
    • 输入数据的最大值、最小值、空值。
    • 数组的第一个和最后一个元素。

单元测试的实施

由于单元(模块)通常不是一个独立的可执行程序,我们需要为它创建一个测试环境。

graph TD
    Driver(测试驱动模块 Driver) -- 调用 --> SUT[被测单元(SUT)];
    SUT -- 调用 --> Stub1(测试桩模块 Stub 1);
    SUT -- 调用 --> Stub2(测试桩模块 Stub 2);

    style SUT fill:#bbf,stroke:#333,stroke-width:2px
    style Driver fill:#bfb,stroke:#333,stroke-width:2px
    style Stub1 fill:#fbb,stroke:#333,stroke-width:2px
    style Stub2 fill:#fbb,stroke:#333,stroke-width:2px
  • 测试驱动模块(Driver):一个模拟调用被测单元的「主程序」。它负责创建被测对象、传入测试数据、调用被测方法,并验证返回结果或最终状态。
  • 测试桩模块(Stub):用于替代被测单元所依赖的、尚未开发或难以在测试中使用的其他模块。例如,一个依赖数据库的模块,在单元测试中可以用一个 Stub 来模拟数据库的返回数据,从而将测试与真实的数据库解耦。

单元测试的四大优点

  1. 验证行为:单元测试是验证代码功能正确性的最直接手段。它为代码重构和功能扩展提供了安全网,确保修改不会意外破坏现有功能。
  2. 设计行为:编写单元测试(尤其是遵循测试驱动开发 Test-Driven Development, TDD)促使我们从调用者的角度思考,从而设计出低耦合、高内聚、易于使用的接口。
  3. 文档行为:单元测试是活的文档。它清晰地展示了模块的各种使用场景和预期行为,并且与代码保持同步,永远不会过时。
  4. 回归性:自动化的单元测试套件可以随时随地快速运行,有效防止代码回归(即新的修改破坏了原有功能)。

代码审查

代码审查

代码审查(Code Review)是一种通过同行评审(peer-review)的方式对代码进行系统性检查的质量保证活动。它可以被看作是一种静态的单元测试,因为它在不实际运行代码的情况下检查代码的质量。

代码审查的目标

  • 缺陷发现:在开发早期找出逻辑错误、设计缺陷和潜在 Bug。
  • 提升代码质量:确保代码可读、可维护,并符合团队的编码规范。
  • 知识共享:促进团队成员间的知识传递和技术交流,避免知识孤岛。
  • 安全保障:作为一道防线(gatekeeping),防止有安全隐患或不合规的代码被合入主干。

现代代码审查流程(以 Google 为例)

现代的代码审查通常是基于工具的、异步的在线流程:

  1. Creating:作者修改代码。
  2. Previewing:作者使用工具(如 Critique)审查自己的代码变更,并运行自动化检查,然后通过邮件或系统通知评审者。
  3. Commenting:评审者在 Web 界面上针对代码行提出具体的评审意见。
  4. Addressing Feedback:作者根据评审意见修改代码或进行回复,直到所有问题都得到解决。
  5. Approving:至少一位评审者批准变更(如标记为 LGTM - Looks Good To Me)后,代码才被允许提交。

代码审查的关注点

  • 设计(Design):代码是否与系统其他部分良好集成?架构是否合理?
  • 功能(Functionality):代码是否符合开发者意图?对用户是否有益?
  • 复杂度(Complexity):代码是否过于复杂?是否可以简化以便他人理解和复用?
  • 测试(Tests):是否有配套的、合理的单元测试或集成测试?
  • 命名(Naming):变量、函数、类的命名是否清晰、表意?
  • 注释(Comments):注释是否清晰、必要?
  • 风格(Style):代码是否符合团队的编码风格指南?
  • 文档(Documentation):相关的文档(如 README)是否已更新?

集成测试

集成测试

集成测试(Integration Testing)是在单元测试的基础上,将已测试过的模块按照设计要求组装起来,测试它们之间接口、交互和协同工作的过程。它也被称为组装测试。

集成策略:渐增式 vs. 非渐增式

  • 非渐增式测试(大爆炸式 Big Bang):先单独测试所有模块,然后一次性将它们全部组装起来进行测试。
    • 缺点:错误定位困难,接口问题发现晚,需要编写大量的驱动和桩模块。
  • 渐增式测试:将下一个要测试的模块与已经测试好的模块集合结合起来进行测试,逐步扩大测试范围。
    • 优点:错误定位相对容易,接口问题能及早发现,所需测试驱动和桩模块较少。
特性 渐增式测试 非渐增式(大爆炸)测试
错误定位 较容易(问题通常与新加入的模块相关) 非常困难
接口问题发现
测试驱动/桩 需求量较少 需求量大
并行开发 受集成顺序限制 可高度并行
早期功能验证 可行 不可行

渐增式集成策略

自顶向下(Top-Down)

从主控制模块开始,沿着程序的控制层次向下,逐步用真实模块替换桩模块。

  • 过程
    1. 测试顶层模块,其所有下层依赖都用(Stub) 代替。
    2. 选择一个桩,用真实模块替换它。
    3. 进行集成测试。
    4. 重复此过程,直到所有模块都被集成。
  • 优点
    • 能尽早验证系统的主控制流程和架构。
    • 如果采用深度优先策略,可以快速实现并展示一个完整的系统功能路径。
  • 缺点
    • 需要编写大量桩模块。
    • 底层关键模块的问题可能很晚才被发现。

自底向上(Bottom-Up)

从最底层的「原子」模块开始,逐步向上集成和测试。

  • 过程
    1. 将底层模块组合成实现某个子功能的「族」。
    2. 为这个「族」编写一个驱动(Driver) 来进行测试。
    3. 测试通过后,用上一层模块调用这个「族」,逐步向上构建。
  • 优点
    • 不需要桩模块。
    • 底层模块和关键算法能得到充分的早期测试。
  • 缺点
    • 直到最后一个模块被集成,程序的整体框架才出现,高层控制逻辑的缺陷发现较晚。

混合策略(Sandwich)

结合自顶向下和自底向上的优点。对系统上层使用自顶向下,对下层使用自底向上,最后在中间层将两者集成。这是实践中最常用也最灵活的策略。

系统测试

系统测试

系统测试(System Testing)是将经过集成测试的软件,作为计算机系统的一个完整部分,与硬件、外设、网络及其他软件等系统元素结合起来,在真实或模拟环境下进行测试。

系统测试关注的是整个系统的行为,验证其是否满足在需求规格说明书中定义的所有功能和非功能性要求。

  • 功能测试:验证软件功能是否满足用户需求。
  • 非功能测试
    • 性能测试:评估系统的响应时间、吞吐量、资源利用率等。
    • 安全性测试:检查系统抵御恶意攻击和保护数据的能力。
    • 兼容性测试:测试软件在不同操作系统、浏览器、设备上的表现。
    • 可用性测试:评估用户界面的友好度和易用性。

专项测试类型

冒烟测试

冒烟测试

冒烟测试(Smoke Testing)源于硬件行业,指对新设备通电,看它是否会冒烟,如果不冒烟,说明核心功能正常。在软件领域,它指对一个新构建的版本进行一次快速、宽泛的测试,以确认系统的核心、关键功能能够正常工作,从而判断该版本是否稳定到可以进行更全面的测试。

  • 目的:快速拒绝一个有严重问题的构建版本,避免测试团队在不稳定版本上浪费时间。
  • 特点
    • 浅而广:覆盖主要功能路径,但不深入细节。
    • 快速:通常是自动化的,在几分钟内完成。
  • 应用:在现代 CI/CD(持续集成/持续部署)流程中,冒烟测试(也称 Build Verification Test, BVT)是代码提交后自动触发的第一道防线。

回归测试

回归测试

回归测试(Regression Testing)是指在软件发生变更(如代码修改、缺陷修复、功能新增)后,重新运行之前的测试用例,以确认这些变更没有引入新的错误或导致原有功能产生问题(即「回归」)。

回归测试的挑战与策略

随着软件迭代,测试用例集会不断膨胀,全部重跑所有测试用例变得不切实际。因此,需要有效的策略来提高回归测试的效率。

  1. 测试用例约简(Minimisation)

    • 目标:在保证特定覆盖率(如语句覆盖、分支覆盖)的前提下,移除冗余的测试用例,创建一个最小的测试集。
    • 方法:这是一个经典的集合覆盖问题。通常使用贪心算法,每次选择能覆盖最多未覆盖项且成本最低的测试用例。
  2. 测试用例选择(Selection)

    • 目标:仅挑选出那些与代码变更相关的测试用例来执行。
    • 方法:通过静态或动态分析,确定代码变更影响了哪些代码区域,然后选择执行路径曾穿过这些区域的测试用例。
  3. 测试用例排序(Prioritisation)

    • 目标:在有限的测试时间内,优先执行那些最有可能发现故障的测试用例,以最大化故障检出率。
    • 方法:由于无法预知哪个用例能发现故障,通常使用代理指标(surrogate metric)进行排序,例如:
      • 优先执行覆盖了最近修改代码的测试用例。
      • 优先执行历史上失败频率高的测试用例。
      • 优先执行能最快提升代码覆盖率的测试用例。
    • 度量标准**:APFD** (Average Percentage of Fault Detection) 用于衡量一个测试序列的故障检测速度。一个好的排序策略能让 APFD 曲线更早、更快地接近 100%。

验收测试

验收测试

验收测试(Acceptance Testing)是软件交付前的最后一道测试关卡,旨在向用户或客户证明系统能够满足其业务需求和合同规定。它通常由最终用户或其代表来进行。

验收测试主要关注软件的有效性——它是否能在真实业务场景中解决用户的问题。

Alpha, Beta, 和 Gamma 测试

这是三种常见的、在产品正式发布前进行的验收测试形式:

  • α (Alpha) 测试
    • 环境:在开发环境下进行,但由内部用户(非开发人员)模拟真实场景操作。
    • 目的:在受控环境中,从用户视角发现产品的功能、可用性、性能等方面的问题。
  • β (Beta) 测试
    • 环境:在真实的、开发者无法控制的用户环境中进行。
    • 目的:将产品交付给大量外部真实用户试用,收集关于错误、兼容性和用户体验的反馈。这是产品正式发布前的「公测」。
  • γ (Gamma) 测试
    • 环境:准发布版本,在正式发布前进行的小范围最终验证。
    • 目的:此时软件功能已基本冻结,主要关注一些细节优化和安装部署流程,确保产品可以上市发行。

各测试阶段对比总结

关注点 单元测试 集成测试 系统测试
测试对象 单个模块、函数、类 模块间的接口与交互 整个软件系统
设计依据 详细设计说明 概要设计说明 需求规格说明
代码可见度 白盒(所有细节可见) 灰盒(关注接口,部分细节) 黑盒(通常无需代码细节)
测试环境 依赖驱动和桩,可能复杂 取决于集成策略 接近真实的用户环境

思考题

1. 简述单元测试的优缺点

  • 优点
    • 验证与回归:验证代码功能正确性,为后续修改和重构提供安全保障,防止回归问题。
    • 驱动设计:促使开发者从调用者角度思考,编写出低耦合、易于测试的代码。
    • 提供文档:测试用例是可运行的、与代码同步的「活文档」,展示了模块如何使用。
  • 缺点
    • 无法发现集成错误:单元测试在隔离环境中进行,不能暴露模块间的接口问题。
    • 工作量:需要编写和维护测试代码,以及可能需要的驱动(Driver)和桩(Stub)模块。

2. 集成测试的策略有几种?简述各自特点

主要有四种策略:

  • 非渐增式(大爆炸式):先分别测试所有模块,然后一次性集成为一个整体进行测试。错误定位困难,接口问题暴露晚。
  • 自顶向下式:从主控模块开始,向下逐层集成测试。需要使用桩模块(Stub)。能尽早验证系统主要控制流程和接口。
  • 自底向上式:从最底层模块开始,向上逐层集成测试。需要使用驱动模块(Driver)。能尽早测试底层关键模块,无需桩模块。
  • 混合式(三明治式):结合自顶向下和自底向上两种策略,上层用自顶向下,下层用自底向上。兼具两者优点,但计划更复杂。

3. 系统测试的目标是什么?

系统测试的目标是将整个软件系统视为一个整体,对照需求规格说明,验证其功能和性能是否满足用户的全部要求。它不仅包括功能验证,还覆盖性能、安全性、兼容性等非功能性特性。

4. 验收测试主要测试什么?

验收测试主要从用户角度出发,测试软件的有效性。即验证软件的功能和性能是否符合用户在需求说明中提出的合理期望。通常由用户参与,使用真实数据在实际或模拟环境中进行,并复查文档、手册等配置是否完整准确。

5. 为什么要进行回归测试?

为了确认对代码的修改(如新增功能或修复缺陷)没有引入新的错误,也没有导致原有功能产生错误(即回归)。回归测试是保证软件在持续迭代和维护过程中质量稳定的关键手段。

6. α 测试主要检测软件哪些方面的问题?

α 测试主要检测软件产品的 FLURPS,即:

  • Functionality(功能)
  • Localization(本地化)
  • Usability(易用性)
  • Reliability(可靠性)
  • Performance(性能)
  • Support(支持性)

尤其关注产品的界面和特色功能

7. α 测试与 β 测试的区别在哪里?

主要区别在于测试环境、测试人员和开发者控制程度:

区别项 α (Alpha) 测试 β (Beta) 测试
测试环境 开发环境下或模拟的实际环境 用户的真实使用环境
测试人员 开发机构内部用户或专业测试人员 软件的最终用户(外部真实用户)
开发者控制 开发者在场或环境受控 开发者不在场,环境不受控

8. 冒烟测试有什么作用?

  • 快速验证: 在大规模测试前,快速验证软件基本功能是否正常,构建是否成功,是否存在导致系统崩溃的致命错误。
  • 降低风险: 最小化集成风险,避免将有严重问题的代码集成到主干,保证代码库的稳定性。
  • 提高效率: 如果冒烟测试失败,则直接打回开发,无需进行后续更耗时的测试,从而简化错误诊断,节约测试资源。
答案未认真修订,仅供参考。