AEP : Agile Engineer Practice (敏捷工程实践)
最近参加完公司组织的一场关于TDD&OO的培训,收获颇丰,在此记录并分享,希望大家也能够一起学习探讨。
Note : 本文案例全部为Java
OOD热身
需求:我需要一个长度来比较大小,比如:1 == 1,2 > 1;
通过面向对象的思想来实现这个需求,第一反应就是会有一个比较长度的对象,它能够比较自己和其他长度的大小。
具体代码如下:
public class CompareLength {
private final int value;
public CompareLength(int value) {
this.value = value;
}
public int compareTo(CompareLength anotherCompareLength) {
return compare(this.value, anotherCompareLength.value);
}
private static int compare(int x, int y) {
return (x < y) ? -1 : ((x == y) ? 0 : 1);
}
}
验证代码如下:
CompareLength lengthThree = new CompareLength(3);
CompareLength lengthFour = new CompareLength(4);
int result = lengthThree.compareTo(lengthFour);
Assert.assertEquals(-1, result);
这就是一个很简单的面向对象的设计实现,主要是为了体现面向对象的思想,但是有的人可能会问:" 在当前这个需求下真的有必要如此设计么,感觉会稍显复杂,需求只需要能够判断长度大小,也可理解为数字大小,若通过一个简单的工具方法直接判断不是更简单 ? "
实现方案二:
public class CompareUtils {
public static int compare(int x, int y) {
return (x < y) ? -1 : ((x == y) ? 0 : 1);
}
}
验证:
int result = CompareUtils.compare(3, 4);
Assert.assertEquals(-1, result);
如此一来代码看起来会很简单,并且同样满足需求。
这时候有人会问: "第二种不是面向对象但是却比第一种对象的形式看起来更简单,那第一种的实现是不好么?"
当时培训时这样的疑问同样存在我的脑海,经过同其他小伙伴一番讨论后,发现第二种其实本身也是一种对象的思想,只是对象的目标是一个工具,而之前的方案是把长度作为对象,两种方案侧重点不一样而已,不能简单的通过代码简洁度来评判好坏。
需求增加:添加一个单位,相同的单位可以比较,比如1cm == 1cm
这是如果第一种方案:只需在CompareLength
中添加对应的属性就可解决,如下:
public class CompareLength {
private final int value;
private final LengthUnit unitType;
public CompareLength(int value, LengthUnit unitType) {
this.value = value;
this.unitType = unitType;
}
public int compareTo(CompareLength anotherCompareLength) {
if (this.unitType == anotherCompareLength.unitType) {
return compare(this.value, anotherCompareLength.value);
}
throw new UnitNotSameException();
}
private static int compare(int x, int y) {
return (x < y) ? -1 : ((x == y) ? 0 : 1);
}
public enum LengthUnit {
MM,
CM,
M,
KM
}
}
若是第二种工具类的方式,就需要更多的重构来做了,所以个人认为在未来需求不确定的时候,对象的定义尽可能从用户和需求出发(这里需求是我需要一个长度,长度被看成了一个对象),有利于后面变化带来的修改成本。
Tips :在对象设计中尽可能把纯算法和业务逻辑分隔开
以上就是一个简单的OOD热身,为了后续的TDD做铺垫。
SOLID原则
(S)单一职责原则:
在代码设计上,让一个类只处理一组相关的事情,控制了它的变化方向,后期也能更好的定位。如果引发变化的因素很多,会导致类的职责过多,难以维护,上帝类就是这么形成的。
(O)开闭原则
一个软件实体应当对扩展开放,对修改关闭;在设计一个模块的时候,应当使这个模块可以在不被修改的前提下被扩展。
(L)里氏替换原则
在代码设计中,里氏替换原则对子类进行了约束,子类不应该去重写父类的具体功能。对调用者来说,能够调用父类的地方,一定可以调用其子类,并且预期结果是一致的。
(I)接口隔离原则
在代码设计上,尽量不要将一个大而全的接口扔给调用者使用,而是将每个调用者关注的接口进行隔离,封装打包后分别提供给他们。
(D)依赖倒置原则
在代码设计中,调用者应该依赖一个抽象的服务接口,而不是去依赖一个具体的服务实现,这样就把依赖的关系倒置过来了。
SOLID原则本身是为了帮助我们通过面向对象的思想把代码做到高内聚,低耦合的目标,但是实际过程中如果只是为了满足这几项原则往往会写出一些复杂的结构在一些简单的需求上,而这样设计就导致做了过多的无用功。所以个人认为实际开发中SOLID应该作为一个辅助思想,并根据实际情况通过简单设计和TDD的方式会达到更好的效果。
有了以上OOD和SOLID的思想基础接下来就可以进入到真正TDD的大门了。
TDD初识
测试驱动开发(Test Driven Development)
刚接触TDD的概念,以为只是一个测试先行,方便后续代码修改时有一个质量保障,但通过逐步的深入和实践慢慢发现远远不止如此,它能够让你在开始之初专注于单个需求,快速实现,逐步重构,时刻从用户的角度出发去设计程序,而当整个需求完成时往往会得到意料之外的收获。
Note : 以下TDD部分概念内容引用自:https://mp.weixin.qq.com/s/CYHshxaMtffmHms91LExnA
TDD是什么
Kent Beck《测试驱动开发》中的解释如下:
Kent Beck:“测试驱动开发不是一种测试技术。它是一种分析技术、设计技术,更是一种组织所有开发活动的技术”。
分析技术: 体现在对问题域的分析,当问题还没有被分解成一个个可操作的任务时,分析技术就派上用场,例如需求分析、任务拆分和任务规划等,《实例化需求》这本书可以给予一定的帮助作用。
设计技术: 测试驱动代码的设计和功能的实现,然后驱动代码的再设计和重构,在持续细微的反馈中改善代码。
组织所有开发活动的技术: TDD 很好地组织了测试、开发和重构活动,但又不仅限于此,比如实施 TDD 的前置活动包括需求分析、任务拆分和规划活动,这使得 TDD 具有非常好的扩展性。
TDD的目的
《测试驱动开发》一书中提到: "代码简洁可用这句言简意赅的话,正是 TDD 所追求的目标"。
对于如何保证“代码简洁可用”可以使用分而治之的方法,先达到“可用”目标,再追求“简洁”目标。
可用: 保证代码通过自动化测试。
代码简洁: 在不同阶段人们对简洁的理解程度也不一样,不过遵循的原则差不多,例如 OOD 的 SOLID 原则,Kent Beck 的 Simple Design 原则等。
虽然有很多因素妨碍我们得到整洁的代码,甚至可用的代码,无需征求太多意见,只需要采用 TDD 的开发方式来驱动出简洁可用的代码。
TDD的规则
在 TDD 的过程中,需要遵循两条简单的规则:
- 仅在自动测试失败时才编写新代码。
- 消除重复设计(去除不必要的依赖关系),优化设计结构(逐渐使代码一般化)。
第一条规则的言下之意是每次只编写刚刚好使测试通过的代码,并且只在测试运行失败的时候才编写新的代码,因为每次增加的代码少,即使有问题定位起来也非常快,确保我们可以遵循小步快跑的节奏;第二条规则就是让小步快跑更加踏实,在自动化测试的支撑下,通过重构环节消除代码的坏味道来避免代码日渐腐烂,为接下来编码打造一个舒适的环境。
关注点分离是这两条规则隐含的另一个非常重要的原则。其表达的含义指在编码阶段先达到代码“可用”的目标,在重构阶段再追求“简洁”目标,每次只关注一件事!!!
TDD口号
简单来说,不可运行/可运行/重构——这正是测试驱动开发的口号,也是 TDD 的核心。在这个闭环中,每一个阶段的输出都会成为下一阶段的输入。
- 不可运行——写一个功能最小完备的单元测试,并使得该单元测试编译失败。
- 可运行——快速编写刚刚好使测试通过的代码,不需要考虑太多,甚至可以使用一些不合理的方法。
- 重构——消除刚刚编码过程引入的重复设计,优化设计结构。
假设这样的开发方式是可能的,那我采用 TDD 真正的动机是什么?
采用 TDD 的动机
- 控制编程过程中的忧虑感。
有一个有趣的现象,当我感觉压力越大,自身就越不想去做足够多的测试。当知道自己做的测试不够时,就会增加自身的压力,因为我担心自己写的代码有 BUG,对自己编写的代码不够自信,这是一种心态上的变化。此时测试是开发人员的试金石,可以将对压力的恐惧变为平日的琐事,采用自动化测试,就有机会选择恐惧的程度。
- 把控编程过程中的反馈与决策之间的差距。
如果我做了一周的规划,并且量化成一个个可操作的任务写到 to-do list,然后使用测试驱动编码,把完成的任务像这样划掉,那么我的工作目标将变得非常清晰,因为我明确工期,明确待办事项,明确难点,可以在持续细微的反馈中有意识地做一些适当的调整,比如添加新的任务,删除冗余的测试;还有一点更加让人振奋,我可以知道我大概什么时候可以完工。项目经理对软件开发进度可以更精确的把握。
TDD的整体流程
- 想一下我要做什么,想想如何测试它,然后写一个小测试。思考所需的类、接口、输入和输出。
- 编写足够的代码使测试失败(明确失败总比模模糊糊的感觉要好)。
- 编写刚刚好使测试通过的代码(保证之前编写的测试也需要通过)。
- 运行并观察所有测试。如果没有通过,则现在解决它,错误只会落在新加入的代码中。
- 如果有任何重复的逻辑或无法解释的代码,重构可以消除重复并提高表达能力(减少耦合,增加内聚力)。
- 再次运行测试验证重构是否引入新的错误。如果没有通过,很可能是在重构时犯了一些错误,需要立即修复并重新运行,直到所有测试通过。
- 重复上述步骤,直到找不到更多驱动编写新代码的测试。
案例
Talk is cheap, show me the code
说了这么多,还是来个案例实在。
需求案例:有一个停车场,停车场可以停车、取车
按照需求,大体可以分为停车和取车两个模块,并且先有停车才可以取车,所以后续的任务拆分上要遵循这样一个顺序。
任务拆分时,尽量遵循Given(需要什么), When(做什么事), Then(预期结果)的三段式结构,这样有利于代码的编写和预期结果的验证
1. (given)容量为1的停车场(when)停入一辆车(then)得到一张停车票
2. (given)一个满的停车场(when)停入一辆车,(then)停车失败
3. (given)一个只有一个空位的停车场(when)连续停入两辆车,(then)第二辆车停车失败
4. (given)从停了两辆汽车的停车场(when)通过停车票取车,(then)得到我的车
5. (given)在停车场用一张车牌不匹配的停车票(when)去取车,(then)取车失败
6. (given)在停车场用一张已用过的停车票(when)去取自己的车,(then)取车失败
在继续下面之前,如果你是新手可以先根据上述需求自行编写测试代码,然后再往下看,这样会更有体会。
编写第一个任务测试:
在写测试之前先根据需求描述,想象我们代码实现的过程,通过描述我们可以找到需要的对象实体目前有三个:停车场、车,停车票;然后存在一个动作:停车;期望的结果是得到一张停车票;那么就可以初步组合起来用代码的方式就是:
构建一个停车场对象,停车场有容量的属性,构建一个车的对象,车本身应该有自己的的车牌属性,停车的行为是停车场的,那么停车场对象中存在一个停车的方法,参数接收一辆车,返回一张票。
测试的命名可以根据团队人员自行统一,一般风格默认是should+期望结果+when+做什么事+其他条件,并以驼峰或者下划线间隔的形式
@Test
public void should_get_ticket_when_parking_1_car_in_available_parking_lot() {
ParkingLot parkingLot = new ParkingLot(1);
Car car = new Car("川AE0000");
Ticket ticket = parkingLot.park(car);
assertNotNull(ticket);
}
此时编译报错,可以暂时忽略,先把测试写完,由于编写测试是从需求角度出发,所以很自然的得出了几个关键对象和行为;接着依次创建所需对象和方法,这时不要去实现方法逻辑,优先保证编译通过是第一步。
public class ParkingLot {
private int capacity;
public ParkingLot(int capacity) {
this.capacity = capacity;
}
public Ticket park(Car car) {
return null;
}
}
@Getter
public class Car {
private String carNum;
public Car(String carNum) {
this.carNum = carNum;
}
}
public class Ticket {
}
编译通过之后,运行当前测试,测试结果失败,这里很明显期望park(car)
方法返回一个不为空的Ticket
对象,接着为了让测试通过我们可以直接new Ticket()
返回,如下:
public class ParkingLot {
private int capacity;
public ParkingLot(int capacity) {
this.capacity = capacity;
}
public Ticket park(Car car) {
return new Ticket();
}
}
测试通过,第一个case完成。这个时候按照流程需要考虑下代码实现的重构,把一些坏味道去掉,但由于是第一个测试任务,很多东西还看不出来,这里可以免去重构步骤。
编写第二个任务测试
2. (given)一个满的停车场(when)停入一辆车,(then)停车失败
测试代码如下:
@Test
public void should_throw_parking_full_exception_when_park_1_car_in_full_parking() {
ParkingLot parkingLot = new ParkingLot(1);
Car carAE0000 = new Car("川AE0000");
Ticket ticket = parkingLot.park(carAE0000);
Car carAE0001 = new Car("川AE0001");
assertThrows(ParkingFullException.class, () -> parkingLot.park(carAE0001));
}
这里可以看到given是一个满的停车场,我们通过构建一个容量为1的停车场,并停入一辆车来实现;也可以通过构建一个容量为0的停车场模拟停车场满的一个状态
我们对期望的结果停车失败的定义为:获得一个异常,这里可以根据个人理解有所区别,也可以通过返回一个null表示停车失败。
此时编译无法通过,还是先保证编译通过:
构建异常类:
public class ParkingFullException extends RuntimeException {
}
编译通过,运行测试,如期望那样测试失败;此时期望抛出ParkingFullException
异常,但是结果却没有,所以需要改造park(car)
方法:
public class ParkingLot {
private int capacity;
private int count;
public ParkingLot(int capacity) {
this.capacity = capacity;
}
public Ticket park(Car car) {
if (count < capacity) {
count++;
return new Ticket();
}
throw new ParkingFullException();
}
}
我新增了一个count用于统计当前停入的车辆个数,在停车的时候判断一下当前个数是否超过停车场的容量,若超过则抛出对应异常
再次跑测试用例,(这里不只跑当前测试,还需要保证之前写的测试一并通过)测试通过。别忘了检查代码坏味道,时刻牢记重构。
编写第三个任务测试
3. (given)一个只有一个空位的停车场(when)连续停入两辆车,(then)第二辆车停车失败
直接上测试代码:
@Test
public void should_throw_exception_when_parking_second_car_when_first_car_already_in_parkinglot_with_one_capacity() {
ParkingLot parkingLot = new ParkingLot(1);
Car carAE0000 = new Car("川AE0000");
Ticket ticket = parkingLot.park(carAE0000);
assertNotNull(ticket);
Car carAE0001 = new Car("川AE0001");
assertThrows(ParkingFullException.class, () -> parkingLot.park(carAE0001));
}
这个测试写完,发现没有编译错误,直接运行,测试尽然通过了,然而并没有写任何的代码;出现这个原因一般可能是两个问题:
1. 任务拆分的时候存在任务重合
2. 在编写之前的测试代码时,过多的实现了任务需求,没有满足刚好让测试通过的原则
对于如何避免这类问题,我暂时没有好的方法,如果大家有好的建议,欢迎在下方留言;个人还是觉得需要通过不断的实践去积累经验,避免上述问题。
编写第四个任务测试
4. (given)从停了两辆汽车的停车场(when)通过停车票取车,(then)得到我的车
@Test
public void should_get_my_car_when_pick_up_with_my_ticket_in_parking_lot_where_two_cars_are_parked() {
ParkingLot parkingLot = new ParkingLot(2);
Car myCar = new Car("川AE0000");
Car anotherCar = new Car("川BE0000");
Ticket myTicket = parkingLot.park(myCar);
assertNotNull(myTicket);
assertNotNull(parkingLot.park(anotherCar));
assertEquals(myCar, parkingLot.pickUpBy(myTicket));
}
发现编译缺少pickUpBy(ticket)
方法,创建方法如下:
public Car pickUpBy(Ticket ticket) {
return null;
}
编译通过,测试失败,这个时候发现需要返回自己的车,那么在停车的时候停车场就需要拥有里面的每一辆车,这样取车才可以得到,并且取车的时候要使用Ticket
,那么需要在Ticket
中存在一个与Car
相关的信息,这样才可以找到对应的车,于是修改代码如下:
@Getter
public class Ticket {
private String carNum;
public Ticket(String carNum) {
this.carNum = carNum;
}
}
public class ParkingLot {
private int capacity;
private int count;
private List carPool = new ArrayList<>();
public ParkingLot(int capacity) {
this.capacity = capacity;
}
public Ticket park(Car car) {
if (count < capacity) {
count++;
carPool.add(car);
return new Ticket(car.getCarNum());
}
throw new ParkingFullException();
}
public Car pickUpBy(Ticket ticket) {
return carPool.stream()
.filter(car -> car.getCarNum().equals(ticket.getCarNum()))
.findFirst()
.orElse(null);
}
}
测试通过,但是观察上面的代码发现count
属性可以通过carPool
的size来表示,于是进行重构优化:
public class ParkingLot {
private int capacity;
private List carPool = new ArrayList<>();
public ParkingLot(int capacity) {
this.capacity = capacity;
}
public Ticket park(Car car) {
if (carPool.size() < capacity) {
carPool.add(car);
return new Ticket(car.getCarNum());
}
throw new ParkingFullException();
}
public Car pickUpBy(Ticket ticket) {
return carPool.stream()
.filter(car -> car.getCarNum().equals(ticket.getCarNum()))
.findFirst()
.orElse(null);
}
}
重构再次观察,发现取车的时候需要循环遍历集合,如果这里通过Map
的方式获取,把Ticket
作为key也许会更高效合理,并且有些代码具备一些逻辑含义,如果单独抽出来作为一个方法这样语义上读起来会更好理解,重构如下:
public class ParkingLot {
private int capacity;
private Map carPool = new HashMap<>();
public ParkingLot(int capacity) {
this.capacity = capacity;
}
public Ticket park(Car car) {
if (isAvailable()) {
return putCarAndGetTicket(car, myCar -> {
Ticket ticket = new Ticket(myCar.getCarNum());
carPool.put(ticket, myCar);
return ticket;
});
}
throw new ParkingFullException();
}
private Ticket putCarAndGetTicket(Car car, Function carTicketFunction) {
return carTicketFunction.apply(car);
}
public boolean isAvailable() {
return carPool.size() < capacity;
}
public Car pickUpBy(Ticket ticket) {
if (hasCar(ticket)) {
Car pickedCar = carPool.get(ticket);
carPool.remove(ticket);
return pickedCar;
}
return null;
}
private boolean hasCar(Ticket ticket) {
return carPool.containsKey(ticket);
}
}
因为Ticket
作为Map的key,而此时我的需求只需要保证Ticket
中的车牌码匹配一致即可取车,所以我重写了Ticket
的equals
和hashCode
@Override
public boolean equals(Object that) {
if (this == that) {
return true;
}
if (that == null || getClass() != that.getClass()) {
return false;
}
Ticket ticket = (Ticket) that;
return Objects.equals(carNum, ticket.carNum);
}
@Override
public int hashCode() {
return Objects.hash(carNum);
}
重构后记住再次运行测试保证测试通过。
编写第五个任务测试
5. (given)在停车场用一张车牌不匹配的停车票(when)去取车,(then)取车失败
@Test
public void should_get_null_when_pick_up_with_wrong_ticket_in_parkinglot() {
ParkingLot parkingLot = new ParkingLot(1);
Ticket wrongTicket = new Ticket("Error Car Number");
assertNull(parkingLot.pickUpBy(wrongTicket));
}
写完测试,发现不用写额外代码即可通过,说明存在之前一样的问题,通过比较上一步的实现,发现这个任务实际上包含在了第四个任务中,故可选择删除当前case。
编写第六个任务测试
6. (given)在停车场用一张已用过的停车票(when)去取自己的车,(then)取车失败
@Test
public void should_throw_exception_when_pick_up_car_with_used_ticket_in_parkinglot_where_my_car_parked() {
ParkingLot parkingLot = new ParkingLot(1);
Car myCar = new Car("川AE0000");
Ticket oldTicket = parkingLot.park(myCar);
assertNotNull(parkingLot.pickUpCar(oldTicket);
assertNotNull(parkingLot.park(myCar));
assertThrows(InvalidTicketException.class, () -> parkingLot.pickUpBy(odlTicket));
}
这里校验的逻辑是先停一辆车,然后得到第一张票,再取车,再次停车,再用第一次的票取车,而此时第一张票已经使用过所以不应该取车成功。
为保证编译通过,创建InvalidTicketException
异常,运行测试,失败;这个时候需要在pickUpBy
里面校验停车票的有效性,而停车票的有效与否应该是属于票本身的状态。故修改代码如下:
public class InvalidTicketException extends RuntimeException {
}
@Getter
@Setter
public class Ticket {
......
private boolean isUsed = false; //新增代码
......
}
public class ParkingLot {
......
public Car pickUpBy(Ticket ticket) {
if (ticket.isUsed()) {
throw new InvalidTicketException();
}
if (hasCar(ticket)) {
Car pickedCar = carPool.get(ticket);
ticket.setUsed(true);
carPool.remove(ticket);
return pickedCar;
}
return null;
}
......
}
再次运行测试,通过,各位小伙伴别忘了检查是否需要重构优化,这里我暂时略过。
自此一个停车场可以停车、取车的需求就完成了,做完后和以前拿到需求一口气做完相比大家有什么感受?我个人的一些感觉是
- 这种小步快跑的方式和逐步优化比以前一口气全部做完要更顺畅并且由于每次专注单个需求,能够对细节有更多的思考
- 需求做完的同时,测试代码也完成了,对以后再次重构或者新需求的变化有一个保障
- 从测试代码出发能够站在使用者的角度去思考行为
- 每次只做刚刚好的事,简单设计,体现了一个不断优化,完善的过程,避免了我过多的焦虑
欢迎各位小伙伴在下面留言分享自己的感受~
如果感觉这个需求还不够,想继续用TDD的方式练习,可参考下面进阶需求自行尝试拆分任务
ParkingLot进阶练习
这里新增了一个角色:ParkingBoy,顾客停车不再需要自己操作,他只管交给ParkingBoy去做就好,而ParkingBoy下面可能存在多个停车场,他需要按照顺序依次停放,例如当第一个停车场满了再去第二个停车场停车,以此类推。顾客取车同样也是ParkingBoy去做
更进一步需求练习:
需求1:
需求2:
需求3:
总结
TDD记住简单设计,SOLID不是万精油,时刻记着重构,TDD任务拆分粒度控制,重构时除了对象的思想还需要有一些设计模式的考虑。