移动开发中的单元测试:它是什么、方法和框架

作者: IT Sectr 发布日期: 2026-04-06 阅读时间: 9 分钟

单元测试是一种软件验证方法,其中单独模块或代码函数的正确性在与系统其他部分隔离的情况下进行测试。据Martin Fowler, 2026,单元测试是CI/CD和重构的基础,提供关于代码可用性的快速反馈。模块化测试有助于在开发的早期阶段发现错误,将修复成本降低数十倍。

核心要点

  • 单元测试 — 在与外部依赖隔离的情况下检查单个模块(函数、方法、类)
  • FIRST原则 — Fast, Isolated, Repeatable, Self-validating, Timely — 质量测试的基础
  • Mock和Stub — 外部依赖(数据库、API、文件系统)的替代品,确保测试的隔离性
  • TDD(Test-Driven Development) — 通过测试驱动开发的方法论:红色-绿色-重构
  • 测试金字塔 — 单元测试占据金字塔的70%,在每次提交时提供快速反馈

什么是单元测试?

单元测试是检查源代码中单独单元(函数、方法、类)与程序其他部分隔离的过程。每个测试执行模块的特定用例场景,并检查结果是否符合预期。单元测试使用与主代码相同的编程语言编写,并在开发环境或CI/CD管道中自动执行。与集成测试不同,单元测试不与真实数据库、文件系统或网络服务交互。

为什么需要单元测试?

主要目标是在代码变更后提供关于代码正确性的快速反馈。如果开发人员重构了一个方法,单元测试集将确认行为没有被破坏。据Google Testing Blog (2025)报道,单元测试覆盖率超过60%的项目,生产事故发生率低2.5倍。其他优势:代码文档(测试展示如何使用API)、简化重构(可以在保持行为的同时改变实现)和快速诊断回归。

什么被视为单元测试?

并非所有自动化测试都是单元测试。标准:测试单个模块(类或函数),外部依赖被mock或stub替代,测试在毫秒内执行,不需要启动服务器或数据库。访问真实数据库的测试是集成测试。打开浏览器的测试是E2E测试。理解测试类型之间的界限对于在测试金字塔中正确分配精力很重要。

FIRST原则和AAA结构

质量单元测试遵循Robert C. Martin提出的FIRST原则。每个测试应该是Fast(快速—毫秒)、Isolated(隔离的—不依赖于其他测试)、Repeatable(可重复的—在任何机器上结果相同)、Self-validating(自我验证的—结果是“passed”或“failed”,无需手动检查)和Timely(及时的—在代码之前或与代码同时编写)。违反任何一个原则都会减低测试的价值。

AAA结构(Arrange-Act-Assert)

编写单元测试的标准模板。Arrange—准备数据和依赖:创建对象、配置mock、设置输入参数。Act—执行被测试的操作:调用方法或函数。Assert—检查结果:将实际值与预期值进行比较。分为三个模块使测试变得可读和易于理解。如果Assert模块需要复杂逻辑,测试可能同时检查太多东西。

kotlin
// 在Kotlin中使用JUnit 5按AAA模板进行单元测试示例
class CalculatorTest {

    private lateinit var calculator: Calculator

    @BeforeEach
    fun setUp() {
        // ARRANGE — 创建被测对象
        calculator = Calculator()
    }

    @Test
    fun addition_shouldReturnCorrectSum() {
        // ACT — 执行操作
        val result = calculator.add(2, 3)

        // ASSERT — 检查结果
        Assertions.assertEquals(5, result)
    }
}

测试命名

测试名称应描述检查什么以及期望的结果。格式:[methodName]_[scenario]_[expectedResult]。示例:calculateTotal_whenDiscountApplied_shouldReturnDiscountedTotal。良好的测试名称可以替代注释,并在失败时立即指出哪个功能受到影响。避免使用test1checkSomethingverify等名称—它们不传递信息并使诊断变得困难。

Mock、Stub和Fake:什么和何时使用

为了将被测模块与外部依赖隔离,使用测试替代品(test doubles)。主要类型:Mock—检查特定方法是否以正确参数被调用;Stub—在方法被调用时返回指定值;Fake—真实组件的简化实现(例如,用InMemoryUserRepository代替与数据库工作的UserRepository)。选择取决于需要检查什么:状态(Stub)还是交互(Mock)。

替代品检查什么示例
Mock以正确参数调用方法userRepository.save(user)被调用了恰好1次
Stub返回值repository.findById(1)返回User(id=1, name=“Test”)
Fake通过简化实现的逻辑使用HashMap的InMemoryMapUserRepository代替数据库
Spy真实对象的部分mockspy(repo).when(findById).thenReturn(user)

Mockito:Java/Kotlin中的Mock示例

