资讯专栏INFORMATION COLUMN

Programming DSL:JSpec

TANKING / 736人阅读

摘要:命名模式为了做到自动发现机制,在运行时完成用例的组织,规定所有的测试用例必须遵循的函数原型。在后文介绍,可以将理解为及其的运行时行为其中,对于于子句,对于于子句。将的执行序列行为固化。

There are two ways of constructing a software design. One way is to make it so simple that there are obviously no deficiencies. And the other way is to make it so complicated that there are no obvious deficiencies. -- C.A.R. Hoare

前世今生

本文是《Programming DSL》系列文章的第2篇,如果该主题感兴趣,可以查阅如下文章:

Programming DSL: Implements JHamcrest

正交设计

本文通过「JSpec」的设计和实现的过程,加深认识「内部DSL」设计的基本思路。JSpec是使用Java8实现的一个简单的「BDD」测试框架。

动机

Java社区中,JUnit是一个广泛被使用的测试框架。不幸的是,JUnit的测试用例必须遵循严格的「标识符」命名规则,给程序员带来了很大的不便。

命名模式

Junit为了做到「自动发现」机制,在运行时完成用例的组织,规定所有的测试用例必须遵循public void testXXX()的函数原型。

public void testTrue() {
  Assert.assertTrue(true);
}
注解

Java 1.5支持「注解」之后,社区逐步意识到了「注解优于命名模式」的最佳实践,JUnit使用@Test注解,增强了用例的表现力。

@Test
public void alwaysTrue() {
  Assert.assertTrue(true);
}
Given-When-Then

经过实践证明,基于场景验收的Given-When-Then命名风格具有强大的表现力。但JUnit遵循严格的标示符命名规则,程序员需要承受巨大的痛苦。

这种混杂「驼峰」和「下划线」的命名风格,虽然在社区中得到了广泛的应用,但在重命名时,变得非常不方便。

public class GivenAStack {
  @Test
  public void should_be_empty_when_created() { 
  }

  @Test
  public void should_pop_the_last_element_pushed_onto_the_stack() { 
  }
}
新贵

