DSL编程: Implements JSpec

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

本文通过「JSpec」的设计和实现的过程,加深认识「内部DSL」设计的基本思路。其中,JSpec是一个基于JUnit执行引擎,使用Java8实现的「BDD」测试框架。

JSpec

JSpec风格与JavaScriptJasmine框架风格类似,并与Junit4结合近乎完美无瑕。

@RunWith(JSpec.class)
public class JSpecs {{
  describe("A spec", () -> {
    List<String> 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<Block> befores:before代码块集合
  • List<Block> afters:after代码块集合
  • Description desc:包含了父子之间的层次关系等上下文描述信息
  • Deque<Executor> executors:执行器的集合。

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

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

参考实现

public class Context {

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

  private Deque<Executor> 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<? extends Block> 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 List<Executor> executors;
  
  @Override
  public void exec(RunNotifier notifier) {
    for (Executor e : executors) {
      e.exec(notifier);
    }
  }
}

实现DSL

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

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

  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<Context> ctxts = new ArrayDeque<Context>();

  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集合按照「树」的组织方式调度起来;

源代码

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 207,248评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,681评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,443评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,475评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,458评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,185评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,451评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,112评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,609评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,083评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,163评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,803评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,357评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,357评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,590评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,636评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,925评论 2 344

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,601评论 18 139
  • 个人笔记,方便自己查阅使用 Contents Java LangAssignment, ReferenceData...
    freenik阅读 1,368评论 0 6
  • 1. 简介 1.1 什么是 MyBatis ? MyBatis 是支持定制化 SQL、存储过程以及高级映射的优秀的...
    笨鸟慢飞阅读 5,429评论 0 4
  • 《销售思想、技术、能力》作者:刘明亮——来自创始美国友邦保险公司的经验!敬请斧正指教!(代恩師劉明亮老先生轉發) ...
    張蕾馥阅读 395评论 0 0
  • 从六年级开始一直在外地上学,聚少离多的。所以今年暑假没有在外地,回家了,主要目的——陪家人。 想想这一个月在家里,...
    金雪娃娃阅读 1,360评论 0 0