【JUnit实战3_33】第二十章:用 JUnit 5 进行测试驱动开发(TDD)(下)——TDD 项目的重构过程及新功能的开发实战

《JUnit in Action》全新第3版封面截图

写在前面

有了上篇构建的 TDD 基础,本节重点介绍 TDD 项目的重构过程,以及基于 TDD 进行新功能特性开发的具体步骤。干货满满,一起来先睹为快吧。

第五部分:用 JUnit 5 开发应用程序

第二十章:用 JUnit 5 进行测试驱动开发

20.3 实战2:改造为带单元测试的准 TDD 版

(接 上篇)......如图所示,一些方法和分支并没有走到,说明源代码还有重构的空间。

20.4 实战3:项目重构的正确打开方式

根据测试覆盖率报表详情可知,未被覆盖的代码都和 flightType 字段相关;但是 switch 分支要是少了 default 子句也会无法编译。因此应该跳出原来的思维框架,将写到核心逻辑的类型判定部分(静态)转换成基于多态的动态判定才是关键。有了多态,flightType 就可以直接删掉了,之前的条件判定也可以转化为各自不同的子类实现,无论是可读性还是项目的可扩展性都得到了极大的提升:

于是可以先删掉 flightType 字段,并让 Flight 成为抽象基类,让具体类型的子类去实现基类中的抽象方法。改造后的 Flight 实体类变为:

java 复制代码
public abstract class Flight {

    private final String id;
    protected List<Passenger> passengers = new ArrayList<>();

    public Flight(String id) {
        this.id = id;
    }

    public String getId() {
        return id;
    }

    public List<Passenger> getPassengersList() {
        return Collections.unmodifiableList(passengers);
    }

    public abstract boolean addPassenger(Passenger passenger);

    public abstract boolean removePassenger(Passenger passenger);

}

注意 L4protected 修饰符,以及末尾的两个抽象方法。这样,经济舱和商务舱的实现就简单多了:

java 复制代码
// 经济舱航班实体
public class EconomyFlight extends Flight {

    public EconomyFlight(String id) {
        super(id);
    }

    @Override
    public boolean addPassenger(Passenger passenger) {
        return passengers.add(passenger);
    }

    @Override
    public boolean removePassenger(Passenger passenger) {
        if (!passenger.isVip()) {
            return passengers.remove(passenger);
        }
        return false;
    }

}

// 商务舱航班实体
public class BusinessFlight extends Flight {

    public BusinessFlight(String id) {
        super(id);
    }

    @Override
    public boolean addPassenger(Passenger passenger) {
        if (passenger.isVip()) {
            return passengers.add(passenger);
        }
        return false;
    }

    @Override
    public boolean removePassenger(Passenger passenger) {
        return false;
    }

}

最后,再同步修改测试用例,将经济舱和商务舱的实例对象分别改成对应子类的初始化(L11L23):

java 复制代码
public class AirportTest {

    @DisplayName("Given there is an economy flight")
    @Nested
    class EconomyFlightTest {

        private Flight economyFlight;

        @BeforeEach
        void setUp() {
            economyFlight = new EconomyFlight("1");
        }
        // -- snip --
    }

    @DisplayName("Given there is a business flight")
    @Nested
    class BusinessFlightTest {
        private Flight businessFlight;

        @BeforeEach
        void setUp() {
            businessFlight = new BusinessFlight("2");
        }
        // -- snip --
    }
}

再次查看覆盖率指标,所有代码行都覆盖了:

重构目标基本达成。

20.5 实战4:添加新特性------新的航班类型

接下来演示新特性的开发,看看一套标准的 TDD 开发流程究竟是怎样的。

新的需求描述如下:新增一个 高级航班 ,该航班只能添加 VIP 乘客,也可以任意移除乘客。添加和移除乘客的具体流程图如下:

利用之前的重构成果,可以直接创建一个子类 PremiumFlight 来描述高级航班。但在此之前,根据 Don Roberts 提出的 三次法则(the Rule of Three),需要先对测试用例再次进行重构。

小贴士:三次法则

三次法则原文摘录如下:

The first time you do something, you just do it. The second time you do something similar, you wince at the duplication, but you do the duplicate thing anyway. The third time you do something similar, you refactor.

首次遭遇,干就完了;梅开二度,虽有重复,忍忍也就过了;再次遭遇,事不过三,就该重构了。

具体解释详见:https://en.wikipedia.org/wiki/Rule_of_three_(computer_programming)

本例中,示例项目的子类将增加到三个,因此很有必要对测试逻辑进行一番重构。

演示的重构重心,放在了变量及其初始化的集中处理、JUnit 5 特性的改造、以及测试用例的可读性提升上。前两个考察编程的基本功,最后一项考察的是将测试用例作为项目文档的良好编程素养。实测时,我又提取了一些反复用到的集合对象:

java 复制代码
public class AirportTest {

    @DisplayName("Given there is an economy flight")
    @Nested
    class EconomyFlightTest {