Mockito是Java和Kotlin中最受欢迎的Mock框架。它允许通过mock()创建mock,通过when().thenReturn()配置返回值,并通过verify()检查调用。现代版本的Mockito(5.x)支持静态mock(mockStatic)和通过BDDMockito(given-willReturn)的简化语法。重要规则:不要mock不属于你的东西—不要为值对象和标准库创建mock。

kotlin
// 在Kotlin中使用Mockito进行单元测试示例
class OrderServiceTest {

    @Mock
    private lateinit var paymentGateway: PaymentGateway

    @Mock
    private lateinit var userRepository: UserRepository

    private lateinit var orderService: OrderService

    @BeforeEach
    fun init() {
        MockitoAnnotations.openMocks(this)
        orderService = OrderService(paymentGateway, userRepository)
    }

    @Test
    fun processOrder_whenPaymentFails_shouldThrowException() {
        // given
        val user = User(id = 1, balance = 100.0)
        val order = Order(amount = 200.0)
        Mockito.`when`(paymentGateway.charge(any())).thenReturn(false)
        Mockito.`when`(userRepository.findById(1)).thenReturn(user)

        // when & then
        assert Throws<PaymentException> {
            orderService.processOrder(user.id, order)
        }

        // verify
        Mockito.verify(paymentGateway).charge(any())
    }
}

TDD:测试驱动开发

TDD(Test-Driven Development)—是一种在代码实现之前编写测试的方法论。“红色-绿色-重构”循环:编写失败的测试(红色),编写通过测试的最小代码(绿色),在不改变行为的情况下改进代码(重构)。TDD保证所有代码被测试覆盖(已编写功能的覆盖率为100%),并且代码是可测试的—如果代码难以测试,说明架构需要改进。

TDD的优势

IBM(2006-2026,纵向研究)报告,使用TDD的团队在生产环境中的缺陷比在代码之后编写测试的团队少40-80%。TDD还改进架构:开发人员必须在实现之前考虑API设计,这导致嵌合松散(loose coupling)和高内聚(high cohesion)。另外的效果是用活代码做文档:测试作为模块行为的规范,始终保持更新。

TDD何时不合适?

TDD并非始终最优。UI组件难以隔离测试—对它们而言,快照测试或视觉回归测试(Percy、Chromatic)更有效。原型开发和研究(spike solutions)不需要测试。没有测试的遗留代码难以通过TDD覆盖—这里首先需要characterization tests(在重构前记录当前行为的测试)。在这些情况下,TDD并不是完全废弃,而是进行适应—为被修改的功能编写测试,而不是为整个遗留代码。

移动应用中的单元测试

移动开发有其特殊性:业务逻辑经常与UI代码(Activity、ViewController、ViewModel)混在一起,这使单元测试变得困难。最佳实践—薄视图、厚ViewModel:将所有逻辑从UI组件中移到单独的类(UseCase、Repository、ViewModel)中,这样可以无需模拟器就轻松测试。对于Android和iOS,有原生的单元测试框架可以在JVM/Native上运行,无需启动设备。

Android上的单元测试(JUnit + Mockito/Robolectric)

Android单元测试在本地JVM上执行,无需模拟器,这确保了执行速度—典型的测试不到100毫秒。JUnit 5是主要运行器。对于ViewModel测试,使用kotlinx-coroutines-test来测试协程,使用Turbine来测试StateFlow。Robolectric通过加载影子类来允许无需模拟器测试依赖于Android的组件(Context、Resources)。对于Compose测试,使用Compose UI Test—但这已经是UI测试,而非单元测试。

iOS上的单元测试(XCTest + Quick/Nimble)

iOS单元测试使用Swift和XCTest(集成在Xcode中)编写。Quick + Nimble—用于更可读测试的BDD框架(describe/context/it)。Mock使用Cuckoo(生成mock)或SwiftyMocky。Swift支持协议和依赖注入,这便于替换依赖。关键点:iOS单元测试在macOS模拟器上执行,而非真实设备。需要硬件功能(摄像头、蓝牙)的测试是集成测试。

Flutter上的单元测试(flutter_test + Mockito)

Flutter单元测试使用flutter_test包,在Dart VM上无需模拟器执行。Mock使用mockito包配合代码生成器(build_runner)。小部件测试(同一包中)测试单个小部件,但需要渲染且运行较慢—仅用于检查UI逻辑。纯Dart逻辑(模型、仓库、blob)作为普通Dart测试测试,无需导入flutter_test。

dart
// 在Flutter中使用mockito进行单元测试示例
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';

@GenerateMocks([ApiClient])
import 'user_repository_test.mocks.dart';

