開發(fā)者測試: 實現(xiàn)BDD測試框架(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

最近在做一些開發(fā)者測試的相關工作,有感寫了幾篇相關的文章初斑。設計測試用例所追求地目標之一辛润,便是“Test as Document”。因此见秤,測試的表達力決定了用例的靈魂砂竖,也是反映業(yè)務最直觀的方式。

開發(fā)DSL(領域描述語言)是一種有效改進用例的方法之一鹃答。本文通過「JSpec」的設計和實現(xiàn)的過程乎澄,加深認識「內部DSL」設計的基本思路。JSpec是一個使用Java8實現(xiàn)的「BDD」測試框架测摔。

當然置济,Java社區(qū)的BDD框架多如牛毛,此處其目標是介紹DSL的構造和應用技術避咆,并非BDD框架本身舟肉。

動機

Java社區(qū)中,JUnit是一個廣泛被使用的測試框架查库。不幸的是路媚,JUnit的測試用例必須遵循嚴格的「標識符」命名規(guī)則,給程序員帶來了很大的不便樊销。

命名模式

Junit為了做到「自動發(fā)現(xiàn)」機制整慎,在運行時完成用例的組織脏款,規(guī)定所有的測試用例必須遵循public void testXXX()的函數(shù)原型。

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

注解

Java 1.5支持「注解」之后裤园,社區(qū)逐步意識到了「注解優(yōu)于命名模式」的最佳實踐撤师,JUnit使用@Test注解,增強了用例的表現(xiàn)力拧揽。

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

Given-When-Then

經過實踐證明剃盾,基于場景驗收的Given-When-Then命名風格具有強大的表現(xiàn)力。但JUnit遵循嚴格的標示符命名規(guī)則淤袜,程序員需要承受巨大的痛苦馋嗜。

這種混雜「駝峰」和「下劃線」的命名風格碾篡,雖然在社區(qū)中得到了廣泛的應用肖揣,但在重命名時惕医,變得非常不方便。

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)測試框架以強大的表現(xiàn)力烦周,迅速得到了社區(qū)的廣泛應用尽爆。其中,RSpec, Jasmine就是我較為喜愛的測試框架读慎。例如漱贱,JasmineJavaScript測試用例是這樣的。

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

JSpec

我們將嘗試設計和實現(xiàn)一個Java版的BDD測試框架:JSpec贪壳。它的風格與Jasmine基本類似饱亿,并與Junit4配合得完美無瑕。

另外闰靴,將此框架名為“JSpec”,以此致敬我所鐘愛的一款使用Ruby實現(xiàn)的測試框架“RSpec”钻注,其用例風格大致如下代碼所示蚂且。

@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都存在一個() -> {...}代碼塊,以便實現(xiàn)行為的定制化品追,為此先抽象一個Block的概念玄括。

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

雛形

定義如下幾個函數(shù),明確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給內嵌的代碼塊建立了「上下文」環(huán)境胃惜。

例如,items在最外層的describe中定義哪雕,它對describe整個內部都可見船殉。

隱式樹

describe可以嵌套describe,并且describe為內部的結構建立「上下文」斯嚎,因此describe之間建立了一棵「隱式樹」利虫。

領域模型

為此,抽象出了Context的概念堡僻,用于描述describe的運行時糠惫。也就是是,Context描述了describe內部可見的幾個重要實體:

  • List<Block> befores:before代碼塊集合
  • List<Block> afters:after代碼塊集合
  • Description desc:包含了父子之間的層次關系等上下文描述信息
  • Deque<Executor> executors:執(zhí)行器的集合苦始。

Executor在后文介紹寞钥,可以將Executor理解為Context及其Spec的運行時行為;其中陌选,Context對于于desribe子句理郑,Spec對于于it子句。

因為describe之間存在「隱式樹」的關系咨油,ContextSpec之間也就形成了「隱式樹」的關系您炉。

參考實現(xiàn)

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));
  }
}

實現(xiàn)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();
      }
    };
  }
}

實現(xiàn)addExecutor

其中酥夭,Executor存在兩種情況:

  • Spec: 使用it定義的用例的代碼塊
  • Context: 使用describe定義上下文赐纱。

為此,addExecutoraddSpec, addChild所調用熬北。addExecutor調用時疙描,將Spec注冊到Executor集合中,并定義了Spec的「執(zhí)行規(guī)則」讶隐。

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的「執(zhí)行序列」行為固化起胰。

  • 首先執(zhí)行before代碼塊集合;
  • 然后執(zhí)行it代碼塊整份;
  • 最后執(zhí)行after代碼塊集合待错;

抽象Executor

之前談過籽孙,Executor存在兩種情況:

  • Spec: 使用it定義的用例的代碼塊
  • Context: 使用describe定義上下文。