        private Flight economyFlight;
        private Passenger mike;
        private Passenger james;

        @BeforeEach
        void setUp() {
            economyFlight = new EconomyFlight("1");
            mike = new Passenger("Mike", false);
            james = new Passenger("James", true);
        }

        @Nested
        @DisplayName("When we have a regular passenger")
        class RegularPassenger {
            @Test
            @DisplayName("Then you cannot add or remove him from a business flight")
            public void testEconomyFlightRegularPassenger() {
                final List<Passenger> passengers = economyFlight.getPassengersList();
                assertAll("Verify all conditions for a regular passenger and an economy flight",
                        () -> assertEquals("1", economyFlight.getId()),
                        () -> assertTrue(economyFlight.addPassenger(mike)),
                        () -> assertEquals(1, passengers.size()),
                        () -> assertEquals("Mike", passengers.get(0).getName()),

                        () -> assertTrue(economyFlight.removePassenger(mike)),
                        () -> assertEquals(0, passengers.size()));
            }
        }

        @Nested
        @DisplayName("When we have a VIP passenger")
        class VipPassenger {

            @Test
            @DisplayName("Then you can add him but cannot remove him from an economy flight")
            public void testEconomyFlightVipPassenger() {
                final List<Passenger> passengers = economyFlight.getPassengersList();
                assertAll("Verify all conditions for a VIP passenger and an economy flight",
                        () -> assertEquals("1", economyFlight.getId()),
                        () -> assertTrue(economyFlight.addPassenger(james)),
                        () -> assertEquals(1, passengers.size()),
                        () -> assertEquals("James", passengers.get(0).getName()),

                        () -> assertFalse(economyFlight.removePassenger(james)),
                        () -> assertEquals(1, passengers.size()));
            }
        }
    }

    @DisplayName("Given there is a business flight")
    @Nested
    class BusinessFlightTest {
        private Flight businessFlight;
        private Passenger mike;
        private Passenger james;

        @BeforeEach
        void setUp() {
            businessFlight = new BusinessFlight("2");
            mike = new Passenger("Mike", false);
            james = new Passenger("James", true);
        }

        @Nested
        @DisplayName("When we have a regular passenger")
        class RegularPassenger {
            @Test
            @DisplayName("Then you cannot add or remove him from a business flight")
            public void testBusinessFlightRegularPassenger() {
                final List<Passenger> passengers = businessFlight.getPassengersList();
                assertAll("",
                        () -> assertFalse(businessFlight.addPassenger(mike)),
                        () -> assertEquals(0, passengers.size()),
                        () -> assertFalse(businessFlight.removePassenger(mike)),
                        () -> assertEquals(0, passengers.size()));
            }
        }

        @Nested
        @DisplayName("When we have a VIP passenger")
        class VipPassenger {
            @Test
            @DisplayName("Then you can add him but cannot remove him from a business flight")
            public void testBusinessFlightVipPassenger() {
                final List<Passenger> passengers = businessFlight.getPassengersList();
                assertAll("",
                        () -> assertTrue(businessFlight.addPassenger(james)),
                        () -> assertEquals(1, passengers.size()),
                        () -> assertFalse(businessFlight.removePassenger(james)),
                        () -> assertEquals(1, passengers.size()));
            }
        }
    }
}

实测效果:

这之后,就可以创建新的子类实现、并追加新的测试套件了。和之前重构不同的是,新的子类实现逻辑先统一设为一个默认值(这里为 false):

java 复制代码
public class PremiumFlight extends Flight {

    public PremiumFlight(String id) {
        super(id);
    }

    @Override
    public boolean addPassenger(Passenger passenger) {
        return false;
    }

    @Override
    public boolean removePassenger(Passenger passenger) {
        return false;
    }

}

然后追加对应的测试套件:

java 复制代码
@Nested
@DisplayName("Given there is a premium flight")
class PremiumFlightTest {
    private Flight premiumFlight;
    private Passenger mike;
    private Passenger james;

    @BeforeEach
    void setUp() {
        premiumFlight = new PremiumFlight("3");
        mike = new Passenger("Mike", false);
        james = new Passenger("James", true);
    }

    @Nested
    @DisplayName("When we have a regular passenger")
    class RegularPassenger {
        @Test
        @DisplayName("Then you cannot add or remove him from a premium flight")
        public void testPremiumFlightRegularPassenger() {
            final List<Passenger> passengers = premiumFlight.getPassengersList();
            assertAll("Verify all conditions for a regular passenger and a premium flight",
                    () -> assertFalse(premiumFlight.addPassenger(mike)),
                    () -> assertEquals(0, passengers.size()),
                    () -> assertFalse(premiumFlight.removePassenger(mike)),
                    () -> assertEquals(0, passengers.size()));
        }
    }