RSpec, Cucumber, Jasmine等为代表的[BDD」(Behavior-Driven Development)测试框架以强大的表现力,迅速得到了社区的广泛应用。其中,RSpec, Jasmine就是我较为喜爱的测试框架。例如,JasmineJavaScript测试用例是这样的。

describe("A suite", function() {
  it("contains spec with an expectation", function() {
    expect(true).toBe(true);
  });
});
JSpec

我们将尝试设计和实现一个Java版的BDD测试框架:JSpec。它的风格与Jasmine基本类似,并与Junit4配合得完美无瑕。

@RunWith(JSpec.class)
public class JSpecs {{
  describe("A spec", () -> {
    List items = new ArrayList<>();

    before(() -> {
      items.add("foo");
      items.add("bar");
    });

    after(() -> {
      items.clear();
    });

    it("runs the before() blocks", () -> {
      assertThat(items, contains("foo", "bar"));
    });

    describe("when nested", () -> {
      before(() -> {
        items.add("baz");
      });

      it("runs before and after from inner and outer scopes", () -> {
        assertThat(items, contains("foo", "bar", "baz"));
      });
    });
  });
}}
初始化块
public class JSpecs {{
  ......
}}

嵌套两层{},这是Java的一种特殊的初始化方法,常称为初始化块。其行为与如下代码类同,但它更加简洁、漂亮。

public class JSpecs {
  public JSpecs() {
    ......
  }
}
代码块

describe, it, before, after都存在一个() -> {...}代码块,以便实现行为的定制化,为此先抽象一个Block的概念。

@FunctionalInterface
public interface Block {
  void apply() throws Throwable;
}
雏形

定义如下几个函数,明确JSpec DSL的基本雏形。

public class JSpec {
  public static void describe(String desc, Block block) {
    ......
  }

  public static void it(String behavior, Block block) {
    ......
  }

  public static void before(Block block) {
    ......
  }

  public static void after(Block block) {
    ......
  }
上下文

describe可以嵌套describe, it, before, after的代码块,并且外层的describe给内嵌的代码块建立了「上下文」环境。

例如,items在最外层的describe中定义,它对describe整个内部都可见。

隐式树

describe可以嵌套describe,并且describe为内部的结构建立「上下文」,因此describe之间建立了一棵「隐式树」。

领域模型

为此,抽象出了Context的概念,用于描述describe的运行时。也就是是,Context描述了describe内部可见的几个重要实体:

List befores:before代码块集合

List afters:after代码块集合

Description desc:包含了父子之间的层次关系等上下文描述信息

Deque executors:执行器的集合。

Executor在后文介绍,可以将Executor理解为Context及其Spec的运行时行为;其中,Context对于于desribe子句,Spec对于于it子句。

因为describe之间存在「隐式树」的关系,ContextSpec之间也就形成了「隐式树」的关系。

参考实现
public class Context {

  private List befores = new ArrayList<>();
  private List afters = new ArrayList<>();

  private Deque executors = new ArrayDeque<>();
  private Description desc;
  
  public Context(Description desc) {
    this.desc = desc;
  }
  
  public void addChild(Context child) {
    desc.addChild(child.desc);
    executors.add(child);
    
    child.addBefore(collect(befores));
    child.addAfter(collect(afters));
  }

  public void addBefore(Block block) {
    befores.add(block);
  }

  public void addAfter(Block block) {
    afters.add(block);
  }

  public void addSpec(String behavior, Block block) {
    Description spec = createTestDescription(desc.getClassName(), behavior);
    desc.addChild(spec);
    addExecutor(spec, block);
  }

  private void addExecutor(Description desc, Block block) {
    Spec spec = new Spec(desc, blocksInContext(block));
    executors.add(spec);
  }

  private Block blocksInContext(Block block) {
    return collect(collect(befores), block, collect(afters));
  }
}
实现addChild

describe嵌套describe时,通过addChild完成了两件重要工作:

子Context」向「父Context」的注册;也就是说,Context之间形成了「树」形结构;

控制父Context中的before/after的代码块集合对子Context的可见性;

public void addChild(Context child) {
  desc.addChild(child.desc);
  executors.add(child);
    
  child.addBefore(collect(befores));
  child.addAfter(collect(afters));
}

其中,collect定义于Block接口中,完成before/after代码块「集合」的迭代处理。这类似于OO世界中的「组合模式」,它们代表了一种隐式的「树状结构」。

public interface Block {
  void apply() throws Throwable;

  static Block collect(Iterable blocks) {
    return () -> {
      for (Block b : blocks) {
        b.apply();
      }
    };
  }
}
实现addExecutor

其中,Executor存在两种情况:

Spec: 使用it定义的用例的代码块

Context: 使用describe定义上下文。

为此,addExecutoraddSpec, addChild所调用。addExecutor调用时,将Spec注册到Executor集合中,并定义了Spec的「执行规则」。

private void addExecutor(Description desc, Block block) {
    Spec spec = new Spec(desc, blocksInContext(block));
    executors.add(spec);
  }

  private Block blocksInContext(Block block) {
    return collect(collect(befores), block, collect(afters));
  }

blocksInContextit的「执行序列」行为固化。

首先执行before代码块集合;

然后执行it代码块;

最后执行after代码块集合;

抽象Executor

之前谈过,Executor存在两种情况:

Spec: 使用it定义的用例的代码块

Context: 使用describe定义上下文。

也就是说,Executor构成了一棵「树状」的数据结构;it扮演了「叶子节点」的角色;Context扮演了「非叶子节点」的角色。为此,Executor的设计采用了「组合模式」。

import org.junit.runner.notification.RunNotifier;

@FunctionalInterface
public interface Executor {
  void exec(RunNotifier notifier);
}
叶子节点:Spec

Spec完成对it行为的封装,当exec时完成it代码块() -> {...}的调用。

public class Spec implements Executor {

  public Spec(Description desc, Block block) {
    this.desc = desc;
    this.block = block;
  }

  @Override
  public void exec(RunNotifier notifier) {
    notifier.fireTestStarted(desc);
    runSpec(notifier);
    notifier.fireTestFinished(desc);
  }

  private void runSpec(RunNotifier notifier) {
    try {
      block.apply();
    } catch (Throwable t) {
      notifier.fireTestFailure(new Failure(desc, t));
    }
  }

  private Description desc;
  private Block block;
}
非叶子节点:Context
public class Context implements Executor {
  ......
  
  private Description desc;
  
  @Override
  public void exec(RunNotifier notifier) {
    for (Executor e : executors) {
      e.exec(notifier);
    }
  }
}
实现DSL

有了Context的领域模型的基础,DSL的实现变得简单了。

public class JSpec {
  private static Deque ctxts = new ArrayDeque();

  public static void describe(String desc, Block block) {
    Context ctxt = new Context(createSuiteDescription(desc));
    enterCtxt(ctxt, block);
  }

  public static void it(String behavior, Block block) {
    currentCtxt().addSpec(behavior, block);
  }

  public static void before(Block block) {
    currentCtxt().addBefore(block);
  }

  public static void after(Block block) {
    currentCtxt().addAfter(block);
  }

  private static void enterCtxt(Context ctxt, Block block) {
    currentCtxt().addChild(ctxt);
    applyBlock(ctxt, block);
  }

  private static void applyBlock(Context ctxt, Block block) {
    ctxts.push(ctxt);
    doApplyBlock(block);
    ctxts.pop();
  }

  private static void doApplyBlock(Block block) {
    try {
      block.apply();
    } catch (Throwable e) {
      it("happen to an error", failing(e));
    }
  }

  private static Context currentCtxt() {
    return ctxts.peek();
  }
}
上下文切换

但为了控制Context之间的「树型关系」(即describe的嵌套关系),为此建立了一个Stack的机制,保证运行时在某一个时刻Context的唯一性。

只有describe的调用会开启「上下文的建立」,并完成上下文「父子关系」的链接。其余操作,例如it, before, after都是在当前上下文进行「元信息」的注册。

虚拟的根结点

使用静态初始化块,完成「虚拟根结点」的注册;也就是说,在运行时初始化时,栈中已存在唯一的 Context("JSpec: All Specs")虚拟根节点。

public class JSpec {
  private static Deque ctxts = new ArrayDeque();

  static {
    ctxts.push(new Context(createSuiteDescription("JSpec: All Specs")));
  }
  
  ......
}
运行器

为了配合JUnit框架将JSpec运行起来,需要定制一个JUnitRunner

public class JSpec extends Runner {
  private Description desc;
  private Context root;

  public JSpec(Class suite) {
    desc = createSuiteDescription(suite);
    root = new Context(desc);
    enterCtxt(root, reflect(suite));
  }

  @Override
  public Description getDescription() {
    return desc;
  }

  @Override
  public void run(RunNotifier notifier) {
    root.exec(notifier);
  }
  
  ......
}

在编写用例时,使用@RunWith(JSpec.class)注解,告诉JUnit定制化了运行器的行为。

@RunWith(JSpec.class)
public class JSpecs {{
  ......
}}

在之前已讨论过,JSpecrun无非就是将「以树形组织的」Executor集合调度起来。

实现reflect

JUnit在运行时,首先看到了@RunWith(JSpec.class)注解,然后反射调用JSpec的构造函数。

public JSpec(Class suite) {
  desc = createSuiteDescription(suite);
  root = new Context(desc);
  enterCtxt(root, reflect(suite));
}

通过Block.reflect的工厂方法,将开始执行测试用例集的「初始化块」。

public interface Block {
  void apply() throws Throwable;
  
  static Block reflect(Class c) {
    return () -> {
      Constructor cons = c.getDeclaredConstructor();
      cons.setAccessible(true);
      cons.newInstance();
    };
  }
}

此刻,被@RunWith(JSpec.class)注解标注的「初始化块」被执行。

@RunWith(JSpec.class)
public class JSpecs {{
  ......
}}

在「初始化块」中顺序完成对describe, it, before, after等子句的调用,其中:

describe开辟新的Context

describe可以递归地调用内部嵌套的describe

describe调用it, before, after时,将信息注册到了Context中;

最终Runner.runExecutor集合按照「树」的组织方式调度起来;

GitHub

JSpec已上传至GitHub:https://github.com/horance-liu/jspec,代码细节请参考源代码。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/65594.html

相关文章

  • Programming DSL: JMatchers

    摘要:袁英杰回顾设计上次在软件匠艺小组上分享了正交设计的基本理论,原则和应用,在活动线下收到了很多朋友的反馈。强迫用户虽然的设计高度可复用性,可由用户根据实际情况,自由拼装组合各种算子。鸣谢正交设计的理论原则及其方法论出自前软件大师袁英杰先生。 软件设计是一个「守破离」的过程。 --袁英杰 回顾设计 上次在「软件匠艺小组」上分享了「正交设计」的基本理论,原则和应用,在活动线下收到了很多朋友的...

    Yuanf 评论0 收藏0
  • 谈谈 DSL 以及 DSL 的应用(以 CocoaPods 为例)

    摘要:与相对,与传统意义上的通用编程语言以及完全不同。在上一节讲到的其实可以被称为外部而这里即将谈到的嵌入式也有一个别名,内部。 最近在公司做了一次有关 DSL 在 iOS 开发中的应用的分享,这篇文章会简单介绍这次分享的内容。 因为 DSL 以及 DSL 的界定本身就是一个比较模糊的概念,所以难免有与他人观点意见相左的地方,如果有不同的意见,我们可以具体讨论。 这次文章的题目虽然是谈谈...

    felix0913 评论0 收藏0
  • 【jOOQ中文】2. jOOQ与Spring和Druid整合

    摘要:在这个例子中,我们将整合但您也可以使用其他连接池,如,,等。作为构建和执行。 jOOQ和Spring很容易整合。 在这个例子中,我们将整合: Alibaba Druid(但您也可以使用其他连接池,如BoneCP,C3P0,DBCP等)。 Spring TX作为事物管理library。 jOOQ作为SQL构建和执行library。 一、准备数据库 DROP TABLE IF EXIS...

    pingink 评论0 收藏0
  • Kotlin 资源大全

    摘要:联系方式开发交流群扫描二维码添加小编好友,备注,稍后会拉你进群 目录 介绍 官网及文档 中文社区 教程 & 文章 开源库和框架 Demo 其他 介绍 今天凌晨的 Google I/O 上,Google 正式宣布官方支持 Kotlin. 为了让大家更快了解和上手 Kotlin,掘金技术社区为大家整理了这份 Kotlin 资源大全,希望可以帮助大家用最短时间学习 Kotlin. 官...

    VPointer 评论0 收藏0
  • 手把手教你从零写一个简单的 VUE--模板篇

    摘要:转换成为模板函数联系上一篇文章,其实模板函数的构造都大同小异,基本是都是通过拼接函数字符串,然后通过对象转换成一个函数,变成一个函数之后,只要传入对应的数据,函数就会返回一个模板数据渲染好的字符串。 教程目录1.手把手教你从零写一个简单的 VUE2.手把手教你从零写一个简单的 VUE--模板篇 Hello,我又回来了,上一次的文章教会了大家如何书写一个简单 VUE,里面实现了VUE 的...

    feng409 评论0 收藏0

发表评论

0条评论

TANKING

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<