浅解 JUnit 4 第十九篇:用 Statement 来封装测试过程中的一个动作

2 阅读7分钟

背景

按照我个人的理解,JUnit 4 最核心的类是以下四个

image.png

我们在前面的一些文章里已经专门探讨过 TestClass/Runner/RunnerBuilder ⬇️

本文会初步探讨 Statement 这个类背后的思想。

正文

生活中的例子:播放电视剧

我们从生活中的例子入手。现在需要写一些类,用于模拟播放一部电视剧的过程。在写代码之前,我们可以先思考一下 ⬇️

  • 无论是播放 一整部剧 还是只播放其中的 一集,都有“播放”这个行为。所以我们可以给它们定义一个公共的接口 Player
  • 播放一整部剧时,需要把这部剧包含的每一集播放出来,所以这里可以借鉴 组合模式 或者 迭代器模式 的思想(代码不必太拘泥,有它们的思想即可)
  • 每一集在播放时,可能又有各自的特殊逻辑,例如第一集需要播放广告,第二集没有广告之类,所以这里可以借鉴 装饰器模式 的思想(代码不必太拘泥,有它的思想即可),播放每一集的行为还是用 Player 这个接口来描述,但我们可以用不同的子类来封装这些特殊逻辑

基于这些思考,我写出了如下的代码(为了行文方便,我把所有类都放在一个 java 文件里了,但是日常开发时,一般不会这样做)

import java.util.List;

/**
 * 这个接口用于表示播放电视剧(可以表示播放一集, 也可以表示播放完整的一部剧)
 */
interface Player {
    /**
     * 播放(可以播放一集, 也可以播放完整的一部剧)
     */
    void play();
}

/**
 * 这个类负责播放完整的一部剧
 */
class TVSeriesPlayer implements Player {

    /**
     * 每个 child 负责播放剧中的一集
     */
    private final List<Player> children;

    public TVSeriesPlayer(List<Player> episodePlayerList) {
        this.children = episodePlayerList;
    }

    @Override
    public void play() {
        boolean isFirstPlayer = true;
        for (Player childPlayer : children) {
            if (!isFirstPlayer) {
                System.out.println();
            }
            isFirstPlayer = false;

            childPlayer.play();
        }
    }
}

/**
 * 这个类负责播放一集电视剧
 */
class EpisodePlayer implements Player {

    private final String description;

    public EpisodePlayer(String description) {
        this.description = description;
    }

    @Override
    public void play() {
        doPlayOneEpisode();
    }

    private void doPlayOneEpisode() {
        System.out.printf(String.format("播放电视剧: [%s]%n", description));
        System.out.printf(String.format("[%s] 播放完了 %n", description));
    }
}

/**
 * 这个类负责播放一集电视剧, 且在播放它之前先播放广告
 */
class WithPreAdPlayer implements Player {

    private final Player nextPlayer;

    public WithPreAdPlayer(Player nextPlayer) {
        this.nextPlayer = nextPlayer;
    }

    private void playAdvertisementBeforeEpisode() {
        System.out.println("先播放了广告 x");
    }

    @Override
    public void play() {
        // 在播放电视剧之前, 先播放广告
        playAdvertisementBeforeEpisode();

        nextPlayer.play();
    }
}

/**
 * 这个类负责播放一集电视剧, 且在播放它之后播放广告
 */
class WithPostAdPlayer implements Player {

    private final Player nextPlayer;

    public WithPostAdPlayer(Player nextPlayer) {
        this.nextPlayer = nextPlayer;
    }

    private void playAdvertisementBeforeEpisode() {
        System.out.println("之后播放了广告 y");
    }

    @Override
    public void play() {
        nextPlayer.play();

        // 在播放电视剧之后, 播放广告
        playAdvertisementBeforeEpisode();
    }
}