    @Nested
    @DisplayName("When we have a VIP passenger")
    class VipPassenger {
        @Test
        @DisplayName("Verify all conditions for a VIP passenger and a premium flight")
        public void testPremiumFlightVipPassenger() {
            final List<Passenger> passengers = premiumFlight.getPassengersList();
            assertAll("Verify all conditions for a VIP passenger and a premium flight",
                    () -> assertTrue(premiumFlight.addPassenger(james)),
                    () -> assertEquals(1, passengers.size()),
                    () -> assertTrue(premiumFlight.removePassenger(james)),
                    () -> assertEquals(0, passengers.size()));
        }
    }
}

先试运行一次测试(报错断言在 L38~L40):

显然,问题出在操作乘客的两个方法都需要讨论乘客类型为 VIP 的情况,因此重新修改为:

java 复制代码
@Override
public boolean addPassenger(Passenger passenger) {
    if(passenger.isVip()) {
        return passengers.add(passenger);
    }
    return false;
}

@Override
public boolean removePassenger(Passenger passenger) {
    if(passenger.isVip()) {
        return passengers.remove(passenger);
    }
    return false;
}

再次执行测试,则顺利通过:

最后运行所有测试再次验证(均通过):

注意

由于使用了内部类作测试套件,实测时并没有一开始就运行所有测试,和书中略有不同;但是最后的全部验证必须要有。

20.6 实战5:添加新特性------每位乘客只能被添加一次

需求很明确,就是要在所有支持添加乘客的航班子类中,增加一个限制条件------不允许出现重复元素。

根据 TDD 的流程,应该测试先行。于是应该先分析出哪些情况下会允许添加乘客,然后利用重复测试的相关注解,去模拟添加多次的应用场景。通过逐一排查每种类型航班的 addPassenger() 方法,根据是否执行 passengers.add(passenger) 语句,列表分析如下(DIY 增补内容):

航班 乘客 是否支持添加乘客 是否需要补充测试用例
经济舱 普通乘客 ✔️ ✔️
经济舱 VIP 乘客 ✔️ ✔️
商务舱 普通乘客
商务舱 VIP 乘客 ✔️ ✔️
高级舱 普通乘客
高级舱 VIP 乘客 ✔️ ✔️

于是找到相应的测试套件,分别添加重复测试逻辑即可。以【经济舱航班 + 普通乘客】为例:

java 复制代码
@DisplayName("Then you cannot add him to an economy flight more than once")
@RepeatedTest(5)
public void testEconomyFlightRegularPassengerAddedOnlyOnce(RepetitionInfo repetitionInfo) {
    IntStream.range(0, repetitionInfo.getTotalRepetitions())
            .forEach(i -> economyFlight.addPassenger(mike));
    final List<Passenger> passengers = economyFlight.getPassengersList();
    assertAll("Verify a regular passenger can be added to an economy flight only once",
            () -> assertEquals(1, passengers.size()),
            () -> assertTrue(passengers.contains(mike)),
            () -> assertEquals("Mike", passengers.get(0).getName())
    );
}

以此类推到其他三种场景下,最后运行测试,得到如下结果:

观察报错结果发现,问题出在 List<Passenger> 天然不支持去重功能,除非使用 Set<Passenger>。于是改造基类 Flightpassengers 成员属性的类型:

java 复制代码
public abstract class Flight {
    // -- snip --
    protected Set<Passenger> passengers = new HashSet<>();
    public Set<Passenger> getPassengersSet() {
        return Collections.unmodifiableSet(passengers);
    }
    // -- snip --
}

这样一来,passengers 就天然支持去重了;但由于改了方法签名,原测试中所有受影响的用例都要同步更新。利用 IDEA 的智能提示可以快速定位并更正:

逐一修改完成后,再次运行所有测试:

注意

实测时发现,添加去重功能很容易止步于在每个子类的 addPassenger() 方法内添加重复校验逻辑,而不会想到统一修改基类属性 passengers 的类型。按照三次法则的建议,就算像这样手动修改了三次,最后也应该主动思考如何重构上述逻辑,尽可能减少代码冗余。

通过一定的练习,最终达到透过现象看本质的目的。

相关推荐
爱学测试的雨果5 小时前
软件测试面试题总结【含答案】
功能测试·测试工具·面试
测试19989 小时前
如何用Appium实现移动端UI自动化测试?
自动化测试·软件测试·python·测试工具·ui·职场和发展·appium
百***341312 小时前
Nginx实现接口复制
运维·nginx·junit
测试老哥1 天前
软件测试之单元测试知识总结
自动化测试·软件测试·python·测试工具·职场和发展·单元测试·测试用例
m0_565611131 天前
Java高级特性:单元测试、反射、注解、动态代理
java·单元测试·log4j
稻香味秋天1 天前
单元测试指南
数据库·sqlserver·单元测试
程序员三藏1 天前
一文了解UI自动化测试
自动化测试·软件测试·python·selenium·测试工具·职场和发展·测试用例
tryCbest2 天前
Selenium中XPath定位元素详细教程
selenium·测试工具