void main() {
    late MockApiClient mockApi;
    late UserRepository repository;

    setUp(() {
        mockApi = MockApiClient();
        repository = UserRepository(mockApi);
    });

    test('fetchUser returns user when API succeeds', () async {
        // Arrange
        final expectedUser = User(id: 1, name: 'Test');
        when(mockApi.getUser(1))
            .thenAnswer((_) async => expectedUser);

        // Act
        final result = await repository.fetchUser(1);

        // Assert
        expect(result, expectedUser);
        verify(mockApi.getUser(1)).called(1);
    });
}

最佳实践和典型错误

有效的单元测试需要纪律。主要规则:测试行为,而非实现。测试不应该知道模块如何内部实现(哪些私有方法被调用、以什么顺序)。如果测试与实现紧密绑定,它将在每次重构时失效并失去价值。测试检查合约:输入X应产生输出Y。例外—对于关键性能算法的测试,其中调用顺序很重要。

  • 每个测试一个检查 — 每个逻辑检查使用一个assert或一组相关的assert
  • 避免重复 — 使用@BeforeEach / setUp进行共同初始化,使用参数化测试处理不同的输入数据
  • 不要测试私有方法 — 通过公共API测试。如果私有方法没有被覆盖,说明它的逻辑从外部不可见
  • 覆盖边界情况 — 空集合、null/undefined、负数、最大值
  • 不要在测试中使用Thread.sleep — 这会使测试变慢并且不稳定。使用测试超时和协程

什么样的覆盖率够了?

100%覆盖率是无法实现且无需要的目标。据Google Testing Blog (2025)报道,单元测试的最佳覆盖率是代码行的70-80%。100%覆盖率往往是通过测试getter、setter和构造器实现的,这没有价值。关注关键业务逻辑:复杂计算、验证、错误处理、边界情况。使用JaCoCo(Java)、Coverage.py(Python)、Istanbul(JS)进行测量,并在CI中设置阈值—覆盖率低于60%时构建失败。

CI/CD和单元测试

单元测试是任何CI/CD管道的第一阶段。它们在每次向仓库推送时执行,在构建和部署之前。项目中单元测试的平均运行时间不应超过5分钟—如果更长,测试就不再“快速”,开发人员就会停止在本地运行它们。将测试分为快速(单元)和慢速(集成),并在管道的不同阶段运行它们。使用并行执行和fail-fast加速。

常见问题

单元测试和集成测试有什么区别?

单元测试检查单个模块的隔离性,用mock替代外部依赖。集成测试检查多个真实组件(数据库、API、文件系统)之间的交互。单元测试在毫秒内执行,集成测试在秒内执行。在测试金字塔中,单元测试占据70%。

应该选择哪个单元测试框架?

选择取决于平台:Java/Kotlin用JUnit 5,iOS/Swift用XCTest,Python用pytest,JavaScript/TypeScript用Jest/Vitest,Flutter用flutter_test。Mock使用Mockito(Java)、Cuckoo(iOS)、unittest.mock(Python)或vitest.mock(JS)。所有现代框架都支持参数化测试、内置assert和并行执行。

什么是F.I.R.S.T.测试原则?

Fast—测试在毫秒内执行。Isolated—不依赖于其他测试和外部系统。Repeatable—在任何机器上产生相同结果。Self-validating—自动检查结果。Timely—在代码之前或与代码同步编写。违反任何一个原则都会降低测试的有效性。

需要为Android/iOS中的ViewModel编写单元测试吗?

是的,必须。ViewModel包含业务逻辑—事件处理、数据转换、状态管理。在Android上,使用kotlinx-coroutines-test测试协程,使用Turbine测试StateFlow。在iOS上,测试ViewModel中的Combine Publishers或async/await。ViewModel测试是纯单元测试,在无需模拟器的JVM/macOS上运行。

如何测试含有网络请求的代码?

单元测试中的网络请求不执行—它们被HTTP客户端的mock替代。在Android上,使用MockWebServer(OkHttp)—它启动本地HTTP服务器,比mock更受推崇,因为它能模拟真实的网络交互。MockWebServer在不失去真实性的情况下提供隔离。对于iOS—使用OHHTTPStubs或URLProtocol来拦截和替换响应。

总结

  • 单元测试 — 在与外部依赖隔离的情况下检查单独模块,提供快速反馈
  • AAA结构 — Arrange(准备)、Act(操作)、Assert(检查)—标准测试模板
  • Mock和Stub — 用于隔离的测试替代品:mock检查调用,stub返回值
  • TDD — 测试驱动开发(红色-绿色-重构)减少缺陷40-80%
  • FIRST原则 — Fast, Isolated, Repeatable, Self-validating, Timely — 质量测试的基础
  • 平台工具 — JUnit 5(Android)、XCTest(iOS)、flutter_test(Flutter)、Jest(React Native)
  • 70-80%覆盖率 — 关键业务逻辑的最佳水平,getter和setter不需要测试

我们将开发一款交钥匙移动应用程序

IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。

讨论项目

另请阅读