也就是說火俄,Executor構成了一棵「樹狀」的數(shù)據(jù)結構犯建;it扮演了「葉子節(jié)點」的角色;Context扮演了「非葉子節(jié)點」的角色瓜客。為此适瓦,Executor的設計采用了「組合模式」。

import org.junit.runner.notification.RunNotifier;

@FunctionalInterface
public interface Executor {
  void exec(RunNotifier notifier);
}

葉子節(jié)點: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;
}

非葉子節(jié)點:Context

public class Context implements Executor {
  ......
  
  private Description desc;
  
  @Override
  public void exec(RunNotifier notifier) {
    for (Executor e : executors) {
      e.exec(notifier);
    }
  }
}

實現(xiàn)DSL

有了Context的領域模型的基礎,DSL的實現(xiàn)變得簡單了疯攒。

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都是在當前上下文進行「元信息」的注冊署恍。

虛擬的根結點

使用靜態(tài)初始化塊,完成「虛擬根結點」的注冊蜻直;也就是說盯质,在運行時初始化時,棧中已存在唯一的Context("JSpec: All Specs")虛擬根節(jié)點概而。

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集合調度起來。

實現(xiàn)reflect

JUnit在運行時啤咽,首先看到了@RunWith(JSpec.class)注解晋辆,然后反射調用JSpec的構造函數(shù)。

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

通過Block.reflect的工廠方法宇整,將開始執(zhí)行測試用例集的「初始化塊」瓶佳。

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)注解標注的「初始化塊」被執(zhí)行鳞青。

@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傻工,代碼細節(jié)請參考源代碼霞溪。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市中捆,隨后出現(xiàn)的幾起案子鸯匹,更是在濱河造成了極大的恐慌,老刑警劉巖泄伪,帶你破解...
    沈念sama閱讀 206,126評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件殴蓬,死亡現(xiàn)場離奇詭異,居然都是意外死亡蟋滴,警方通過查閱死者的電腦和手機染厅,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,254評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來脓杉,“玉大人糟秘,你說我怎么就攤上這事∏蛏ⅲ” “怎么了尿赚?”我有些...
    開封第一講書人閱讀 152,445評論 0 341
  • 文/不壞的土叔 我叫張陵,是天一觀的道長蕉堰。 經常有香客問我凌净,道長,這世上最難降的妖魔是什么屋讶? 我笑而不...
    開封第一講書人閱讀 55,185評論 1 278
  • 正文 為了忘掉前任冰寻,我火速辦了婚禮,結果婚禮上皿渗,老公的妹妹穿的比我還像新娘斩芭。我一直安慰自己,他們只是感情好乐疆,可當我...
    茶點故事閱讀 64,178評論 5 371
  • 文/花漫 我一把揭開白布划乖。 她就那樣靜靜地躺著,像睡著了一般挤土。 火紅的嫁衣襯著肌膚如雪琴庵。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 48,970評論 1 284
  • 那天,我揣著相機與錄音迷殿,去河邊找鬼儿礼。 笑死,一個胖子當著我的面吹牛庆寺,可吹牛的內容都是我干的蚊夫。 我是一名探鬼主播,決...
    沈念sama閱讀 38,276評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼止邮,長吁一口氣:“原來是場噩夢啊……” “哼这橙!你這毒婦竟也來了?” 一聲冷哼從身側響起导披,我...
    開封第一講書人閱讀 36,927評論 0 259
  • 序言:老撾萬榮一對情侶失蹤屈扎,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后撩匕,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體鹰晨,經...
    沈念sama閱讀 43,400評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 35,883評論 2 323
  • 正文 我和宋清朗相戀三年止毕,在試婚紗的時候發(fā)現(xiàn)自己被綠了模蜡。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 37,997評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡扁凛,死狀恐怖忍疾,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情谨朝,我是刑警寧澤卤妒,帶...
    沈念sama閱讀 33,646評論 4 322
  • 正文 年R本政府宣布,位于F島的核電站字币,受9級特大地震影響则披,放射性物質發(fā)生泄漏。R本人自食惡果不足惜洗出,卻給世界環(huán)境...
    茶點故事閱讀 39,213評論 3 307
  • 文/蒙蒙 一士复、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧翩活,春花似錦阱洪、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,204評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至辟犀,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背堂竟。 一陣腳步聲響...
    開封第一講書人閱讀 31,423評論 1 260
  • 我被黑心中介騙來泰國打工魂毁, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人出嘹。 一個月前我還...
    沈念sama閱讀 45,423評論 2 352
  • 正文 我出身青樓席楚,卻偏偏與公主長得像,于是被迫代替她去往敵國和親税稼。 傳聞我的和親對象是個殘疾皇子烦秩,可洞房花燭夜當晚...
    茶點故事閱讀 42,722評論 2 345