class DailyLifeExample {
    public static void main(String[] args) {
        Player playerForEpisode1 = new WithPostAdPlayer(
                new WithPreAdPlayer(new EpisodePlayer("第一集"))
        );
        Player playerForEpisode2 = new EpisodePlayer("第二集"); // 假设第二集不播放广告
        Player playerForEpisode3 = new WithPreAdPlayer(new EpisodePlayer("第三集"));

        List<Player> childPlayers = // 假设第三集很精彩, 要播放两次
                List.of(playerForEpisode1, playerForEpisode2, playerForEpisode3, playerForEpisode3);
        Player player = new TVSeriesPlayer(childPlayers);
        player.play();
    }
}

请将以上代码保存为 DailyLifeExample.java

编译和运行

执行下方的命令就可以编译 DailyLifeExample.java 以及运行其中的 main 方法

javac DailyLifeExample.java
java DailyLifeExample

运行结果如下 ⬇️

先播放了广告 x
播放电视剧: [第一集]
[第一集] 播放完了 
之后播放了广告 y

播放电视剧: [第二集]
[第二集] 播放完了 

先播放了广告 x
播放电视剧: [第三集]
[第三集] 播放完了 

先播放了广告 x
播放电视剧: [第三集]
[第三集] 播放完了

从输出结果来看,程序的运行符合预期

  • 在播放第一集前后都要播放广告
  • 在播放第二集前后都不要播放广告
  • 在播放第三集之前要播放广告(之后不播放广告)
  • 第三集要播放两次

分析

image.png

我用 Work Breakdown Structure 展示了调用 player.play() 时发生了什么 ⬇️

image.png

只要把这个例子看明白,其实也就明白了 org.junit.runners.model.Statement\text{org.junit.runners.model.Statement} 的核心思想。org.junit.runners.model.Statement\text{org.junit.runners.model.Statement} 的内容如下 ⬇️

image.png

其实它和我们定义的 Player 接口很相似,区别在于

  • Player 接口是对播放行为的抽象
  • Statement 抽象类是对测试时发生的 action 的抽象

在此基础上再去看 @Before/@After/@Before/@AfterClass 是如何生效的,应该就不难了。我在以下文章中,探讨了 JUnit 4 处理这四个注解的方式,有兴趣的读者可以参考。

其他

“背景”里的类图是如何画出来的?

我是用 PlantUML 的插件画的,代码如下

@startuml

title <i>JUnit 4</i> 中 <i>4</i> 个核心类的简要类图

interface org.junit.runners.model.Annotatable
class org.junit.runners.model.TestClass
interface org.junit.runner.Describable
abstract org.junit.runner.Runner
abstract org.junit.runners.model.RunnerBuilder
abstract org.junit.runners.model.Statement

org.junit.runners.model.Annotatable <|.. org.junit.runners.model.TestClass
org.junit.runner.Describable <|.. org.junit.runner.Runner

note "我觉得这四个类是核心的类" as N #orange
N -- org.junit.runners.model.TestClass #orange
N -- org.junit.runner.Runner #orange
N -- org.junit.runners.model.RunnerBuilder #orange
N -- org.junit.runners.model.Statement #orange

header 我认为 <i>JUnit 4</i> 中的核心类有以下四个\n<&star><i>TestClass</i>\n<&star><i>Runner</i>\n<&star><i>RunnerBuilder</i>\n<&star><i>Statement</i>

@enduml

“模拟播放电视剧行为, 所用到的各个类的类图”是如何画出来的?

我是用 PlantUML 的插件画的,代码如下

@startuml

title 模拟播放电视剧行为, 所用到的各个类的类图
caption \n\n
' caption 里的内容只是为了防止掘金平台自动生成的水印遮盖图中的内容

interface Player
class TVSeriesPlayer
class EpisodePlayer
class WithPreAdPlayer
class WithPostAdPlayer

interface Player {
    void play()
}

Player <|.. TVSeriesPlayer
Player <|.. EpisodePlayer
Player <|.. WithPreAdPlayer
Player <|.. WithPostAdPlayer

class TVSeriesPlayer {
    - final List<Player> children
    + TVSeriesPlayer(List<Player> episodePlayerList)
    + void play()
}

class EpisodePlayer {
    - final String description
    + EpisodePlayer(String description)
    + void play()
    - void doPlayOneEpisode()
}

