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
就是我較為喜愛的測試框架读慎。例如漱贱,Jasmine
的JavaScript
測試用例是這樣的。
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
之間存在「隱式樹」的關系咨油,Context
及Spec
之間也就形成了「隱式樹」的關系您炉。
參考實現(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
定義上下文赐纱。
為此,addExecutor
被addSpec, 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));
}
blocksInContext
將it
的「執(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
運行起來呼巷,需要定制一個JUnit
的Runner
。
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 {{
......
}}
在之前已討論過乡范,JSpec
的run
無非就是將「以樹形組織的」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.run
將Executor
集合按照「樹」的組織方式調度起來;
GitHub
JSpec
已上傳至GitHub:
https://github.com/horance-liu/jspec傻工,代碼細節(jié)請參考源代碼霞溪。