收获点
TDD 不是一种写代码方法,而是一种功能迭代模式。
-
理解需求,找到需求输入、输出;
-
拆解需求,如何将一个需求拆解为子任务,并以测试来驱动子任务实现;
-
克服恐惧,不再指望一次性完成需求,而是慢慢测试迭代,保证鲁棒性;
-
按照 红-> 绿 -> 重构循环来不断推进代码;
-
单测不通过-> 红灯,改生产代码;
-
单测通过 -> 绿灯,重构生产代码;
-
尽量使用可读的方法名替代注释;
-
由于初期良好的绿灯重构,代码结构规整,责任分配良好,因此后续代码越写越快,效率越来越高;
-
TDD 的 3 个特点:
a. 将功能拆分为一系列任务,再将任务转化为测试,以测试体现研发进度,将开发过程变成有序的流程,减少无效劳动;
b. 修改代码时,可以及时通过测试来回归,发现错误后可快速定位,降低 bug 带来的成本;
c. 时刻感知认知提升,克服恐惧。 -
TDD 是帮助我们从可用代码进化到优质代码的一种优秀的工程实践方法,在没有上帝视角的情况下,唯有通过实践才能得知在当前条件下哪个路径是最佳的,TDD 可以帮助减小改进的成本,固化测试知识,每进行一次回归测试收益就增加一次。
-
测试步骤:setup -> exercise -> verify -> teardown
-
验证方式:状态验证,行为验证
a. 对于状态验证,在引入三方中间件的情况下,会引入不断增长的数据,因此可以使用增量验证的方式来完成测试,比如测试插入一条数据,先查询已有数据数量,然后再插入一条数据,然后再查询现有数据数量,比较先后数量的差值为 1 即可;
b. 行为验证没有状态验证直观和方便,但是对于一些需要 mock 的场景,比如数据库 mock,如果是状态验证,那么必须得拿到数据才能验证,因此我们必须得引入数据 mock,但如果是行为验证,对数据结果不关心的情况下,我们只需关心函数是否被调用,功能是否 work,如下:@Test public void should_parse_value_if_flag_present() { // 并不会真正的去解析,而是需要验证返回值,具体的说是验证 parse函数是否被调用 // 因此此处,状态验证就会先得很别扭,我们将其改造为行为验证 Object parsed = new Object(); Function<String, Object> parse = (it) -> parsed; Object whatever = new Object(); assertSame(parsed, OptionParsers.unary(whatever, parse).parse(Arrays.asList("-p", "8080"), option("p"))); } @Test public void should_parse_value_if_flag_present2() { Function parse = Mockito.mock(Function.class); OptionParsers.unary(Mockito.any(), parse).parse(Arrays.asList("-p", "8080"), option("p")); Mockito.verify(parse).apply("8080"); // 验证 parse方法被调用,并且传入参数为 8080 }
举一个业务例子: StudentRepository 是一个 JPA 代理的仓储服务:
@Repository public interface StudentRepository extends JpaRepository<Student, Long> { Optional<Student> findByEmail(String email); }StudentServiceImpl 将其引入,并作为查询数据库数据的代理: public class StudentServiceImpl implements StudentService { private final StudentRepository studentRepository; public StudentServiceImpl(StudentRepository studentRepository) { this.studentRepository = studentRepository; } @Override public Optional<Student> queryOneById(Long id) { return studentRepository.findById(id); } }
如果是状态测试,那么我们必须引入数据库 Mock,比如 H2 等;实际上,studentRepository.findById 是由 JPA 保证正确性的,我们对状态结果并不关系,即使测试环境下 H2 的用例全部通过了,等到了生成环境数据库采用了 MySQL,也不一定能保证测试通过,因此我们的核心诉求是验证 findById 这个函数是否被调用,即行为验证,而不是这个函数返回的结果,即状态验证,那么行为测试我们可以这样写:
class StudentServiceImplTest { StudentRepository repository; @BeforeEach public void before() { repository = Mockito.mock(StudentRepository.class); } @Test void queryOneById() { var service = new StudentServiceImpl(repository); service.queryOneById(1L); Mockito.verify(repository).findById(1L); } }
这样,对于 queryOneById 这个方法,我们每天验证其返回的数据,而是通过 Mockito 来验证内部的 findById 函数是否被成功调用,且传入的参数是否正确,这样就减少了依赖的复杂度。
-
行为验证的适用场景(状态数据难以得到,就采用行为测试):
a. 微服务调用
b. 三方支付场景
c. MQ
d. 数据库
e. so on... -
行为测试对 TDD 的用处不大,核心在于行为测试必须先写一篇测试实现代码,然后在生产代码再写一遍,另外 TDD 本质上就是拆分为多个小任务,每个小任务都有明确的结果验证再继续推进;行为验证本身并不能验证功能是否正确,而只能验证功能是否按照某种方式实现,这与 TDD 的核心逻辑就冲突了。在 TDD 的红 / 绿 / 重构中,重构要求在功能不变的前提下,改变实现方式。而对于行为验证而言,实现方式改变就是功能改变(代码变更)。因而重构就无法进行!需要重写!也就是说,行为验证会阻碍 TDD 的进行;虽然行为验证的主要目的是降低测试成本,但如果丧失了测试的有效性,那么成本再低也是无意义的;将行为验证放在接口而非实现上;
-
在 TDD 的语境下,“单元测试”指的是能提供快速反馈的低成本的研发测试(Developer Test),Martin 将其称为极限单元测试(Xunit Test):
a. TDD 中的测试是由不同粒度的功能测试构成的;
b. 每一个测试都兼具功能验证和错误定位的功效;
c. 要从发现问题和定位问题的角度,去思考测试的效用与成本;
d. 单元粒度要以独立的功能上下文或变化点为粒度。 -
将所有直接耦合都视为坏味道的设计取向,会将功能需求的上下文打散到一组细碎的对象群落中,增加理解的难度。最终滑向过度设计(Over Design)的深渊;
-
测试驱动开发的主要关注点在于功能在单元(模块)间的分配,而对于模块内怎么实现,需要你有自己的想法,因此测试驱动开发在“单元(模块)内的实现方式”失去驱动力;
-
TDD“驱动”的是架构,因而实际是一种架构技术;
-
从功能测试出发,逐步完成软件开发,这或许没问题。但架构怎么办?实际上,红 / 绿 / 重构循环中的重构就是解决架构问题的。只不过架构并不是预先设计的(Upfront Design),而是在完成功能的前提下演进而来的,因而也称演进式设计(Evlutionary Design);
-
“最晚尽责时刻”让我们不必花费时间进行空对空的讨论,可以尽早开始实现功能,再通过重构从可工作的软件(Working Software)中提取架构。这种方式也被称作 TDD 的经典学派(Classic School)或芝加哥学派(Chicago School)。除了经典学派之外,还有一种 TDD 风格,被称作 TDD 的伦敦学派(London School)。如果架构愿景已经比较清晰了,那么我们就可以使用伦敦学派进行 TDD;
-
伦敦学派是在组件结构、接口定义的情况下开始 TDD,而经典学派是没有架构设计,只有输入、输出的情况下,通过 TDD 一步步推出架构;
-
伦敦学派可以先对其他组件(功能函数)进行打桩(Stub),返回固定值,对某个模块用 TDD 驱动开发,最后完成全部开发:
@Test void should_query_one_by_id() { Mockito.when(repository.findById(1L)).thenReturn(Optional.of(new Student("pedro", "1312342604@qq.com"))); var service = new StudentServiceImpl(repository); Optional<Student> student = service.queryOneById(1L); assertTrue(student.isPresent()); assertEquals("pedro", student.get().getName()); }
在这个测试例子中,已知 StudentServiceImpl 一定会依赖 StudentRepository,这是墨守成规的 MVC 模式,因此我们不需要 TDD 来推出这个架构,而是可以直接给出分层代码,在 StudentServiceImpl 单元模块推进下,完全可以对 StudentRepository 进行打桩,然后驱动其开发。 无论是伦敦学派,还是经典学派,都是 TDD 的一种模式,都需要掌握。
-
TDD 流程: • 首先将需求分解为功能点,也就是将需求转化为一系列可验证的里程碑点;
• 如果已经存在架构或架构愿景,则依据架构中定义的组件与交互,将功能点分解为不同的功能上下文;
• 如果尚不存在架构愿景,则可以将功能点作为功能上下文;
• 将功能点按照功能上下文,分解为任务项。也就是进一步将可验证的里程碑点,分解为功能上下文中可验证的任务项;
• 将任务项转化为自动化测试,进入红 / 绿 / 重构循环,驱动功能上下文内的功能实现;
• 如果重构涉及功能上下文的重新划分,即提取 / 合并组件,即视作对于架构的重构与梳理。需调整后续功能点中对于功能上下文以及任务项的划分。
• 如此往复,直到所有功能完成。