class WithPostAdPlayer {
    - final Player nextPlayer
    + WithPostAdPlayer(Player nextPlayer)
    - void playAdvertisementBeforeEpisode()
    + void play()
}

class WithPreAdPlayer {
    - final Player nextPlayer
    + WithPreAdPlayer(Player nextPlayer)
    - void playAdvertisementBeforeEpisode()
    + void play()
}

note right of Player
这个接口用于表示播放电视剧
(可以表示播放一集, 也可以表示播放完整的一部剧)
end note

note bottom of TVSeriesPlayer
这个类负责播放完整的 <b>一部剧</b>
end note

note bottom of EpisodePlayer
这个类负责播放 <b>一集</b> 电视剧
end note

note bottom of WithPreAdPlayer
这个类负责播放一集电视剧,
且在播放它 <b>之前</b> 先播放广告
end note

note bottom of WithPostAdPlayer
这个类负责播放一集电视剧,
且在播放它 <b>之后</b> 播放广告
end note

@enduml

“调用 player.play() 时发生了什么”一图是如何画出来的?

我是用 PlantUML 的插件画的,代码如下

@startwbs
'https://plantuml.com/wbs-diagram

title 调用 <i>player.play()</i> 时发生了什么

* 调用 <i>player.play()</i>
**:第一个 <i>child</i> 执行 <i>play()</i> 方法
(我们把第一个 <i>child</i> 简称为 <i>c<sub>1</sub></i>,
它的精确类型是 <i>WithPostAdPlayer</i>)
<i>c<sub>1</sub></i> 的 <i>play()</i> 方法所做的事情 <:point_down:>;
***:调用 <i>nextPlayer.play()</i>
(<i>c<sub>1</sub></i> 中有 <i>nextPlayer</i> 字段,
后者的精确类型是 <i>WithPreAdPlayer</i>,
我们将后者简称为 <b><i>Player<sub>甲</sub></i></b>)
<i>Player<sub>甲</sub>.play()</i> 所做的事情 <:point_down:>;
****[#lightgreen] 播放前置的广告
****:调用 <i>nextPlayer.play()</i>
(<i>Player<sub>甲</sub></i> 里也有一个 <i>nextPlayer</i> 字段,
后者的精确类型是 <i>EpisodePlayer</i>,
我们将后者简称为 <b><i>Player<sub>乙</sub></i></b>)
<i>Player<sub>乙</sub>.play()</i> 所做的事情 <:point_down:>;
*****[#orange] 播放第一集
***[#lightgreen] 播放后置的广告

**:第二个 <i>child</i> 执行 <i>play()</i> 方法
(我们把第二个 <i>child</i> 简称为 <i>c<sub>2</sub></i>,
它的精确类型是 <i>EpisodePlayer</i>)
<i>c<sub>2</sub></i> 的 <i>play()</i> 方法所做的事情 <:point_down:>;
***[#orange] 播放第二集

**:第三个 <i>child</i> 执行 <i>play()</i> 方法
(我们把第三个 <i>child</i> 简称为 <i>c<sub>3</sub></i>,
它的精确类型是 <i>WithPreAdPlayer</i>)
<i>c<sub>3</sub></i> 的 <i>play()</i> 方法所做的事情 <:point_down:>;
***[#lightgreen] 播放前置的广告
***:调用 <i>nextPlayer.play()</i>
(<i>c<sub>3</sub></i> 中有 <i>nextPlayer</i> 字段,
后者的精确类型是 <i>EpisodePlayer</i>
我们将后者简称为 <b><i>Player<sub>丙</sub></i></b>)
<i>Player<sub>丙</sub>.play()</i> 方法所做的事情 <:point_down:>;
****[#orange] 播放第三集

**:第四个 <i>child</i> 执行 <i>play()</i> 方法
(第四个 <i>child</i> 和第三个 <i>child</i> 是同一个对象,
所以就不必再分析一遍了);
***_ ...

header 播放某一集的行为用 <b>橙色</b> 的节点来表示\n播放广告的行为用 <b>浅绿色</b> 的节点来表示
@endwbs