Programming DSL: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. Hoarejava

前世此生

本文是《Programming DSL》系列文章的第2篇,若是該主題感興趣,能夠查閱以下文章:git

本文經過「JSpec」的設計和實現的過程,加深認識「內部DSL」設計的基本思路。JSpec是使用Java8實現的一個簡單的「BDD」測試框架。數據結構

動機

Java社區中,JUnit是一個普遍被使用的測試框架。不幸的是,JUnit的測試用例必須遵循嚴格的「標識符」命名規則,給程序員帶來了很大的不便。app

命名模式

Junit爲了作到「自動發現」機制,在運行時完成用例的組織,規定全部的測試用例必須遵循public void testXXX()的函數原型。框架

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

註解

Java 1.5支持「註解」以後,社區逐步意識到了「註解優於命名模式」的最佳實踐,JUnit使用@Test註解,加強了用例的表現力。jsp

@Test
public void alwaysTrue() {
  Assert.assertTrue(true);
}

Given-When-Then

通過實踐證實,基於場景驗收的Given-When-Then命名風格具備強大的表現力。但JUnit遵循嚴格的標示符命名規則,程序員須要承受巨大的痛苦。ide

這種混雜「駝峯」和「下劃線」的命名風格,雖然在社區中獲得了普遍的應用,但在重命名時,變得很是不方便。函數

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<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 Description desc;
  
  @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集合按照「樹」的組織方式調度起來;

GitHub

JSpec已上傳至GitHub:https://github.com/horance-liu/jspec,代碼細節請參考源代碼。

相關文章
相關標籤/搜索